Files
ladill-servers/app/Http/Controllers/Hosting/HostingProductController.php
T
isaaccladandCursor b6defa5e47
Deploy Ladill Servers / deploy (push) Successful in 1m49s
Add page heroes to VPS and dedicated server pages.
Surface server counts, status breakdown, and available plans in heroes consistent with other Ladill apps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 14:19:41 +00:00

650 lines
26 KiB
PHP

<?php
namespace App\Http\Controllers\Hosting;
use App\Http\Controllers\Controller;
use App\Models\CustomerHostingOrder;
use App\Models\HostingAccount;
use App\Models\HostingAccountMember;
use App\Models\HostingOrder;
use App\Models\HostingPlanChange;
use App\Models\HostingProduct;
use App\Models\RcServiceOrder;
use App\Services\Hosting\HostingOrderFulfillmentService;
use App\Services\Hosting\HostingPlanChangeService;
use App\Services\Hosting\HostingRenewalCheckoutService;
use App\Services\Hosting\ServerOrderService;
use App\Services\Hosting\UpsellRecommendationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class HostingProductController extends Controller
{
public function singleDomain(Request $request): View
{
return $this->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();
// New Hosting Orders
$orders = CustomerHostingOrder::query()
->forUser($user->id)
->whereHas('product', fn ($q) => $q->ofType($type))
->with(['product', 'domain', 'hostingAccount', 'hostingNode'])
->latest()
->get();
// Admin-assigned Hosting Accounts
$sharedAccountIds = HostingAccountMember::query()
->where('user_id', $user->id)
->pluck('hosting_account_id');
$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);
return view('hosting.type', [
'title' => $title,
'type' => $type,
'orders' => $orders,
'hostingAccounts' => $hostingAccounts,
'rcHostingOrders' => $rcHostingOrders,
'rcServiceOrders' => $rcServiceOrders,
'marketingCategory' => $marketingCategory,
'nativeForm' => $nativeForm,
'userDomains' => $userDomains,
]);
}
private function renderServerPage(Request $request, string $type, string $title): View
{
$user = $request->user();
$serverOrders = app(ServerOrderService::class);
// New Hosting Orders
$orders = CustomerHostingOrder::query()
->forUser($user->id)
->whereHas('product', fn ($q) => $q->ofType($type))
->with(['product', 'vpsInstance'])
->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)',
];
$pendingStatuses = ['pending_payment', 'pending_approval', 'approved', 'provisioning'];
$heroStats = [
'total' => $orders->count() + $rcServiceOrders->count(),
'active' => $orders->where('status', 'active')->count() + $rcServiceOrders->where('status', 'active')->count(),
'pending' => $orders->whereIn('status', $pendingStatuses)->count() + $rcServiceOrders->whereIn('status', $pendingStatuses)->count(),
'plans' => $products->count(),
];
return view('hosting.server-type', [
'title' => $title,
'type' => $type,
'orders' => $orders,
'rcServiceOrders' => $rcServiceOrders,
'marketingCategory' => $marketingCategory,
'nativeForm' => $nativeForm,
'serverOrderPayloads' => $serverOrderPayloads,
'billingCycles' => $billingCycles,
'userDomains' => $userDomains,
'heroStats' => $heroStats,
]);
}
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']);
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
$freeEmailAllowance = $account->freeEmailAllowance();
$mailboxes = $account->loadedMailboxes();
$mailboxCount = $mailboxes->count();
$extraMailboxCount = $freeEmailAllowance === null ? 0 : max($mailboxCount - $freeEmailAllowance, 0);
return view('hosting.account', [
'account' => $account,
'linkedSites' => $account->sites->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])->values(),
'maxDomains' => $maxDomains,
'canLinkMoreDomains' => $account->sites->count() < $maxDomains,
'ownedDomains' => $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('servers.orders.show', $order),
]);
}
return redirect()->route('servers.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('servers.orders.show', $order),
]);
}
return redirect()->route('servers.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<string, mixed>
*/
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);
}
}