Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
197 lines
8.2 KiB
PHP
197 lines
8.2 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
|
|
{
|
|
$accounts = HostingAccount::query()
|
|
->where('user_id', $user->id)
|
|
->where('status', HostingAccount::STATUS_ACTIVE)
|
|
->with(['product', 'mailboxes:id,hosting_account_id,is_paid'])
|
|
->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', 'mailboxes']);
|
|
|
|
$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->mailboxes->count();
|
|
if ($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() : ''
|
|
);
|
|
}
|
|
}
|