Files
ladill-hosting/app/Services/Hosting/UpsellRecommendationService.php
isaaccladandCursor 1135915797
Deploy Ladill Hosting / deploy (push) Successful in 20s
Fix 500 when exiting hosting panel for existing accounts.
The account overview queried a local mailboxes table that the extracted hosting app does not have; guard those lookups and skip email-addon upsells when mailboxes are managed on Ladill Email.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 17:02:44 +00:00

202 lines
8.3 KiB
PHP

<?php
namespace App\Services\Hosting;
use App\Models\HostingAccount;
use App\Models\HostingProduct;
use App\Models\User;
use Illuminate\Support\Collection;
class UpsellRecommendationService
{
private const STORAGE_THRESHOLD = 70.0;
private const TRAFFIC_THRESHOLD = 70.0;
private const EMAIL_ADDON_QUANTITY = 10;
private const EMAIL_ADDON_UNIT_PRICE = 5;
private const BYTES_PER_GB = 1073741824;
/**
* @return Collection<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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() : ''
);
}
}