Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
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>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
<?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() : ''
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user