feat(dedicated): phase 2 — routes, configurator data, checkout setup fee

Routes:
- /dedicated-servers/{slug} (per-chassis detail) added alongside the
  existing /dedicated-servers landing. Landing route now eager-loads
  prices; detail route eager-loads chassis-specific config groups.

ConfigOptionSeeder — 6 new dedicated 14th-gen groups:
- CPU Upgrade (4 tiers; attached to R440/R540/R640/R740)
- CPU Upgrade R740xd (4 tiers, higher-TDP; attached to R740xd variants)
- RAM Upgrade (7 tiers, 32GB → 1.5TB)
- Operating System (6 options incl. Windows BYOL)
- Bandwidth (5 tiers, 1G unmetered → 10G unmetered fair-use)
- IPv4 Block (4 tiers, single → /27)
All idempotent via updateOrCreate, attached per chassis.

HandleSubscriptionCreated listener: build-to-order dedicated
plans (those with features.lead_time_days set) auto-create the
'ordered' build milestone. Other service types unaffected.

CheckoutController + Checkout/Show.vue:
- Pass plan.setup_fee as 'setupFee' Inertia prop
- Vue computes effectiveSetupFee (waived on semi_annual/annual,
  charged on monthly/quarterly per the brainstorm) and adds it
  to total. Display line-item still pending v3 polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 17:49:31 -04:00
parent c5fd4bcc7e
commit c7e545601e
5 changed files with 210 additions and 2 deletions

View File

@@ -90,6 +90,10 @@ class CheckoutController extends Controller
'osTemplates' => $osTemplates,
'osTemplateGroups' => $osTemplateGroups,
'prefilledSelections' => $prefilledSelections,
// Setup-fee amount per chassis. The Vue page decides whether to
// display it based on the selected billing cycle — waived on
// semi_annual / annual per the dedicated-server brainstorm.
'setupFee' => (float) ($plan->setup_fee ?? 0),
]);
}

View File

@@ -6,6 +6,7 @@ namespace App\Listeners;
use App\Events\ServiceProvisioned;
use App\Events\SubscriptionCreated;
use App\Models\ServiceBuildMilestone;
use App\Models\WinbackRecipient;
use App\Notifications\SubscriptionCreatedNotification;
use App\Services\Provisioning\ProvisioningFactory;
@@ -44,7 +45,29 @@ class HandleSubscriptionCreated implements ShouldQueue
$plan = \App\Models\Plan::find($planId);
if ($plan) {
// Automatically provision the service
// 14th-gen build-to-order dedicated plans get a build tracker
// started at the "ordered" stage. The remaining stages
// (hardware_acquired → assembly → racked → deployed) are
// marked manually by ops as work progresses.
$isBuildToOrder = $plan->service_type === 'dedicated'
&& ($plan->features['lead_time_days'] ?? null) !== null;
if ($isBuildToOrder) {
ServiceBuildMilestone::firstOrCreate(
[
'subscription_id' => $event->subscription->id,
'stage' => ServiceBuildMilestone::STAGE_ORDERED,
],
[
'reached_at' => now(),
'note' => 'Order received. Hardware will be ordered with our supplier shortly.',
],
);
}
// Automatically provision the service. For build-to-order
// dedicated, this provision step is admin-driven and the
// factory throws — the catch block below handles it cleanly.
try {
$provisioner = $this->provisioningFactory->make($plan->service_type);
$service = $provisioner->provision($event->subscription);

View File

@@ -342,6 +342,150 @@ class ConfigOptionSeeder extends Seeder
$veeamCloudConnectPlan = Plan::query()->where('slug', 'veeam-cloud-connect')->pluck('id');
$veeamCloudConnect->plans()->syncWithoutDetaching($veeamCloudConnectPlan);
// ─── Dedicated 14th Gen — CPU Upgrade ───────────────────────────
$gen14CpuUpgrade = PlanConfigGroup::updateOrCreate(
['name' => 'Dedicated 14th Gen — CPU Upgrade'],
[
'description' => 'Upgrade from the 2× Xeon Gold 6230 baseline to a higher core count or clock speed.',
'mode' => 'preset',
'service_type' => 'dedicated',
'is_active' => true,
'sort_order' => 40,
],
);
$gen14CpuOption = $this->seedRadioOption($gen14CpuUpgrade, 'CPU Upgrade', false, 1);
$this->seedValues($gen14CpuOption, [
['label' => 'Baseline: 2× Xeon Gold 6230 (40C / 2.10 GHz)', 'value' => 'gold-6230-baseline', 'monthly' => 0, 'is_default' => true],
['label' => 'Upgrade: 2× Xeon Gold 6248 (40C / 2.50 GHz)', 'value' => 'gold-6248', 'monthly' => 35.00],
['label' => 'Upgrade: 2× Xeon Gold 6248R (48C / 3.00 GHz)', 'value' => 'gold-6248r', 'monthly' => 75.00],
['label' => 'Upgrade: 2× Xeon Gold 6258R (56C / 2.70 GHz)', 'value' => 'gold-6258r', 'monthly' => 145.00],
]);
$gen14CpuPlanSlugs = ['r440-4lff', 'r540-8lff', 'r640-8sff', 'r740-16sff'];
$gen14CpuPlans = Plan::query()->whereIn('slug', $gen14CpuPlanSlugs)->pluck('id');
$gen14CpuUpgrade->plans()->syncWithoutDetaching($gen14CpuPlans);
// ─── Dedicated 14th Gen — CPU Upgrade (R740xd) ──────────────────
$gen14CpuUpgradeXd = PlanConfigGroup::updateOrCreate(
['name' => 'Dedicated 14th Gen — CPU Upgrade (R740xd)'],
[
'description' => 'R740xd-specific CPU options. Higher TDP support than non-xd variants.',
'mode' => 'preset',
'service_type' => 'dedicated',
'is_active' => true,
'sort_order' => 41,
],
);
$gen14CpuXdOption = $this->seedRadioOption($gen14CpuUpgradeXd, 'CPU Upgrade', false, 1);
$this->seedValues($gen14CpuXdOption, [
['label' => 'Baseline: 2× Xeon Gold 6230 (40C / 2.10 GHz)', 'value' => 'gold-6230-baseline', 'monthly' => 0, 'is_default' => true],
['label' => 'Upgrade: 2× Xeon Gold 6248R (48C / 3.00 GHz)', 'value' => 'gold-6248r', 'monthly' => 75.00],
['label' => 'Upgrade: 2× Xeon Gold 6258R (56C / 2.70 GHz)', 'value' => 'gold-6258r', 'monthly' => 145.00],
['label' => 'Upgrade: 2× Xeon Platinum 8280 (56C / 2.70 GHz)', 'value' => 'platinum-8280', 'monthly' => 285.00],
]);
$gen14CpuXdPlanSlugs = ['r740xd-24sff', 'r740xd-12lff', 'r740xd-24nvme'];
$gen14CpuXdPlans = Plan::query()->whereIn('slug', $gen14CpuXdPlanSlugs)->pluck('id');
$gen14CpuUpgradeXd->plans()->syncWithoutDetaching($gen14CpuXdPlans);
// ─── Dedicated 14th Gen — RAM Upgrade ───────────────────────────
$gen14Ram = PlanConfigGroup::updateOrCreate(
['name' => 'Dedicated 14th Gen — RAM Upgrade'],
[
'description' => 'DDR4-2666 ECC RDIMM (up to 256GB) or DDR4-2400 LRDIMM (above 256GB; downclocks memory to 2400 MHz, ~18% bandwidth penalty).',
'mode' => 'preset',
'service_type' => 'dedicated',
'is_active' => true,
'sort_order' => 42,
],
);
$gen14RamOption = $this->seedRadioOption($gen14Ram, 'RAM Upgrade', false, 1);
$this->seedValues($gen14RamOption, [
['label' => '32 GB (baseline)', 'value' => '32', 'monthly' => 0, 'is_default' => true],
['label' => '64 GB', 'value' => '64', 'monthly' => 35.00],
['label' => '128 GB', 'value' => '128', 'monthly' => 90.00],
['label' => '256 GB', 'value' => '256', 'monthly' => 195.00],
['label' => '512 GB (LRDIMM)', 'value' => '512', 'monthly' => 380.00],
['label' => '1 TB (LRDIMM)', 'value' => '1024', 'monthly' => 580.00],
['label' => '1.5 TB (LRDIMM, R640/R740/R740xd only)', 'value' => '1536', 'monthly' => 780.00],
]);
$gen14AllPlanSlugs = ['r440-4lff', 'r540-8lff', 'r640-8sff', 'r740-16sff', 'r740xd-24sff', 'r740xd-12lff', 'r640-10nvme', 'r740xd-24nvme'];
$gen14AllPlans = Plan::query()->whereIn('slug', $gen14AllPlanSlugs)->pluck('id');
$gen14Ram->plans()->syncWithoutDetaching($gen14AllPlans);
// ─── Dedicated 14th Gen — Operating System ──────────────────────
$gen14Os = PlanConfigGroup::updateOrCreate(
['name' => 'Dedicated 14th Gen — Operating System'],
[
'description' => 'Choose the OS image installed at provisioning time. Windows requires your own license (BYOL).',
'mode' => 'preset',
'service_type' => 'dedicated',
'is_active' => true,
'sort_order' => 43,
],
);
$gen14OsOption = $this->seedRadioOption($gen14Os, 'Operating System', false, 1);
$this->seedValues($gen14OsOption, [
['label' => 'No OS (BYO image / custom PXE)', 'value' => 'none', 'monthly' => 0, 'is_default' => true],
['label' => 'AlmaLinux 9', 'value' => 'alma9', 'monthly' => 0],
['label' => 'Ubuntu 24.04 LTS', 'value' => 'ubuntu24', 'monthly' => 0],
['label' => 'Debian 12', 'value' => 'debian12', 'monthly' => 0],
['label' => 'Rocky Linux 9', 'value' => 'rocky9', 'monthly' => 0],
['label' => 'Windows Server 2022 (BYOL)', 'value' => 'windows-2022-byol', 'monthly' => 0],
]);
$gen14Os->plans()->syncWithoutDetaching($gen14AllPlans);
// ─── Dedicated 14th Gen — Bandwidth ─────────────────────────────
$gen14Bandwidth = PlanConfigGroup::updateOrCreate(
['name' => 'Dedicated 14th Gen — Bandwidth'],
[
'description' => 'Network port speed and bandwidth allocation. 10 Gbps tiers include traffic packages with overage protection.',
'mode' => 'preset',
'service_type' => 'dedicated',
'is_active' => true,
'sort_order' => 44,
],
);
$gen14BandwidthOption = $this->seedRadioOption($gen14Bandwidth, 'Bandwidth', false, 1);
$this->seedValues($gen14BandwidthOption, [
['label' => '1 Gbps unmetered (baseline)', 'value' => '1g-unmetered', 'monthly' => 0, 'is_default' => true],
['label' => '10 Gbps + 10 TB included', 'value' => '10g-10tb', 'monthly' => 45.00],
['label' => '10 Gbps + 50 TB included', 'value' => '10g-50tb', 'monthly' => 95.00],
['label' => '10 Gbps + 100 TB included', 'value' => '10g-100tb', 'monthly' => 175.00],
['label' => '10 Gbps unmetered (fair-use AUP)', 'value' => '10g-unmetered-fair-use', 'monthly' => 295.00],
]);
$gen14Bandwidth->plans()->syncWithoutDetaching($gen14AllPlans);
// ─── Dedicated 14th Gen — IPv4 Block ────────────────────────────
$gen14Ipv4 = PlanConfigGroup::updateOrCreate(
['name' => 'Dedicated 14th Gen — IPv4 Block'],
[
'description' => 'Additional IPv4 addresses beyond the 1 included with every server.',
'mode' => 'preset',
'service_type' => 'dedicated',
'is_active' => true,
'sort_order' => 45,
],
);
$gen14Ipv4Option = $this->seedRadioOption($gen14Ipv4, 'IPv4 Block', false, 1);
$this->seedValues($gen14Ipv4Option, [
['label' => '1 included (no extra)', 'value' => '1', 'monthly' => 0, 'is_default' => true],
['label' => '/29 (5 usable)', 'value' => '5', 'monthly' => 12.00],
['label' => '/28 (13 usable)', 'value' => '13', 'monthly' => 36.00],
['label' => '/27 (29 usable)', 'value' => '29', 'monthly' => 80.00],
]);
$gen14Ipv4->plans()->syncWithoutDetaching($gen14AllPlans);
}
/**

View File

@@ -40,6 +40,7 @@ interface Props {
configGroups?: PlanConfigGroup[]
mode?: 'standard' | 'custom'
prefilledSelections?: PrefilledSelection[]
setupFee?: number
}
defineOptions({ layout: AccountLayout })
@@ -112,9 +113,18 @@ const basePriceWithCycle = computed(() => {
return discountedMonthly * cycle.months
})
// Setup fee is non-zero only on dedicated 14th-gen plans, and only
// charged on monthly/quarterly cycles. Waived on semi-annual / annual
// per the brainstorm.
const effectiveSetupFee = computed<number>(() => {
const fee = props.setupFee ?? 0
if (fee <= 0) return 0
return ['monthly', 'quarterly'].includes(billingCycle.value) ? fee : 0
})
const total = computed(() => {
const basePrice = basePriceWithCycle.value
return Math.max(0, basePrice - couponDiscount.value + configTotalPrice.value).toFixed(2)
return Math.max(0, basePrice - couponDiscount.value + configTotalPrice.value + effectiveSetupFee.value).toFixed(2)
})
const savingsAmount = computed(() => {

View File

@@ -84,6 +84,7 @@ Route::get('/dedicated-servers', function () {
$plans = Plan::query()
->where('service_type', 'dedicated')
->where('status', 'active')
->with('prices')
->orderBy('sort_order')
->orderBy('price')
->get();
@@ -92,6 +93,32 @@ Route::get('/dedicated-servers', function () {
'plans' => $plans,
]);
})->name('dedicated-servers');
Route::get('/dedicated-servers/{slug}', function (string $slug) {
$plan = Plan::query()
->where('service_type', 'dedicated')
->where('slug', $slug)
->where('status', 'active')
->with('prices')
->firstOrFail();
// Configurable add-on groups attached to this specific chassis,
// scoped to the 14th-gen Dedicated configurator namespace so we
// don't leak unrelated groups (e.g., Server Management, Veeam) into
// the per-chassis customizer.
$configGroups = $plan->configGroups()
->where('is_active', true)
->where('mode', 'preset')
->where('name', 'like', 'Dedicated 14th Gen%')
->with(['options' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'), 'options.values' => fn ($q) => $q->orderBy('sort_order')])
->orderBy('sort_order')
->get();
return Inertia::render('Marketing/DedicatedServerDetail', [
'plan' => $plan,
'configGroups' => $configGroups,
]);
})->name('dedicated-servers.show');
Route::get('/web-hosting', function () {
$plans = Plan::query()
->where('service_type', 'hosting')