diff --git a/website/app/Http/Controllers/Account/CheckoutController.php b/website/app/Http/Controllers/Account/CheckoutController.php index 679f501..7b414e3 100644 --- a/website/app/Http/Controllers/Account/CheckoutController.php +++ b/website/app/Http/Controllers/Account/CheckoutController.php @@ -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), ]); } diff --git a/website/app/Listeners/HandleSubscriptionCreated.php b/website/app/Listeners/HandleSubscriptionCreated.php index a9037d3..7fd4e39 100644 --- a/website/app/Listeners/HandleSubscriptionCreated.php +++ b/website/app/Listeners/HandleSubscriptionCreated.php @@ -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); diff --git a/website/database/seeders/ConfigOptionSeeder.php b/website/database/seeders/ConfigOptionSeeder.php index 01c8929..0affd61 100644 --- a/website/database/seeders/ConfigOptionSeeder.php +++ b/website/database/seeders/ConfigOptionSeeder.php @@ -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); } /** diff --git a/website/resources/ts/Pages/Checkout/Show.vue b/website/resources/ts/Pages/Checkout/Show.vue index 259fd81..7fa104d 100644 --- a/website/resources/ts/Pages/Checkout/Show.vue +++ b/website/resources/ts/Pages/Checkout/Show.vue @@ -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(() => { + 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(() => { diff --git a/website/routes/marketing.php b/website/routes/marketing.php index 2c84143..e020149 100644 --- a/website/routes/marketing.php +++ b/website/routes/marketing.php @@ -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')