> */ public function recommendationsForUser(User $user): Collection { $accountsQuery = HostingAccount::query() ->where('user_id', $user->id) ->where('status', HostingAccount::STATUS_ACTIVE) ->with(['product']); if (HostingAccount::tracksLocalMailboxes()) { $accountsQuery->with(['mailboxes:id,hosting_account_id,is_paid']); } $accounts = $accountsQuery->get(); return $accounts ->flatMap(fn (HostingAccount $account): array => $this->recommendationsForAccount($account)) ->sortByDesc(fn (array $recommendation): float => (float) ($recommendation['priority_score'] ?? 0)) ->values(); } /** * @return array> */ public function recommendationsForAccount(HostingAccount $account): array { $account->loadMissing(['product']); $recommendations = []; $upgradeProduct = $this->nextUpgradeProduct($account); $diskPercent = $account->diskUsagePercent(); $trafficPercent = $this->bandwidthUsagePercent($account); $trafficTriggered = $trafficPercent !== null && $trafficPercent >= self::TRAFFIC_THRESHOLD; $storageTriggered = $diskPercent >= self::STORAGE_THRESHOLD; if ($upgradeProduct && ($storageTriggered || $trafficTriggered)) { $recommendations[] = [ 'id' => sprintf('account-%d-upgrade', $account->id), 'type' => 'plan_upgrade', 'trigger' => $storageTriggered && $trafficTriggered ? 'storage_and_traffic' : ($storageTriggered ? 'storage_usage' : 'traffic_spike'), 'account_id' => $account->id, 'account_label' => $account->primary_domain ?: $account->username, 'title' => $storageTriggered && $trafficTriggered ? sprintf('%s is outgrowing its current plan', $account->primary_domain ?: $account->username) : ($storageTriggered ? sprintf('Storage usage is high on %s', $account->primary_domain ?: $account->username) : sprintf('Traffic is climbing on %s', $account->primary_domain ?: $account->username)), 'message' => $this->upgradeMessage($account, $upgradeProduct, $diskPercent, $trafficPercent), 'cta_label' => sprintf('Upgrade to %s', $upgradeProduct->name), 'cta_url' => route('hosting.accounts.show', $account), 'target_product_id' => $upgradeProduct->id, 'target_product_name' => $upgradeProduct->name, 'target_product_type' => $upgradeProduct->type, 'priority_score' => max($diskPercent, $trafficPercent ?? 0), 'metrics' => [ 'disk_usage_percent' => round($diskPercent, 2), 'traffic_usage_percent' => $trafficPercent !== null ? round($trafficPercent, 2) : null, ], ]; } $freeAllowance = $account->freeEmailAllowance(); $mailboxCount = $account->loadedMailboxes()->count(); if (HostingAccount::tracksLocalMailboxes() && $freeAllowance !== null && $mailboxCount > $freeAllowance) { $extraMailboxes = $mailboxCount - $freeAllowance; $addonAmount = self::EMAIL_ADDON_QUANTITY * self::EMAIL_ADDON_UNIT_PRICE; $recommendations[] = [ 'id' => sprintf('account-%d-email-addon', $account->id), 'type' => 'email_addon', 'trigger' => 'email_overage', 'account_id' => $account->id, 'account_label' => $account->primary_domain ?: $account->username, 'title' => sprintf('Mailbox overage on %s', $account->primary_domain ?: $account->username), 'message' => sprintf( 'This account is using %d mailbox%s on a plan with %d included. Adding %d more mailboxes would be GHS %d/month.', $mailboxCount, $mailboxCount === 1 ? '' : 'es', $freeAllowance, self::EMAIL_ADDON_QUANTITY, $addonAmount ), 'cta_label' => 'Manage Email Add-ons', 'cta_url' => route('hosting.accounts.show', $account), 'suggested_quantity' => self::EMAIL_ADDON_QUANTITY, 'estimated_amount' => (float) $addonAmount, 'priority_score' => 50 + ($extraMailboxes * 5), 'metrics' => [ 'mailbox_count' => $mailboxCount, 'free_allowance' => $freeAllowance, 'extra_mailboxes' => $extraMailboxes, ], ]; } return $recommendations; } private function nextUpgradeProduct(HostingAccount $account): ?HostingProduct { $current = $account->product; if (! $current) { return null; } return HostingProduct::query() ->active() ->visible() ->where('category', HostingProduct::CATEGORY_SHARED) ->whereKeyNot($current->id) ->get() ->filter(function (HostingProduct $candidate) use ($current): bool { $candidateDomains = $candidate->max_domains ?? 0; $currentDomains = $current->max_domains ?? 0; return ($candidate->disk_gb ?? 0) > ($current->disk_gb ?? 0) || ($candidate->max_email_accounts ?? 0) > ($current->max_email_accounts ?? 0) || ($candidateDomains === -1 && $currentDomains !== -1) || ($candidateDomains > $currentDomains && $currentDomains !== -1); }) ->sortBy(fn (HostingProduct $candidate): string => sprintf( '%d-%08d-%012.2f', $candidate->type === $current->type ? 0 : 1, (int) ($candidate->sort_order ?? 0), (float) ($candidate->price_monthly ?? 0) )) ->first(); } private function bandwidthUsagePercent(HostingAccount $account): ?float { $limitBytes = $this->bandwidthLimitBytes($account); if ($limitBytes === null || $limitBytes <= 0) { return null; } return ($account->bandwidth_used_bytes / $limitBytes) * 100; } private function bandwidthLimitBytes(HostingAccount $account): ?int { $limitBytes = data_get($account->resource_limits, 'bandwidth_limit_bytes'); if (is_numeric($limitBytes) && (int) $limitBytes > 0) { return (int) $limitBytes; } $bandwidthGb = data_get($account->resource_limits, 'bandwidth_gb'); if (is_numeric($bandwidthGb) && (int) $bandwidthGb > 0) { return (int) $bandwidthGb * self::BYTES_PER_GB; } if ($account->product?->bandwidth_gb && $account->product->bandwidth_gb > 0) { return (int) $account->product->bandwidth_gb * self::BYTES_PER_GB; } return null; } private function upgradeMessage(HostingAccount $account, HostingProduct $target, float $diskPercent, ?float $trafficPercent): string { $parts = []; if ($diskPercent >= self::STORAGE_THRESHOLD) { $parts[] = sprintf('disk usage is at %.1f%%', $diskPercent); } if ($trafficPercent !== null && $trafficPercent >= self::TRAFFIC_THRESHOLD) { $parts[] = sprintf('traffic usage is at %.1f%%', $trafficPercent); } $reason = $parts === [] ? 'usage is increasing' : implode(' and ', $parts); return sprintf( '%s. Moving this account to %s gives you %s storage%s.', ucfirst($reason), $target->name, $target->formatDiskSize(), $target->formatDomainLimit() ? ' and '.$target->formatDomainLimit() : '' ); } }