renderHostingPage($request, HostingProduct::TYPE_SINGLE_DOMAIN, 'Single Domain Hosting'); } public function multiDomain(Request $request): View { return $this->renderHostingPage($request, HostingProduct::TYPE_MULTI_DOMAIN, 'Multi Domain Hosting'); } public function wordpress(Request $request): View { return $this->renderHostingPage($request, HostingProduct::TYPE_WORDPRESS, 'WordPress Hosting'); } public function vps(Request $request): View { return $this->renderServerPage($request, HostingProduct::TYPE_VPS, 'VPS'); } public function dedicated(Request $request): View { return $this->renderServerPage($request, HostingProduct::TYPE_DEDICATED, 'Dedicated Server'); } private function renderHostingPage(Request $request, string $type, string $title): View { $user = $request->user(); // In-flight orders only — Active accounts belong under Hosting Accounts // (legacy Sales Centre left Active pending-setup.local orders that duplicated accounts). $orders = CustomerHostingOrder::query() ->forUser($user->id) ->inFlight() ->whereHas('product', fn ($q) => $q->ofType($type)) ->with(['product', 'domain', 'hostingAccount.sites', 'hostingNode']) ->latest() ->get(); // Admin-assigned Hosting Accounts $sharedAccountIds = app(HostingAccessResolver::class)->sharedAccountIds($user); $hostingAccounts = HostingAccount::query() ->where(function ($query) use ($user, $sharedAccountIds) { $query->where('user_id', $user->id); if ($sharedAccountIds->isNotEmpty()) { $query->orWhereIn('id', $sharedAccountIds); } }) ->whereHas('product', fn ($q) => $q->ofType($type)) ->with(['product', 'sites']) ->latest() ->get(); // RC Hosting Orders (Legacy) $legacyHostingType = $this->getLegacyHostingTypeForType($type); $rcCategory = $this->getRcCategoryForType($type); $rcHostingOrders = collect(); $rcServiceOrders = collect(); if ($legacyHostingType) { $rcHostingOrders = HostingOrder::query() ->where('user_id', $user->id) ->where('hosting_type', $legacyHostingType) ->latest() ->get(); } if ($rcCategory && Schema::hasTable('rc_service_orders')) { $rcServiceOrders = RcServiceOrder::query() ->where('user_id', $user->id) ->where('category', $rcCategory) ->visibleToCustomer() ->latest() ->get(); } $marketingCategory = $this->getMarketingOrderCategory($type); // Native form data for in-dashboard ordering $nativeForm = $this->nativeHostingProductForm($type); $userDomains = $this->getUserDomains($user); $pendingStatuses = ['pending_payment', 'pending_approval', 'approved', 'provisioning']; $heroStats = [ 'accounts' => $hostingAccounts->count(), 'active' => $hostingAccounts->where('status', 'active')->count() + $orders->where('status', 'active')->count() + $rcHostingOrders->where('status', 'active')->count() + $rcServiceOrders->where('status', 'active')->count(), 'pending' => $orders->whereIn('status', $pendingStatuses)->count() + $rcHostingOrders->whereIn('status', $pendingStatuses)->count() + $rcServiceOrders->whereIn('status', $pendingStatuses)->count(), 'plans' => count($nativeForm['packages'] ?? []), ]; return view('hosting.type', [ 'title' => $title, 'type' => $type, 'orders' => $orders, 'hostingAccounts' => $hostingAccounts, 'rcHostingOrders' => $rcHostingOrders, 'rcServiceOrders' => $rcServiceOrders, 'marketingCategory' => $marketingCategory, 'nativeForm' => $nativeForm, 'userDomains' => $userDomains, 'heroStats' => $heroStats, ]); } private function renderServerPage(Request $request, string $type, string $title): View { $user = $request->user(); $serverOrders = app(ServerOrderService::class); // In-flight orders only — Active accounts belong under Hosting Accounts $orders = CustomerHostingOrder::query() ->forUser($user->id) ->inFlight() ->whereHas('product', fn ($q) => $q->ofType($type)) ->with(['product', 'vpsInstance', 'hostingAccount.sites']) ->latest() ->get(); // RC Service Orders (Legacy) $rcCategory = $this->getRcCategoryForType($type); $rcServiceOrders = collect(); if ($rcCategory && Schema::hasTable('rc_service_orders')) { $rcServiceOrders = RcServiceOrder::query() ->where('user_id', $user->id) ->where('category', $rcCategory) ->visibleToCustomer() ->latest() ->get(); } $marketingCategory = $this->getMarketingOrderCategory($type); // Native form data for in-dashboard ordering $nativeForm = $this->nativeHostingProductForm($type); $userDomains = $this->getUserDomains($user); $products = HostingProduct::query() ->active() ->visible() ->where('type', $type) ->ordered() ->get(); $serverOrderPayloads = []; foreach ($products as $product) { $serverOrderPayloads[$product->id] = $serverOrders->frontendPayload($product); } $billingCycles = [ CustomerHostingOrder::CYCLE_MONTHLY => 'Monthly', CustomerHostingOrder::CYCLE_QUARTERLY => 'Quarterly (3 months)', CustomerHostingOrder::CYCLE_SEMIANNUAL => 'Semiannual (6 months)', CustomerHostingOrder::CYCLE_YEARLY => 'Yearly (12 months)', ]; return view('hosting.server-type', [ 'title' => $title, 'type' => $type, 'orders' => $orders, 'rcServiceOrders' => $rcServiceOrders, 'marketingCategory' => $marketingCategory, 'nativeForm' => $nativeForm, 'serverOrderPayloads' => $serverOrderPayloads, 'billingCycles' => $billingCycles, 'userDomains' => $userDomains, ]); } public function showAccount( Request $request, HostingAccount $account, ?UpsellRecommendationService $upsells = null, ?HostingRenewalCheckoutService $renewals = null ): View { Gate::forUser($request->user())->authorize('viewAccount', $account); $upsells ??= app(UpsellRecommendationService::class); $renewals ??= app(HostingRenewalCheckoutService::class); $account->load(['product', 'user', 'node', 'sites']); // Heal legacy Sales Centre placeholders (pending-setup.local) that stayed // primary after a real domain was linked as an addon. app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class)->healAccount($account); $account->refresh()->load(['product', 'user', 'node', 'sites']); $placeholders = app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class); $maxDomains = $account->maxDomainsLimit(); $freeEmailAllowance = $account->freeEmailAllowance(); $mailboxes = $account->loadedMailboxes(); $mailboxCount = $mailboxes->count(); $extraMailboxCount = $freeEmailAllowance === null ? 0 : max($mailboxCount - $freeEmailAllowance, 0); return view('hosting.account', [ 'account' => $account, 'accountLabel' => $placeholders->displayLabel($account), 'linkedSites' => $account->sites ->filter(fn ($site) => ! \App\Services\Hosting\PlaceholderPrimaryDomainService::isPlaceholderDomain($site->domain)) ->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain]) ->values(), 'maxDomains' => $maxDomains, 'canLinkMoreDomains' => $account->canAddDomain(), 'ownedDomains' => collect($this->getUserDomains($request->user())), 'emailUsage' => [ 'mailbox_count' => $mailboxCount, 'free_allowance' => $freeEmailAllowance, 'extra_count' => $extraMailboxCount, 'paid_count' => $mailboxes->where('is_paid', true)->count(), 'extra_total' => round($extraMailboxCount * 5, 2), ], 'upsellRecommendations' => $upsells->recommendationsForAccount($account), 'renewalOptions' => $account->canBeRenewed() ? $renewals->availableRenewalOptions($account) : [], ]); } public function changeAccountPlan(Request $request, HostingAccount $account, HostingPlanChangeService $planChanges): RedirectResponse { Gate::forUser($request->user())->authorize('viewAccount', $account); $validated = $request->validate([ 'target_product_id' => ['required', 'integer', 'exists:hosting_products,id'], ]); $account->load(['product', 'latestOrder', 'sites', 'databases', 'node']); $targetProduct = HostingProduct::query()->findOrFail($validated['target_product_id']); try { $quote = $planChanges->quote($account, $targetProduct); $result = $planChanges->apply($account, $targetProduct, $request->user()); } catch (ValidationException $exception) { return redirect() ->route('hosting.accounts.show', $account) ->withErrors($exception->errors()) ->with('error', $exception->getMessage()); } catch (\Throwable $exception) { report($exception); return redirect() ->route('hosting.accounts.show', $account) ->with('error', 'Unable to change the hosting plan right now. Please try again.'); } $message = match ($quote['direction']) { HostingPlanChange::DIRECTION_UPGRADE => $quote['charge_amount'] > 0 ? sprintf( 'Plan upgraded to %s. A GHS %s invoice has been added for the prorated difference.', $targetProduct->name, number_format((float) $quote['charge_amount'], 2) ) : sprintf('Plan upgraded to %s.', $targetProduct->name), HostingPlanChange::DIRECTION_DOWNGRADE => $quote['credit_amount'] > 0 ? sprintf( 'Plan changed to %s. A GHS %s credit has been recorded for the unused balance.', $targetProduct->name, number_format((float) $quote['credit_amount'], 2) ) : sprintf('Plan changed to %s.', $targetProduct->name), default => 'Plan updated successfully.', }; return redirect() ->route('hosting.accounts.show', $result['account']) ->with('success', $message); } public function renewAccount(Request $request, HostingAccount $account, HostingRenewalCheckoutService $renewals): RedirectResponse|JsonResponse { Gate::forUser($request->user())->authorize('viewAccount', $account); $durationMonths = $request->has('duration_months') ? (int) $request->input('duration_months') : null; try { $checkout = $renewals->beginCheckout( $account, $request->user(), route('hosting.accounts.renew.callback', $account), $durationMonths ); } catch (ValidationException $exception) { if ($request->wantsJson()) { return response()->json(['error' => $exception->getMessage()], 422); } return back()->withErrors($exception->errors())->with('error', $exception->getMessage()); } catch (\Throwable $exception) { report($exception); if ($request->wantsJson()) { return response()->json(['error' => 'Unable to start renewal payment. Please try again.'], 422); } return back()->with('error', 'Unable to start renewal payment. Please try again.'); } if ($request->wantsJson()) { return response()->json(['checkout_url' => $checkout['authorization_url']]); } return redirect()->away($checkout['authorization_url']); } public function renewAccountCallback(Request $request, HostingAccount $account, HostingRenewalCheckoutService $renewals): RedirectResponse { Gate::forUser($request->user())->authorize('viewAccount', $account); $reference = (string) $request->query('reference', ''); if ($reference === '') { return redirect() ->route('hosting.accounts.show', $account) ->with('error', 'Missing renewal payment reference.'); } try { $invoice = $renewals->completeCheckout($account, $request->user(), $reference); } catch (ValidationException $exception) { return redirect() ->route('hosting.accounts.show', $account) ->withErrors($exception->errors()) ->with('error', $exception->getMessage()); } catch (\Throwable $exception) { report($exception); return redirect() ->route('hosting.accounts.show', $account) ->with('error', 'Unable to verify renewal payment. If you were charged, contact support with your payment reference.'); } $months = (int) data_get($invoice->metadata, 'months', $account->assignedDurationMonths() ?? 0); return redirect() ->route('hosting.accounts.show', $account) ->with('success', "Hosting renewal payment confirmed. Your account has been renewed for {$months} month(s)."); } public function show(Request $request, CustomerHostingOrder $order): View { abort_if($order->user_id !== $request->user()->id, 403); $order->load(['product', 'domain', 'hostingAccount', 'hostingNode', 'vpsInstance']); $viewName = $order->product?->requiresContabo() ? 'hosting.server-order' : 'hosting.order'; return view($viewName, [ 'order' => $order, ]); } public function store(Request $request): JsonResponse|RedirectResponse { $validated = $request->validate([ 'product_id' => 'required|exists:hosting_products,id', 'domain_name' => 'required|string|max:255', 'billing_cycle' => 'required|in:monthly,quarterly,yearly,biennial', ]); $product = HostingProduct::findOrFail($validated['product_id']); abort_unless($product->is_active && $product->is_visible, 404, 'Product not available'); $price = $product->getPriceForCycle($validated['billing_cycle']); abort_unless($price !== null, 400, 'Invalid billing cycle for this product'); $order = CustomerHostingOrder::create([ 'user_id' => $request->user()->id, 'hosting_product_id' => $product->id, 'domain_name' => $validated['domain_name'], 'billing_cycle' => $validated['billing_cycle'], 'amount_paid' => $price + ($product->setup_fee ?? 0), 'currency' => $product->display_currency, 'status' => CustomerHostingOrder::STATUS_PENDING_APPROVAL, ]); $fulfillment = app(HostingOrderFulfillmentService::class); $fulfilled = $fulfillment->attemptAutoFulfillment($order)['fulfilled']; $customerMessage = $fulfilled ? 'Order received. We are setting up your hosting.' : 'Order received. Your order is pending.'; if ($request->wantsJson()) { return response()->json([ 'success' => true, 'message' => $customerMessage, 'order' => $order->fresh(), 'redirect' => route('hosting.orders.show', $order), ]); } return redirect()->route('hosting.orders.show', $order) ->with('success', $customerMessage); } public function storeServer(Request $request): JsonResponse|RedirectResponse { $serverOrders = app(ServerOrderService::class); $validated = $request->validate([ 'product_id' => 'required|exists:hosting_products,id', 'server_name' => 'required|string|max:255', 'billing_cycle' => 'required|in:monthly,quarterly,semiannual,yearly', 'region' => 'required|string|max:50', 'image' => 'required|string|max:255', 'license' => 'required|string|max:100', 'application' => 'required|string|max:100', 'default_user' => 'required|string|max:50', 'additional_ip' => 'required|string|max:50', 'private_networking' => 'required|string|max:50', 'storage_type' => 'required|string|max:50', 'object_storage' => 'required|string|max:50', 'server_password' => ['required', 'string', 'confirmed', 'regex:'.$serverOrders->passwordPattern()], ]); $product = HostingProduct::findOrFail($validated['product_id']); abort_unless($product->is_active && $product->is_visible, 404, 'Product not available'); abort_unless($product->requiresContabo(), 400, 'Invalid product type for server order'); $selection = $serverOrders->selectionAndQuote($product, $validated); $quote = $selection['quote']; $order = CustomerHostingOrder::create([ 'user_id' => $request->user()->id, 'hosting_product_id' => $product->id, 'domain_name' => $validated['server_name'], 'billing_cycle' => $validated['billing_cycle'], 'amount_paid' => (float) $quote['total'], 'currency' => $product->display_currency, 'status' => CustomerHostingOrder::STATUS_PENDING_APPROVAL, 'meta' => [ 'region' => $selection['selection']['region'], 'server_order' => [ 'selection' => $selection['selection'], 'selection_summary' => $selection['selection_summary'], 'quote' => $quote, 'panel_snapshot' => $selection['panel_snapshot'] ?? [], 'server_password_encrypted' => encrypt($validated['server_password']), ], 'server_panel_runtime' => $selection['panel_snapshot'] ?? [], ], ]); $fulfillment = app(HostingOrderFulfillmentService::class); $fulfilled = $fulfillment->attemptAutoFulfillment($order)['fulfilled']; $customerMessage = $fulfilled ? 'Order received. We are setting up your server.' : 'Order received. Your order is pending.'; if ($request->wantsJson()) { return response()->json([ 'success' => true, 'message' => $customerMessage, 'order' => $order->fresh(), 'redirect' => route('hosting.orders.show', $order), ]); } return redirect()->route('hosting.orders.show', $order) ->with('success', $customerMessage); } public function cancel(Request $request, CustomerHostingOrder $order): JsonResponse|RedirectResponse { abort_if($order->user_id !== $request->user()->id, 403); abort_unless($order->canBeCancelled(), 400, 'This order cannot be cancelled'); $order->cancel($request->input('reason')); if ($request->wantsJson()) { return response()->json([ 'success' => true, 'message' => 'Order cancelled successfully.', ]); } return back()->with('success', 'Order cancelled successfully.'); } private function getLegacyHostingTypeForType(string $type): ?string { return match ($type) { HostingProduct::TYPE_SINGLE_DOMAIN => HostingOrder::TYPE_SINGLE_DOMAIN, HostingProduct::TYPE_MULTI_DOMAIN => HostingOrder::TYPE_MULTI_DOMAIN, default => null, }; } private function getRcCategoryForType(string $type): ?string { return match ($type) { HostingProduct::TYPE_SINGLE_DOMAIN => 'single-domain-hosting', HostingProduct::TYPE_MULTI_DOMAIN => 'multi-domain-hosting', HostingProduct::TYPE_WORDPRESS => 'wordpress-hosting', HostingProduct::TYPE_VPS => 'vps', HostingProduct::TYPE_DEDICATED => 'dedicated-server', default => null, }; } private function getMarketingOrderCategory(string $type): ?string { // Map internal hosting types to marketing order categories return match ($type) { HostingProduct::TYPE_SINGLE_DOMAIN => 'single-domain-hosting', HostingProduct::TYPE_MULTI_DOMAIN => 'multi-domain-hosting', HostingProduct::TYPE_WORDPRESS => 'wordpress-hosting', HostingProduct::TYPE_VPS => 'vps', HostingProduct::TYPE_DEDICATED => 'dedicated-server', default => null, }; } /** * Build native form definition from HostingProduct records. * * @return array */ private function nativeHostingProductForm(string $productType): array { $products = HostingProduct::query() ->active() ->visible() ->where('type', $productType) ->ordered() ->get(); $serverOrders = app(ServerOrderService::class); $packages = $products->map(function (HostingProduct $product) use ($serverOrders) { if ($product->requiresContabo()) { $payload = $serverOrders->frontendPayload($product); $currency = strtoupper((string) ($payload['currency'] ?? $product->display_currency)); $monthly = (float) data_get($payload, 'prices.monthly', 0); $quarterly = (float) data_get($payload, 'prices.quarterly', 0); $semiannual = (float) data_get($payload, 'prices.semiannual', 0); $yearly = (float) data_get($payload, 'prices.yearly', 0); $setupFees = (array) ($payload['setup_fees'] ?? []); $prices = []; $totals = []; if ($monthly > 0) { $prices['1'] = $currency.' '.number_format($monthly, 2); $totals['1'] = $currency.' '.number_format($monthly + (float) ($setupFees['monthly'] ?? 0), 2); } if ($quarterly > 0) { $prices['3'] = $currency.' '.number_format($quarterly / 3, 2); $totals['3'] = $currency.' '.number_format($quarterly + (float) ($setupFees['quarterly'] ?? 0), 2); } if ($semiannual > 0) { $prices['6'] = $currency.' '.number_format($semiannual / 6, 2); $totals['6'] = $currency.' '.number_format($semiannual + (float) ($setupFees['semiannual'] ?? 0), 2); } if ($yearly > 0) { $prices['12'] = $currency.' '.number_format($yearly / 12, 2); $totals['12'] = $currency.' '.number_format($yearly + (float) ($setupFees['yearly'] ?? 0), 2); } return [ 'value' => (string) $product->id, 'label' => $product->name, 'summary' => $this->nativeProductSummary($product), 'starting_price' => $currency.' '.number_format($monthly, 2), 'starting_term' => '1', 'starting_label' => $currency.' '.number_format($monthly, 2).'/mo', 'prices' => $prices, 'totals' => $totals, 'pricing_source' => 'contabo_dynamic', ]; } $currency = $product->display_currency; $monthly = $product->getPriceForCycle('monthly'); $quarterly = $product->getPriceForCycle('quarterly'); $yearly = $product->getPriceForCycle('yearly'); $biennial = $product->getPriceForCycle('biennial'); $prices = []; $totals = []; if ($monthly) { $prices['1'] = $currency.' '.number_format($monthly, 2); $totals['1'] = $currency.' '.number_format($monthly, 2); } if ($quarterly) { $prices['3'] = $currency.' '.number_format($quarterly / 3, 2); $totals['3'] = $currency.' '.number_format($quarterly, 2); } if ($yearly) { $prices['12'] = $currency.' '.number_format($yearly / 12, 2); $totals['12'] = $currency.' '.number_format($yearly, 2); } if ($biennial) { $prices['24'] = $currency.' '.number_format($biennial / 24, 2); $totals['24'] = $currency.' '.number_format($biennial, 2); } $summary = $this->nativeProductSummary($product); return [ 'value' => (string) $product->id, 'label' => $product->name, 'summary' => $summary, 'starting_price' => $currency.' '.number_format($product->display_price_monthly, 2), 'starting_term' => '1', 'starting_label' => $currency.' '.number_format($product->display_price_monthly, 2).'/mo', 'prices' => $prices, 'totals' => $totals, ]; })->values()->all(); return [ 'packages' => $packages, 'has_packages' => $products->isNotEmpty(), 'region' => 'Global', 'requires_domain' => true, 'supports_os' => false, 'os_options' => [], 'supports_addons' => false, 'addon_options' => [], 'supports_ssl_toggle' => false, 'supports_dedicated_ip' => false, ]; } private function nativeProductSummary(HostingProduct $product): ?string { return $product->catalogSummary(); } private function getUserDomains($user): array { return app(\App\Services\Domains\LadillDomainsClient::class) ->ownedForUser((string) $user->public_id); } }