Files
ladill-hosting/app/Http/Controllers/Hosting/HostingProductController.php
T
isaaccladandCursor 537e34cbd7
Deploy Ladill Hosting / deploy (push) Successful in 51s
Wire Ladill domain picker and purchase modal into hosting flows.
Use the Domains API for owned domains across panel, account, and order forms, with an embedded iframe purchase modal instead of redirecting to domains.ladill.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 18:01:23 +00:00

639 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\HostingOrder;
use App\Models\HostingPlanChange;
use App\Models\HostingProduct;
use App\Models\RcServiceOrder;
use App\Services\Hosting\HostingAccessResolver;
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 = 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);
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)',
];
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']);
$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' => 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<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);
}
}