From ce8489eacfcb6657030a0c228fa848192391e731 Mon Sep 17 00:00:00 2001 From: simbabimba-dev <79574809+simbabimba-dev@users.noreply.github.com> Date: Tue, 19 May 2026 20:31:56 +0000 Subject: [PATCH 1/8] refactor: implement better credit runout calculation and projection for users --- .../Commands/RecalculateAllCreditRunouts.php | 48 +++++++ app/Http/Controllers/HomeController.php | 129 +++-------------- app/Jobs/RecalculateCreditRunoutJob.php | 64 +++++++++ app/Models/Product.php | 19 ++- app/Models/Server.php | 27 +++- app/Models/User.php | 10 +- app/Services/CreditService.php | 136 +++++++++++++++++- ...546_add_credit_runout_columns_to_users.php | 30 ++++ 8 files changed, 341 insertions(+), 122 deletions(-) create mode 100644 app/Console/Commands/RecalculateAllCreditRunouts.php create mode 100644 app/Jobs/RecalculateCreditRunoutJob.php create mode 100644 database/migrations/2026_05_19_100546_add_credit_runout_columns_to_users.php diff --git a/app/Console/Commands/RecalculateAllCreditRunouts.php b/app/Console/Commands/RecalculateAllCreditRunouts.php new file mode 100644 index 000000000..e675d8d63 --- /dev/null +++ b/app/Console/Commands/RecalculateAllCreditRunouts.php @@ -0,0 +1,48 @@ +option('chunk'); + $total = User::count(); + + $this->info("Queueing credit runout recalculation for {$total} users..."); + + User::query() + ->chunk($chunkSize, function ($users) { + foreach ($users as $user) { + RecalculateCreditRunoutJob::dispatch($user->id); + } + + $this->line("Queued batch of users..."); + }); + + $this->info("All users queued for credit runout recalculation."); + $this->info("Monitor your queue worker/cronjob for completion: php artisan queue:work"); + } +} + diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index de965b4e2..a9a612509 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -2,15 +2,14 @@ namespace App\Http\Controllers; +use App\Jobs\RecalculateCreditRunoutJob; use App\Models\PartnerDiscount; use App\Models\UsefulLink; use App\Settings\GeneralSettings; use App\Settings\WebsiteSettings; use App\Settings\ReferralSettings; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; -use Carbon\Carbon; class HomeController extends Controller @@ -24,103 +23,6 @@ public function __construct() $this->middleware('auth'); } - /** - * Calculate when user will run out of credits. Holy shit what have i done? for just 1 fucking box on the dashboard? - */ - protected function calculateCreditRunout($user, $credits) - { - $servers = $user->getServersWithProduct(); - if ($servers->isEmpty()) { - return [ - 'run_out_date' => null, - 'simulation_steps' => [] - ]; - } - - // Prepare all servers: get next billing date and price (in credits) - $serverStates = []; - foreach ($servers as $server) { - $product = $server->product; - $period = $product->billing_period; - $price = $product->price; - $lastBilled = $server->last_billed ? Carbon::parse($server->last_billed) : now(); - $nextBilling = $lastBilled->copy(); - while ($nextBilling->lessThanOrEqualTo(now())) { - switch ($period) { - case 'hourly': $nextBilling->addHour(); break; - case 'daily': $nextBilling->addDay(); break; - case 'weekly': $nextBilling->addWeek(); break; - case 'monthly': $nextBilling->addMonth(); break; - case 'quarterly': $nextBilling->addMonths(3); break; - case 'half-annually': $nextBilling->addMonths(6); break; - case 'annually': $nextBilling->addYear(); break; - } - } - $serverStates[] = [ - 'server' => $server, - 'product' => $product, - 'period' => $period, - 'price' => $price, - 'nextBilling' => $nextBilling - ]; - } - - $simulationSteps = []; - $currentCredits = $credits; - $runOutDate = null; - $maxSteps = 1000; // max steps to generate events. Good accuracy for most cases, prevents infinite loops. - $step = 0; - - while ($step < $maxSteps) { - // Find the next billing date among all servers - $nextDates = array_map(fn($s) => $s['nextBilling'], $serverStates); - $minDate = collect($nextDates)->min(); - // Find all servers that bill at this date - $dueServers = array_filter($serverStates, fn($s) => $s['nextBilling']->equalTo($minDate)); - $sum = 0; - $actions = []; - foreach ($dueServers as $idx => $s) { - $sum += $s['price']; - $actions[] = $s['product']->name . ' (' . $s['period'] . ')'; - } - if ($currentCredits < $sum) { - $runOutDate = $minDate; - break; - } - $currentCredits -= $sum; - $simulationSteps[] = [ - 'date' => $minDate->format('Y-m-d H:i:s'), - 'action' => implode(' + ', $actions), - 'amount' => -$sum, - 'remaining' => $currentCredits, - 'details' => '' - ]; - // Advance nextBilling for all due servers - foreach ($serverStates as &$s) { - if ($s['nextBilling']->equalTo($minDate)) { - switch ($s['period']) { - case 'hourly': $s['nextBilling']->addHour(); break; - case 'daily': $s['nextBilling']->addDay(); break; - case 'weekly': $s['nextBilling']->addWeek(); break; - case 'monthly': $s['nextBilling']->addMonth(); break; - case 'quarterly': $s['nextBilling']->addMonths(3); break; - case 'half-annually': $s['nextBilling']->addMonths(6); break; - case 'annually': $s['nextBilling']->addYear(); break; - } - } - } - unset($s); - $step++; - } - if ($runOutDate === null && count($simulationSteps) > 0) { - $runOutDate = Carbon::parse($simulationSteps[count($simulationSteps)-1]['date']); - } - return [ - 'run_out_date' => $runOutDate, - 'simulation_steps' => $simulationSteps - ]; - } - /** * Format time left for display */ @@ -175,19 +77,26 @@ public function index(GeneralSettings $general_settings, WebsiteSettings $websit $timeLeft = null; if ($credits > 0) { - $cacheKey = 'user_credits_left:' . $user->id; - $calculation = Cache::remember($cacheKey, now()->addMinutes(5), function() use ($user, $credits) { - return $this->calculateCreditRunout($user, $credits); - }); - - if ($calculation['run_out_date']) { - $timeLeft = $this->formatTimeLeft($calculation['run_out_date']); - $timeLeft['message'] = 'Estimated run out: ' . $calculation['run_out_date']->format('d.m.Y H:i'); - - // For debugging - // $timeLeft['simulation'] = $calculation['simulation_steps']; + // Check if projection has been computed + if ($user->credit_runout_at) { + // Use existing projection + $timeLeft = $this->formatTimeLeft($user->credit_runout_at); + $timeLeft['message'] = 'Estimated run out: ' . $user->credit_runout_at->format('d.m.Y H:i'); + } elseif (!$user->credit_runout_updated_at) { + // Queue projection calculation and show placeholder + RecalculateCreditRunoutJob::dispatch($user->id); + + $timeLeft = [ + 'value' => '...', + 'unit' => '', + 'bg' => self::TIME_LEFT_BG_WARNING, + 'message' => 'Calculating estimate...' + ]; } + // if the credit_runout_at is null and credit_runout_updated_at exists, + // it means user has no active billing (all servers suspended or canceled) } + return view('home')->with([ 'usage' => $user->creditUsage(), 'credits' => $credits, diff --git a/app/Jobs/RecalculateCreditRunoutJob.php b/app/Jobs/RecalculateCreditRunoutJob.php new file mode 100644 index 000000000..6d6c69279 --- /dev/null +++ b/app/Jobs/RecalculateCreditRunoutJob.php @@ -0,0 +1,64 @@ +queue = 'default'; + + } + /** + * Get the middleware the job should pass through. + */ + public function middleware(): array + { + return [new WithoutOverlapping("credit_runout:{$this->userId}")]; + } + + public function uniqueId(): string + { + return (string) $this->userId; + } + + /** + * Execute the job. + */ + public function handle(CreditService $creditService): void + { + $user = User::find($this->userId); + if (!$user) { + return; + } + + $user->refresh(); + + try { + $runoutAt = $creditService->calculateCreditRunout($user); + } catch (\Throwable $exception) { + $runoutAt = null; + } + + // Update user with projection result + $user->update([ + 'credit_runout_at' => $runoutAt, + 'credit_runout_updated_at' => now(), + ]); + } +} + diff --git a/app/Models/Product.php b/app/Models/Product.php index 9afa4b585..518ca7a83 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -49,9 +49,24 @@ public static function boot() parent::boot(); static::creating(function (Product $product) { - $client = new Client(); + if (!$product->{$product->getKeyName()}) { + $client = new Client(); - $product->{$product->getKeyName()} = $client->generateId($size = 21); + $product->{$product->getKeyName()} = $client->generateId($size = 21); + } + }); + + static::updated(function (Product $product) { + // When product pricing or billing period changes, recalculate for all affected users + if ($product->wasChanged(['price', 'billing_period'])) { + $userIds = \App\Models\Server::where('product_id', $product->id) + ->distinct() + ->pluck('user_id'); + + foreach ($userIds as $userId) { + \App\Jobs\RecalculateCreditRunoutJob::dispatch($userId); + } + } }); static::deleting(function (Product $product) { diff --git a/app/Models/Server.php b/app/Models/Server.php index dd90d9d3e..c44e69167 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -82,9 +82,9 @@ public function getActivitylogOptions(): LogOptions 'billing_priority' => BillingPriority::class ]; - public function __construct() + public function __construct(array $attributes = []) { - parent::__construct(); + parent::__construct($attributes); $ptero_settings = new PterodactylSettings(); $this->pterodactyl = new PterodactylClient($ptero_settings); @@ -95,9 +95,23 @@ public static function boot() parent::boot(); static::creating(function (Server $server) { - $client = new Client(); + if (!$server->{$server->getKeyName()}) { + $client = new Client(); - $server->{$server->getKeyName()} = $client->generateId($size = 21); + $server->{$server->getKeyName()} = $client->generateId($size = 21); + } + }); + + static::created(function (Server $server) { + // Recalculate credit runout when server is created + \App\Jobs\RecalculateCreditRunoutJob::dispatch($server->user_id); + }); + + static::updated(function (Server $server) { + // Recalculate if product_id or billing_period-affecting fields changed + if ($server->wasChanged(['product_id', 'suspended', 'canceled'])) { + \App\Jobs\RecalculateCreditRunoutJob::dispatch($server->user_id); + } }); static::deleting(function (Server $server) { @@ -109,6 +123,11 @@ public static function boot() } } }); + + static::deleted(function (Server $server) { + // Recalculate credit runout when server is deleted + \App\Jobs\RecalculateCreditRunoutJob::dispatch($server->user_id); + }); } /** diff --git a/app/Models/User.php b/app/Models/User.php index 82a34a3b9..3a59c1afe 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -72,6 +72,8 @@ class User extends Authenticatable implements MustVerifyEmail 'suspended', 'referral_code', 'email_verified_reward', + 'credit_runout_at', + 'credit_runout_updated_at', ]; /** @@ -93,12 +95,14 @@ class User extends Authenticatable implements MustVerifyEmail 'email_verified_at' => 'datetime', 'last_seen' => 'datetime', 'server_limit' => 'integer', - 'email_verified_reward' => 'boolean' + 'email_verified_reward' => 'boolean', + 'credit_runout_at' => 'datetime', + 'credit_runout_updated_at' => 'datetime', ]; - public function __construct() + public function __construct(array $attributes = []) { - parent::__construct(); + parent::__construct($attributes); $ptero_settings = new PterodactylSettings(); $this->pterodactyl = new PterodactylClient($ptero_settings); diff --git a/app/Services/CreditService.php b/app/Services/CreditService.php index c03ac01e1..9284c7cfe 100644 --- a/app/Services/CreditService.php +++ b/app/Services/CreditService.php @@ -2,8 +2,10 @@ namespace App\Services; +use App\Enums\BillingPriority; +use App\Jobs\RecalculateCreditRunoutJob; use App\Models\User; -use Illuminate\Support\Facades\Cache; +use Carbon\Carbon; class CreditService { @@ -19,13 +21,141 @@ public function reserve(User $user, int $amount): void throw new \Exception('Unable to reserve credits: either insufficient balance or concurrent provisioning in progress. Please retry.'); } - Cache::forget('user_credits_left:' . $user->id); + // Queue projection recalculation + RecalculateCreditRunoutJob::dispatch($user->id); } public function refund(User $user, int $amount): void { User::where('id', $user->id)->increment('credits', $amount); - Cache::forget('user_credits_left:' . $user->id); + // Queue projection recalculation + RecalculateCreditRunoutJob::dispatch($user->id); + } + + /** + * Calculate when user will run out of credits using discrete billing simulation. + * + * This mirrors the ChargeServers cron behavior (calendar-aware periods, per-server charges). + * + * @param User $user + * @return Carbon|null - Estimated runout timestamp, or null if no active billing + */ + public function calculateCreditRunout(User $user): ?Carbon + { + $servers = $user->getServersWithProduct(); + if ($servers->isEmpty()) { + return null; + } + + $hasPositivePrice = $servers->contains(function ($server) { + return $server->product && $server->product->price > 0; + }); + + if (!$hasPositivePrice) { + return null; + } + + $now = now(); + $serverStates = []; + + foreach ($servers as $server) { + $product = $server->product; + if (!$product) { + continue; + } + + $period = $product->billing_period; + $price = $product->price; + + $effectivePriority = $server->effective_billing_priority; + $priorityValue = $effectivePriority instanceof BillingPriority + ? $effectivePriority->value + : (int) $effectivePriority; + + $lastBilled = $server->last_billed ? Carbon::parse($server->last_billed) : $now; + $nextBilling = $this->advanceBillingDate($lastBilled, $period); + + $serverStates[] = [ + 'period' => $period, + 'price' => $price, + 'nextBilling' => $nextBilling, + 'priority' => $priorityValue, + 'createdAt' => $server->created_at?->getTimestamp() ?? 0, + ]; + } + + if (empty($serverStates)) { + return null; + } + + $currentCredits = $user->credits; + $maxIterations = 100000; + $iterations = 0; + + while ($iterations < $maxIterations) { + $iterations++; + $minDate = $serverStates[0]['nextBilling']; + foreach ($serverStates as $state) { + if ($state['nextBilling']->lt($minDate)) { + $minDate = $state['nextBilling']; + } + } + + $dueServers = array_filter($serverStates, fn($s) => $s['nextBilling']->equalTo($minDate)); + usort($dueServers, function ($a, $b) { + if ($a['priority'] === $b['priority']) { + return $a['createdAt'] <=> $b['createdAt']; + } + + return $a['priority'] <=> $b['priority']; + }); + + foreach ($dueServers as $s) { + if ($s['price'] > 0 && $currentCredits < $s['price']) { + return $minDate->greaterThan($now) ? $minDate : $now; + } + + if ($s['price'] > 0) { + $currentCredits -= $s['price']; + } + } + + foreach ($serverStates as &$s) { + if ($s['nextBilling']->equalTo($minDate)) { + $s['nextBilling'] = $this->advanceBillingDate($s['nextBilling'], $s['period']); + } + } + unset($s); + } + + throw new \RuntimeException('Credit runout simulation exceeded max iterations.'); + } + + /** + * Advance a billing date by the product billing period. + */ + private function advanceBillingDate(Carbon $date, string $period): Carbon + { + $next = $date->copy(); + + switch ($period) { + case 'hourly': + return $next->addHour(); + case 'daily': + return $next->addDay(); + case 'weekly': + return $next->addWeek(); + case 'monthly': + return $next->addMonth(); + case 'quarterly': + return $next->addMonths(3); + case 'half-annually': + return $next->addMonths(6); + case 'annually': + return $next->addYear(); + default: + throw new \InvalidArgumentException("Invalid billing period: {$period}"); + } } } diff --git a/database/migrations/2026_05_19_100546_add_credit_runout_columns_to_users.php b/database/migrations/2026_05_19_100546_add_credit_runout_columns_to_users.php new file mode 100644 index 000000000..26e7b76b9 --- /dev/null +++ b/database/migrations/2026_05_19_100546_add_credit_runout_columns_to_users.php @@ -0,0 +1,30 @@ +timestamp('credit_runout_at')->nullable()->after('credits'); + $table->timestamp('credit_runout_updated_at')->nullable()->after('credit_runout_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('credit_runout_at'); + $table->dropColumn('credit_runout_updated_at'); + }); + } +}; From 12cba8a249a00c6b6869f31bc60f895a7aabb70e Mon Sep 17 00:00:00 2001 From: simbabimba-dev <79574809+simbabimba-dev@users.noreply.github.com> Date: Tue, 19 May 2026 21:29:40 +0000 Subject: [PATCH 2/8] feat: add credit_runout_capped field and update related logic for user credit projections --- app/Http/Controllers/HomeController.php | 48 +++++++++++++------ app/Jobs/RecalculateCreditRunoutJob.php | 18 ++++--- app/Models/User.php | 2 + app/Services/CreditService.php | 24 ++++++---- ...0526_add_credit_runout_capped_to_users.php | 28 +++++++++++ 5 files changed, 92 insertions(+), 28 deletions(-) create mode 100644 database/migrations/2026_05_19_210526_add_credit_runout_capped_to_users.php diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index a9a612509..2792ccb7c 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -9,6 +9,7 @@ use App\Settings\WebsiteSettings; use App\Settings\ReferralSettings; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -77,24 +78,25 @@ public function index(GeneralSettings $general_settings, WebsiteSettings $websit $timeLeft = null; if ($credits > 0) { - // Check if projection has been computed - if ($user->credit_runout_at) { - // Use existing projection - $timeLeft = $this->formatTimeLeft($user->credit_runout_at); - $timeLeft['message'] = 'Estimated run out: ' . $user->credit_runout_at->format('d.m.Y H:i'); - } elseif (!$user->credit_runout_updated_at) { - // Queue projection calculation and show placeholder - RecalculateCreditRunoutJob::dispatch($user->id); + $stale = $user->credit_runout_updated_at + ? $user->credit_runout_updated_at->lt(now()->subHour()) + : true; + if ($stale) { + $this->queueRunoutRecalc($user->id); + $timeLeft = $this->calculatingTimeLeft(); + } elseif ($user->credit_runout_capped) { $timeLeft = [ - 'value' => '...', - 'unit' => '', - 'bg' => self::TIME_LEFT_BG_WARNING, - 'message' => 'Calculating estimate...' + 'value' => 'More than 2', + 'unit' => 'years', + 'bg' => self::TIME_LEFT_BG_SUCCESS, + 'message' => 'Estimated run out: More than 2 years' ]; + } elseif ($user->credit_runout_at) { + $timeLeft = $this->formatTimeLeft($user->credit_runout_at); + $timeLeft['message'] = 'Estimated run out: ' . $user->credit_runout_at->format('d.m.Y H:i'); } - // if the credit_runout_at is null and credit_runout_updated_at exists, - // it means user has no active billing (all servers suspended or canceled) + // If credit_runout_at is null and not capped, user has no active billing. } return view('home')->with([ @@ -110,4 +112,22 @@ public function index(GeneralSettings $general_settings, WebsiteSettings $websit 'referral_settings' => $referral_settings ]); } + + private function queueRunoutRecalc(int $userId): void + { + $lock = Cache::lock("credit-runout-recalc:{$userId}", 300); + if ($lock->get()) { + RecalculateCreditRunoutJob::dispatch($userId); + } + } + + private function calculatingTimeLeft(): array + { + return [ + 'value' => '...', + 'unit' => '', + 'bg' => self::TIME_LEFT_BG_WARNING, + 'message' => 'Calculating estimate...' + ]; + } } diff --git a/app/Jobs/RecalculateCreditRunoutJob.php b/app/Jobs/RecalculateCreditRunoutJob.php index 6d6c69279..e4749ed08 100644 --- a/app/Jobs/RecalculateCreditRunoutJob.php +++ b/app/Jobs/RecalculateCreditRunoutJob.php @@ -8,6 +8,7 @@ use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Queue\Middleware\WithoutOverlapping; +use Illuminate\Support\Facades\DB; class RecalculateCreditRunoutJob implements ShouldQueue, ShouldBeUnique { @@ -49,16 +50,21 @@ public function handle(CreditService $creditService): void $user->refresh(); try { - $runoutAt = $creditService->calculateCreditRunout($user); + $result = $creditService->calculateCreditRunout($user); + $runoutAt = $result['runoutAt']; + $capped = $result['capped']; } catch (\Throwable $exception) { $runoutAt = null; + $capped = false; } - // Update user with projection result - $user->update([ - 'credit_runout_at' => $runoutAt, - 'credit_runout_updated_at' => now(), - ]); + DB::transaction(function () use ($user, $runoutAt, $capped) { + $user->update([ + 'credit_runout_at' => $runoutAt, + 'credit_runout_capped' => $capped, + 'credit_runout_updated_at' => now(), + ]); + }); } } diff --git a/app/Models/User.php b/app/Models/User.php index 3a59c1afe..6f42143fd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -73,6 +73,7 @@ class User extends Authenticatable implements MustVerifyEmail 'referral_code', 'email_verified_reward', 'credit_runout_at', + 'credit_runout_capped', 'credit_runout_updated_at', ]; @@ -97,6 +98,7 @@ class User extends Authenticatable implements MustVerifyEmail 'server_limit' => 'integer', 'email_verified_reward' => 'boolean', 'credit_runout_at' => 'datetime', + 'credit_runout_capped' => 'boolean', 'credit_runout_updated_at' => 'datetime', ]; diff --git a/app/Services/CreditService.php b/app/Services/CreditService.php index 9284c7cfe..781877d0c 100644 --- a/app/Services/CreditService.php +++ b/app/Services/CreditService.php @@ -39,13 +39,16 @@ public function refund(User $user, int $amount): void * This mirrors the ChargeServers cron behavior (calendar-aware periods, per-server charges). * * @param User $user - * @return Carbon|null - Estimated runout timestamp, or null if no active billing + * @return array{runoutAt: ?Carbon, capped: bool} */ - public function calculateCreditRunout(User $user): ?Carbon + public function calculateCreditRunout(User $user): array { - $servers = $user->getServersWithProduct(); + $servers = $user->servers() + ->whereNull('suspended') + ->with('product') + ->get(); if ($servers->isEmpty()) { - return null; + return ['runoutAt' => null, 'capped' => false]; } $hasPositivePrice = $servers->contains(function ($server) { @@ -53,10 +56,11 @@ public function calculateCreditRunout(User $user): ?Carbon }); if (!$hasPositivePrice) { - return null; + return ['runoutAt' => null, 'capped' => false]; } $now = now(); + $projectionLimit = $now->copy()->addYears(2); $serverStates = []; foreach ($servers as $server) { @@ -86,11 +90,11 @@ public function calculateCreditRunout(User $user): ?Carbon } if (empty($serverStates)) { - return null; + return ['runoutAt' => null, 'capped' => false]; } $currentCredits = $user->credits; - $maxIterations = 100000; + $maxIterations = 25000; $iterations = 0; while ($iterations < $maxIterations) { @@ -102,6 +106,10 @@ public function calculateCreditRunout(User $user): ?Carbon } } + if ($minDate->gte($projectionLimit)) { + return ['runoutAt' => $projectionLimit, 'capped' => true]; + } + $dueServers = array_filter($serverStates, fn($s) => $s['nextBilling']->equalTo($minDate)); usort($dueServers, function ($a, $b) { if ($a['priority'] === $b['priority']) { @@ -113,7 +121,7 @@ public function calculateCreditRunout(User $user): ?Carbon foreach ($dueServers as $s) { if ($s['price'] > 0 && $currentCredits < $s['price']) { - return $minDate->greaterThan($now) ? $minDate : $now; + return ['runoutAt' => $minDate->greaterThan($now) ? $minDate : $now, 'capped' => false]; } if ($s['price'] > 0) { diff --git a/database/migrations/2026_05_19_210526_add_credit_runout_capped_to_users.php b/database/migrations/2026_05_19_210526_add_credit_runout_capped_to_users.php new file mode 100644 index 000000000..1c9acdd89 --- /dev/null +++ b/database/migrations/2026_05_19_210526_add_credit_runout_capped_to_users.php @@ -0,0 +1,28 @@ +boolean('credit_runout_capped')->default(false)->after('credit_runout_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('credit_runout_capped'); + }); + } +}; From 23bfccca6b1a205d9cdd87a30b0335c1d0f06fa7 Mon Sep 17 00:00:00 2001 From: simbabimba-dev <79574809+simbabimba-dev@users.noreply.github.com> Date: Wed, 20 May 2026 15:43:37 +0000 Subject: [PATCH 3/8] Revert credit runout system refactor This reverts commit ce8489eacfcb6657030a0c228fa848192391e731. --- .../Commands/RecalculateAllCreditRunouts.php | 48 ------ app/Http/Controllers/HomeController.php | 147 +++++++++++++----- app/Jobs/RecalculateCreditRunoutJob.php | 70 --------- app/Models/Product.php | 19 +-- app/Models/Server.php | 27 +--- app/Models/User.php | 12 +- app/Services/CreditService.php | 144 +---------------- ...546_add_credit_runout_columns_to_users.php | 30 ---- ...0526_add_credit_runout_capped_to_users.php | 28 ---- 9 files changed, 121 insertions(+), 404 deletions(-) delete mode 100644 app/Console/Commands/RecalculateAllCreditRunouts.php delete mode 100644 app/Jobs/RecalculateCreditRunoutJob.php delete mode 100644 database/migrations/2026_05_19_100546_add_credit_runout_columns_to_users.php delete mode 100644 database/migrations/2026_05_19_210526_add_credit_runout_capped_to_users.php diff --git a/app/Console/Commands/RecalculateAllCreditRunouts.php b/app/Console/Commands/RecalculateAllCreditRunouts.php deleted file mode 100644 index e675d8d63..000000000 --- a/app/Console/Commands/RecalculateAllCreditRunouts.php +++ /dev/null @@ -1,48 +0,0 @@ -option('chunk'); - $total = User::count(); - - $this->info("Queueing credit runout recalculation for {$total} users..."); - - User::query() - ->chunk($chunkSize, function ($users) { - foreach ($users as $user) { - RecalculateCreditRunoutJob::dispatch($user->id); - } - - $this->line("Queued batch of users..."); - }); - - $this->info("All users queued for credit runout recalculation."); - $this->info("Monitor your queue worker/cronjob for completion: php artisan queue:work"); - } -} - diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 2792ccb7c..de965b4e2 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers; -use App\Jobs\RecalculateCreditRunoutJob; use App\Models\PartnerDiscount; use App\Models\UsefulLink; use App\Settings\GeneralSettings; @@ -11,6 +10,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Carbon\Carbon; class HomeController extends Controller @@ -24,6 +24,103 @@ public function __construct() $this->middleware('auth'); } + /** + * Calculate when user will run out of credits. Holy shit what have i done? for just 1 fucking box on the dashboard? + */ + protected function calculateCreditRunout($user, $credits) + { + $servers = $user->getServersWithProduct(); + if ($servers->isEmpty()) { + return [ + 'run_out_date' => null, + 'simulation_steps' => [] + ]; + } + + // Prepare all servers: get next billing date and price (in credits) + $serverStates = []; + foreach ($servers as $server) { + $product = $server->product; + $period = $product->billing_period; + $price = $product->price; + $lastBilled = $server->last_billed ? Carbon::parse($server->last_billed) : now(); + $nextBilling = $lastBilled->copy(); + while ($nextBilling->lessThanOrEqualTo(now())) { + switch ($period) { + case 'hourly': $nextBilling->addHour(); break; + case 'daily': $nextBilling->addDay(); break; + case 'weekly': $nextBilling->addWeek(); break; + case 'monthly': $nextBilling->addMonth(); break; + case 'quarterly': $nextBilling->addMonths(3); break; + case 'half-annually': $nextBilling->addMonths(6); break; + case 'annually': $nextBilling->addYear(); break; + } + } + $serverStates[] = [ + 'server' => $server, + 'product' => $product, + 'period' => $period, + 'price' => $price, + 'nextBilling' => $nextBilling + ]; + } + + $simulationSteps = []; + $currentCredits = $credits; + $runOutDate = null; + $maxSteps = 1000; // max steps to generate events. Good accuracy for most cases, prevents infinite loops. + $step = 0; + + while ($step < $maxSteps) { + // Find the next billing date among all servers + $nextDates = array_map(fn($s) => $s['nextBilling'], $serverStates); + $minDate = collect($nextDates)->min(); + // Find all servers that bill at this date + $dueServers = array_filter($serverStates, fn($s) => $s['nextBilling']->equalTo($minDate)); + $sum = 0; + $actions = []; + foreach ($dueServers as $idx => $s) { + $sum += $s['price']; + $actions[] = $s['product']->name . ' (' . $s['period'] . ')'; + } + if ($currentCredits < $sum) { + $runOutDate = $minDate; + break; + } + $currentCredits -= $sum; + $simulationSteps[] = [ + 'date' => $minDate->format('Y-m-d H:i:s'), + 'action' => implode(' + ', $actions), + 'amount' => -$sum, + 'remaining' => $currentCredits, + 'details' => '' + ]; + // Advance nextBilling for all due servers + foreach ($serverStates as &$s) { + if ($s['nextBilling']->equalTo($minDate)) { + switch ($s['period']) { + case 'hourly': $s['nextBilling']->addHour(); break; + case 'daily': $s['nextBilling']->addDay(); break; + case 'weekly': $s['nextBilling']->addWeek(); break; + case 'monthly': $s['nextBilling']->addMonth(); break; + case 'quarterly': $s['nextBilling']->addMonths(3); break; + case 'half-annually': $s['nextBilling']->addMonths(6); break; + case 'annually': $s['nextBilling']->addYear(); break; + } + } + } + unset($s); + $step++; + } + if ($runOutDate === null && count($simulationSteps) > 0) { + $runOutDate = Carbon::parse($simulationSteps[count($simulationSteps)-1]['date']); + } + return [ + 'run_out_date' => $runOutDate, + 'simulation_steps' => $simulationSteps + ]; + } + /** * Format time left for display */ @@ -78,27 +175,19 @@ public function index(GeneralSettings $general_settings, WebsiteSettings $websit $timeLeft = null; if ($credits > 0) { - $stale = $user->credit_runout_updated_at - ? $user->credit_runout_updated_at->lt(now()->subHour()) - : true; - - if ($stale) { - $this->queueRunoutRecalc($user->id); - $timeLeft = $this->calculatingTimeLeft(); - } elseif ($user->credit_runout_capped) { - $timeLeft = [ - 'value' => 'More than 2', - 'unit' => 'years', - 'bg' => self::TIME_LEFT_BG_SUCCESS, - 'message' => 'Estimated run out: More than 2 years' - ]; - } elseif ($user->credit_runout_at) { - $timeLeft = $this->formatTimeLeft($user->credit_runout_at); - $timeLeft['message'] = 'Estimated run out: ' . $user->credit_runout_at->format('d.m.Y H:i'); + $cacheKey = 'user_credits_left:' . $user->id; + $calculation = Cache::remember($cacheKey, now()->addMinutes(5), function() use ($user, $credits) { + return $this->calculateCreditRunout($user, $credits); + }); + + if ($calculation['run_out_date']) { + $timeLeft = $this->formatTimeLeft($calculation['run_out_date']); + $timeLeft['message'] = 'Estimated run out: ' . $calculation['run_out_date']->format('d.m.Y H:i'); + + // For debugging + // $timeLeft['simulation'] = $calculation['simulation_steps']; } - // If credit_runout_at is null and not capped, user has no active billing. } - return view('home')->with([ 'usage' => $user->creditUsage(), 'credits' => $credits, @@ -112,22 +201,4 @@ public function index(GeneralSettings $general_settings, WebsiteSettings $websit 'referral_settings' => $referral_settings ]); } - - private function queueRunoutRecalc(int $userId): void - { - $lock = Cache::lock("credit-runout-recalc:{$userId}", 300); - if ($lock->get()) { - RecalculateCreditRunoutJob::dispatch($userId); - } - } - - private function calculatingTimeLeft(): array - { - return [ - 'value' => '...', - 'unit' => '', - 'bg' => self::TIME_LEFT_BG_WARNING, - 'message' => 'Calculating estimate...' - ]; - } } diff --git a/app/Jobs/RecalculateCreditRunoutJob.php b/app/Jobs/RecalculateCreditRunoutJob.php deleted file mode 100644 index e4749ed08..000000000 --- a/app/Jobs/RecalculateCreditRunoutJob.php +++ /dev/null @@ -1,70 +0,0 @@ -queue = 'default'; - - } - /** - * Get the middleware the job should pass through. - */ - public function middleware(): array - { - return [new WithoutOverlapping("credit_runout:{$this->userId}")]; - } - - public function uniqueId(): string - { - return (string) $this->userId; - } - - /** - * Execute the job. - */ - public function handle(CreditService $creditService): void - { - $user = User::find($this->userId); - if (!$user) { - return; - } - - $user->refresh(); - - try { - $result = $creditService->calculateCreditRunout($user); - $runoutAt = $result['runoutAt']; - $capped = $result['capped']; - } catch (\Throwable $exception) { - $runoutAt = null; - $capped = false; - } - - DB::transaction(function () use ($user, $runoutAt, $capped) { - $user->update([ - 'credit_runout_at' => $runoutAt, - 'credit_runout_capped' => $capped, - 'credit_runout_updated_at' => now(), - ]); - }); - } -} - diff --git a/app/Models/Product.php b/app/Models/Product.php index 518ca7a83..9afa4b585 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -49,24 +49,9 @@ public static function boot() parent::boot(); static::creating(function (Product $product) { - if (!$product->{$product->getKeyName()}) { - $client = new Client(); + $client = new Client(); - $product->{$product->getKeyName()} = $client->generateId($size = 21); - } - }); - - static::updated(function (Product $product) { - // When product pricing or billing period changes, recalculate for all affected users - if ($product->wasChanged(['price', 'billing_period'])) { - $userIds = \App\Models\Server::where('product_id', $product->id) - ->distinct() - ->pluck('user_id'); - - foreach ($userIds as $userId) { - \App\Jobs\RecalculateCreditRunoutJob::dispatch($userId); - } - } + $product->{$product->getKeyName()} = $client->generateId($size = 21); }); static::deleting(function (Product $product) { diff --git a/app/Models/Server.php b/app/Models/Server.php index c44e69167..dd90d9d3e 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -82,9 +82,9 @@ public function getActivitylogOptions(): LogOptions 'billing_priority' => BillingPriority::class ]; - public function __construct(array $attributes = []) + public function __construct() { - parent::__construct($attributes); + parent::__construct(); $ptero_settings = new PterodactylSettings(); $this->pterodactyl = new PterodactylClient($ptero_settings); @@ -95,23 +95,9 @@ public static function boot() parent::boot(); static::creating(function (Server $server) { - if (!$server->{$server->getKeyName()}) { - $client = new Client(); + $client = new Client(); - $server->{$server->getKeyName()} = $client->generateId($size = 21); - } - }); - - static::created(function (Server $server) { - // Recalculate credit runout when server is created - \App\Jobs\RecalculateCreditRunoutJob::dispatch($server->user_id); - }); - - static::updated(function (Server $server) { - // Recalculate if product_id or billing_period-affecting fields changed - if ($server->wasChanged(['product_id', 'suspended', 'canceled'])) { - \App\Jobs\RecalculateCreditRunoutJob::dispatch($server->user_id); - } + $server->{$server->getKeyName()} = $client->generateId($size = 21); }); static::deleting(function (Server $server) { @@ -123,11 +109,6 @@ public static function boot() } } }); - - static::deleted(function (Server $server) { - // Recalculate credit runout when server is deleted - \App\Jobs\RecalculateCreditRunoutJob::dispatch($server->user_id); - }); } /** diff --git a/app/Models/User.php b/app/Models/User.php index 6f42143fd..82a34a3b9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -72,9 +72,6 @@ class User extends Authenticatable implements MustVerifyEmail 'suspended', 'referral_code', 'email_verified_reward', - 'credit_runout_at', - 'credit_runout_capped', - 'credit_runout_updated_at', ]; /** @@ -96,15 +93,12 @@ class User extends Authenticatable implements MustVerifyEmail 'email_verified_at' => 'datetime', 'last_seen' => 'datetime', 'server_limit' => 'integer', - 'email_verified_reward' => 'boolean', - 'credit_runout_at' => 'datetime', - 'credit_runout_capped' => 'boolean', - 'credit_runout_updated_at' => 'datetime', + 'email_verified_reward' => 'boolean' ]; - public function __construct(array $attributes = []) + public function __construct() { - parent::__construct($attributes); + parent::__construct(); $ptero_settings = new PterodactylSettings(); $this->pterodactyl = new PterodactylClient($ptero_settings); diff --git a/app/Services/CreditService.php b/app/Services/CreditService.php index 781877d0c..c03ac01e1 100644 --- a/app/Services/CreditService.php +++ b/app/Services/CreditService.php @@ -2,10 +2,8 @@ namespace App\Services; -use App\Enums\BillingPriority; -use App\Jobs\RecalculateCreditRunoutJob; use App\Models\User; -use Carbon\Carbon; +use Illuminate\Support\Facades\Cache; class CreditService { @@ -21,149 +19,13 @@ public function reserve(User $user, int $amount): void throw new \Exception('Unable to reserve credits: either insufficient balance or concurrent provisioning in progress. Please retry.'); } - // Queue projection recalculation - RecalculateCreditRunoutJob::dispatch($user->id); + Cache::forget('user_credits_left:' . $user->id); } public function refund(User $user, int $amount): void { User::where('id', $user->id)->increment('credits', $amount); - // Queue projection recalculation - RecalculateCreditRunoutJob::dispatch($user->id); - } - - /** - * Calculate when user will run out of credits using discrete billing simulation. - * - * This mirrors the ChargeServers cron behavior (calendar-aware periods, per-server charges). - * - * @param User $user - * @return array{runoutAt: ?Carbon, capped: bool} - */ - public function calculateCreditRunout(User $user): array - { - $servers = $user->servers() - ->whereNull('suspended') - ->with('product') - ->get(); - if ($servers->isEmpty()) { - return ['runoutAt' => null, 'capped' => false]; - } - - $hasPositivePrice = $servers->contains(function ($server) { - return $server->product && $server->product->price > 0; - }); - - if (!$hasPositivePrice) { - return ['runoutAt' => null, 'capped' => false]; - } - - $now = now(); - $projectionLimit = $now->copy()->addYears(2); - $serverStates = []; - - foreach ($servers as $server) { - $product = $server->product; - if (!$product) { - continue; - } - - $period = $product->billing_period; - $price = $product->price; - - $effectivePriority = $server->effective_billing_priority; - $priorityValue = $effectivePriority instanceof BillingPriority - ? $effectivePriority->value - : (int) $effectivePriority; - - $lastBilled = $server->last_billed ? Carbon::parse($server->last_billed) : $now; - $nextBilling = $this->advanceBillingDate($lastBilled, $period); - - $serverStates[] = [ - 'period' => $period, - 'price' => $price, - 'nextBilling' => $nextBilling, - 'priority' => $priorityValue, - 'createdAt' => $server->created_at?->getTimestamp() ?? 0, - ]; - } - - if (empty($serverStates)) { - return ['runoutAt' => null, 'capped' => false]; - } - - $currentCredits = $user->credits; - $maxIterations = 25000; - $iterations = 0; - - while ($iterations < $maxIterations) { - $iterations++; - $minDate = $serverStates[0]['nextBilling']; - foreach ($serverStates as $state) { - if ($state['nextBilling']->lt($minDate)) { - $minDate = $state['nextBilling']; - } - } - - if ($minDate->gte($projectionLimit)) { - return ['runoutAt' => $projectionLimit, 'capped' => true]; - } - - $dueServers = array_filter($serverStates, fn($s) => $s['nextBilling']->equalTo($minDate)); - usort($dueServers, function ($a, $b) { - if ($a['priority'] === $b['priority']) { - return $a['createdAt'] <=> $b['createdAt']; - } - - return $a['priority'] <=> $b['priority']; - }); - - foreach ($dueServers as $s) { - if ($s['price'] > 0 && $currentCredits < $s['price']) { - return ['runoutAt' => $minDate->greaterThan($now) ? $minDate : $now, 'capped' => false]; - } - - if ($s['price'] > 0) { - $currentCredits -= $s['price']; - } - } - - foreach ($serverStates as &$s) { - if ($s['nextBilling']->equalTo($minDate)) { - $s['nextBilling'] = $this->advanceBillingDate($s['nextBilling'], $s['period']); - } - } - unset($s); - } - - throw new \RuntimeException('Credit runout simulation exceeded max iterations.'); - } - - /** - * Advance a billing date by the product billing period. - */ - private function advanceBillingDate(Carbon $date, string $period): Carbon - { - $next = $date->copy(); - - switch ($period) { - case 'hourly': - return $next->addHour(); - case 'daily': - return $next->addDay(); - case 'weekly': - return $next->addWeek(); - case 'monthly': - return $next->addMonth(); - case 'quarterly': - return $next->addMonths(3); - case 'half-annually': - return $next->addMonths(6); - case 'annually': - return $next->addYear(); - default: - throw new \InvalidArgumentException("Invalid billing period: {$period}"); - } + Cache::forget('user_credits_left:' . $user->id); } } diff --git a/database/migrations/2026_05_19_100546_add_credit_runout_columns_to_users.php b/database/migrations/2026_05_19_100546_add_credit_runout_columns_to_users.php deleted file mode 100644 index 26e7b76b9..000000000 --- a/database/migrations/2026_05_19_100546_add_credit_runout_columns_to_users.php +++ /dev/null @@ -1,30 +0,0 @@ -timestamp('credit_runout_at')->nullable()->after('credits'); - $table->timestamp('credit_runout_updated_at')->nullable()->after('credit_runout_at'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('users', function (Blueprint $table) { - $table->dropColumn('credit_runout_at'); - $table->dropColumn('credit_runout_updated_at'); - }); - } -}; diff --git a/database/migrations/2026_05_19_210526_add_credit_runout_capped_to_users.php b/database/migrations/2026_05_19_210526_add_credit_runout_capped_to_users.php deleted file mode 100644 index 1c9acdd89..000000000 --- a/database/migrations/2026_05_19_210526_add_credit_runout_capped_to_users.php +++ /dev/null @@ -1,28 +0,0 @@ -boolean('credit_runout_capped')->default(false)->after('credit_runout_at'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('users', function (Blueprint $table) { - $table->dropColumn('credit_runout_capped'); - }); - } -}; From 51321cdc5f7de4499cddae20eb25656e4428df13 Mon Sep 17 00:00:00 2001 From: simbabimba-dev <79574809+simbabimba-dev@users.noreply.github.com> Date: Wed, 20 May 2026 15:52:02 +0000 Subject: [PATCH 4/8] refactor: simplify credit runout logic and enhance time left display in HomeController --- app/Http/Controllers/HomeController.php | 225 +++++++++--------------- 1 file changed, 80 insertions(+), 145 deletions(-) diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index de965b4e2..6d60f2143 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -8,15 +8,18 @@ use App\Settings\WebsiteSettings; use App\Settings\ReferralSettings; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; -use Carbon\Carbon; - +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\URL; class HomeController extends Controller { const TIME_LEFT_BG_SUCCESS = 'bg-success'; + const TIME_LEFT_BG_WARNING = 'bg-warning'; + const TIME_LEFT_BG_DANGER = 'bg-danger'; public function __construct() @@ -24,178 +27,110 @@ public function __construct() $this->middleware('auth'); } + /* + * TODO: This is commented due to the fact the market is a bad dependency, will be changed later. + public function callHome() + { + if (Storage::exists('callHome')) { + return; + } + Http::asForm()->post('https://market.CtrlPanel.gg/callhome.php', [ + 'id' => Hash::make(URL::current()), + ]); + Storage::put('callHome', 'This is only used to count the installations of cpgg.'); + }*/ + /** - * Calculate when user will run out of credits. Holy shit what have i done? for just 1 fucking box on the dashboard? + * @description Get the Background Color for the Days-Left-Box in HomeView + * + * @param float $daysLeft + * @return string */ - protected function calculateCreditRunout($user, $credits) + public function getTimeLeftBoxBackground(float $daysLeft): string { - $servers = $user->getServersWithProduct(); - if ($servers->isEmpty()) { - return [ - 'run_out_date' => null, - 'simulation_steps' => [] - ]; + if ($daysLeft >= 15) { + return $this::TIME_LEFT_BG_SUCCESS; } - - // Prepare all servers: get next billing date and price (in credits) - $serverStates = []; - foreach ($servers as $server) { - $product = $server->product; - $period = $product->billing_period; - $price = $product->price; - $lastBilled = $server->last_billed ? Carbon::parse($server->last_billed) : now(); - $nextBilling = $lastBilled->copy(); - while ($nextBilling->lessThanOrEqualTo(now())) { - switch ($period) { - case 'hourly': $nextBilling->addHour(); break; - case 'daily': $nextBilling->addDay(); break; - case 'weekly': $nextBilling->addWeek(); break; - case 'monthly': $nextBilling->addMonth(); break; - case 'quarterly': $nextBilling->addMonths(3); break; - case 'half-annually': $nextBilling->addMonths(6); break; - case 'annually': $nextBilling->addYear(); break; - } - } - $serverStates[] = [ - 'server' => $server, - 'product' => $product, - 'period' => $period, - 'price' => $price, - 'nextBilling' => $nextBilling - ]; + if ($daysLeft <= 7) { + return $this::TIME_LEFT_BG_DANGER; } - $simulationSteps = []; - $currentCredits = $credits; - $runOutDate = null; - $maxSteps = 1000; // max steps to generate events. Good accuracy for most cases, prevents infinite loops. - $step = 0; - - while ($step < $maxSteps) { - // Find the next billing date among all servers - $nextDates = array_map(fn($s) => $s['nextBilling'], $serverStates); - $minDate = collect($nextDates)->min(); - // Find all servers that bill at this date - $dueServers = array_filter($serverStates, fn($s) => $s['nextBilling']->equalTo($minDate)); - $sum = 0; - $actions = []; - foreach ($dueServers as $idx => $s) { - $sum += $s['price']; - $actions[] = $s['product']->name . ' (' . $s['period'] . ')'; - } - if ($currentCredits < $sum) { - $runOutDate = $minDate; - break; - } - $currentCredits -= $sum; - $simulationSteps[] = [ - 'date' => $minDate->format('Y-m-d H:i:s'), - 'action' => implode(' + ', $actions), - 'amount' => -$sum, - 'remaining' => $currentCredits, - 'details' => '' - ]; - // Advance nextBilling for all due servers - foreach ($serverStates as &$s) { - if ($s['nextBilling']->equalTo($minDate)) { - switch ($s['period']) { - case 'hourly': $s['nextBilling']->addHour(); break; - case 'daily': $s['nextBilling']->addDay(); break; - case 'weekly': $s['nextBilling']->addWeek(); break; - case 'monthly': $s['nextBilling']->addMonth(); break; - case 'quarterly': $s['nextBilling']->addMonths(3); break; - case 'half-annually': $s['nextBilling']->addMonths(6); break; - case 'annually': $s['nextBilling']->addYear(); break; - } - } - } - unset($s); - $step++; - } - if ($runOutDate === null && count($simulationSteps) > 0) { - $runOutDate = Carbon::parse($simulationSteps[count($simulationSteps)-1]['date']); - } - return [ - 'run_out_date' => $runOutDate, - 'simulation_steps' => $simulationSteps - ]; + return $this::TIME_LEFT_BG_WARNING; } /** - * Format time left for display + * @description Set "hours", "days" or nothing behind the remaining time + * + * @param float $daysLeft + * @param float $hoursLeft + * @return string|void */ - protected function formatTimeLeft($date) + public function getTimeLeftBoxUnit(float $daysLeft, float $hoursLeft) { - if (!$date) return null; - - $now = now(); - $daysLeft = $now->diffInDays($date, false); - $hoursLeft = $now->diffInHours($date, false); - $minutesLeft = $now->diffInMinutes($date, false); - if ($daysLeft > 1) { - return [ - 'value' => floor($daysLeft), - 'unit' => 'days', - 'bg' => $daysLeft >= 15 ? self::TIME_LEFT_BG_SUCCESS : - ($daysLeft <= 7 ? self::TIME_LEFT_BG_DANGER : self::TIME_LEFT_BG_WARNING) - ]; + return __('days'); } - if ($hoursLeft > 1) { - return [ - 'value' => floor($hoursLeft), - 'unit' => 'hours', - 'bg' => $hoursLeft <= 24 ? self::TIME_LEFT_BG_DANGER : self::TIME_LEFT_BG_WARNING - ]; - } + return $hoursLeft < 1 ? null : __('hours'); + } - if ($minutesLeft > 1) { - return [ - 'value' => floor($minutesLeft), - 'unit' => 'minutes', - 'bg' => self::TIME_LEFT_BG_DANGER - ]; + /** + * @description Get the Text for the Days-Left-Box in HomeView + * + * @param float $daysLeft + * @param float $hoursLeft + * @return string + */ + public function getTimeLeftBoxText(float $daysLeft, float $hoursLeft) + { + if ($daysLeft > 1) { + return strval(number_format($daysLeft, 0)); } - return [ - 'value' => 'Less than 1', - 'unit' => 'minute', - 'bg' => self::TIME_LEFT_BG_DANGER - ]; + return $hoursLeft < 1 ? __('You ran out of Credits') : strval($hoursLeft); } - /** - * Show the application dashboard - */ + /** Show the application dashboard. */ public function index(GeneralSettings $general_settings, WebsiteSettings $website_settings, ReferralSettings $referral_settings) { - $user = Auth::user(); - $credits = $user->credits; + $usage = Auth::user()->creditUsage(); + $credits = Auth::user()->credits; + $bg = ''; + $boxText = ''; + $unit = ''; $timeLeft = null; - if ($credits > 0) { - $cacheKey = 'user_credits_left:' . $user->id; - $calculation = Cache::remember($cacheKey, now()->addMinutes(5), function() use ($user, $credits) { - return $this->calculateCreditRunout($user, $credits); - }); + /** Build our Time-Left-Box */ + if ($credits > 10 && $usage > 0) { + $daysLeft = $credits / ($usage / 30); + $hoursLeft = $credits / ($usage / 30 / 24); - if ($calculation['run_out_date']) { - $timeLeft = $this->formatTimeLeft($calculation['run_out_date']); - $timeLeft['message'] = 'Estimated run out: ' . $calculation['run_out_date']->format('d.m.Y H:i'); + $bg = $this->getTimeLeftBoxBackground($daysLeft); + $boxText = $this->getTimeLeftBoxText($daysLeft, $hoursLeft); + $unit = $this->getTimeLeftBoxUnit($daysLeft, $hoursLeft); - // For debugging - // $timeLeft['simulation'] = $calculation['simulation_steps']; - } + $timeLeft = [ + 'bg' => $bg, + 'message' => __('Estimated run out: :value :unit', ['value' => $boxText, 'unit' => $unit ?? '']), + 'value' => $boxText, + 'unit' => $unit + ]; } + + //$this->callhome(); TODO: Same as the function + + // RETURN ALL VALUES return view('home')->with([ - 'usage' => $user->creditUsage(), + 'usage' => $usage, 'credits' => $credits, 'useful_links_dashboard' => UsefulLink::where("position","like","%dashboard%")->get()->sortby("id"), - 'timeLeft' => $timeLeft, - 'numberOfReferrals' => DB::table('user_referrals')->where('referral_id', '=', $user->id)->count(), - 'partnerDiscount' => PartnerDiscount::where('user_id', $user->id)->first(), + 'bg' => $bg, + 'boxText' => $boxText, + 'unit' => $unit, + 'numberOfReferrals' => DB::table('user_referrals')->where('referral_id', '=', Auth::user()->id)->count(), + 'partnerDiscount' => PartnerDiscount::where('user_id', Auth::user()->id)->first(), 'myDiscount' => PartnerDiscount::getDiscount(), + 'timeLeft' => $timeLeft, 'general_settings' => $general_settings, 'website_settings' => $website_settings, 'referral_settings' => $referral_settings From 66e6d3cde3f6d11b14c36a636a4d1502cce5a1df Mon Sep 17 00:00:00 2001 From: simbabimba-dev <79574809+simbabimba-dev@users.noreply.github.com> Date: Wed, 20 May 2026 16:32:59 +0000 Subject: [PATCH 5/8] refactor: remove unused callhome method. --- app/Http/Controllers/HomeController.php | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 6d60f2143..db0a6f822 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -27,19 +27,6 @@ public function __construct() $this->middleware('auth'); } - /* - * TODO: This is commented due to the fact the market is a bad dependency, will be changed later. - public function callHome() - { - if (Storage::exists('callHome')) { - return; - } - Http::asForm()->post('https://market.CtrlPanel.gg/callhome.php', [ - 'id' => Hash::make(URL::current()), - ]); - Storage::put('callHome', 'This is only used to count the installations of cpgg.'); - }*/ - /** * @description Get the Background Color for the Days-Left-Box in HomeView * From 8dcbe50b4eb8e131ecbe7f35f61af5f013510ee5 Mon Sep 17 00:00:00 2001 From: simbabimba-dev <79574809+simbabimba-dev@users.noreply.github.com> Date: Sun, 24 May 2026 03:20:03 +0000 Subject: [PATCH 6/8] feat: add estimated run out date calculation in HomeController --- app/Http/Controllers/HomeController.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index db0a6f822..df55dae18 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\URL; +use Carbon\Carbon; class HomeController extends Controller { @@ -96,15 +97,21 @@ public function index(GeneralSettings $general_settings, WebsiteSettings $websit $boxText = $this->getTimeLeftBoxText($daysLeft, $hoursLeft); $unit = $this->getTimeLeftBoxUnit($daysLeft, $hoursLeft); + if ($daysLeft > 1) { + $estimatedDate = Carbon::now()->addDays((int) ceil($daysLeft)); + } else { + $estimatedDate = Carbon::now()->addHours((int) ceil($hoursLeft)); + } + $timeLeft = [ 'bg' => $bg, - 'message' => __('Estimated run out: :value :unit', ['value' => $boxText, 'unit' => $unit ?? '']), + 'message' => __('Estimated run out: :date', ['date' => $estimatedDate->format('d-m-Y H:i')]), + 'date' => $estimatedDate->toDateString(), 'value' => $boxText, 'unit' => $unit ]; } - //$this->callhome(); TODO: Same as the function // RETURN ALL VALUES return view('home')->with([ From dd40a2d3642b2b582921150d852e4152b6df1b20 Mon Sep 17 00:00:00 2001 From: simbabimba-dev <79574809+simbabimba-dev@users.noreply.github.com> Date: Sun, 24 May 2026 03:21:38 +0000 Subject: [PATCH 7/8] remove empty lines between constant definitions --- app/Http/Controllers/HomeController.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index df55dae18..46b7c2045 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -18,9 +18,7 @@ class HomeController extends Controller { const TIME_LEFT_BG_SUCCESS = 'bg-success'; - const TIME_LEFT_BG_WARNING = 'bg-warning'; - const TIME_LEFT_BG_DANGER = 'bg-danger'; public function __construct() From e5a971fdd2462e5d3327b3ad2aca3a4c0193b8a7 Mon Sep 17 00:00:00 2001 From: simbabimba-dev <79574809+simbabimba-dev@users.noreply.github.com> Date: Sun, 31 May 2026 05:22:36 +0000 Subject: [PATCH 8/8] feat: show estimated run-out date and improve time remaining display --- app/Http/Controllers/HomeController.php | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 46b7c2045..fb988a596 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -45,7 +45,7 @@ public function getTimeLeftBoxBackground(float $daysLeft): string } /** - * @description Set "hours", "days" or nothing behind the remaining time + * @description Set unit behind the remaining time (deprecated - units now in value) * * @param float $daysLeft * @param float $hoursLeft @@ -53,11 +53,7 @@ public function getTimeLeftBoxBackground(float $daysLeft): string */ public function getTimeLeftBoxUnit(float $daysLeft, float $hoursLeft) { - if ($daysLeft > 1) { - return __('days'); - } - - return $hoursLeft < 1 ? null : __('hours'); + return null; } /** @@ -69,11 +65,21 @@ public function getTimeLeftBoxUnit(float $daysLeft, float $hoursLeft) */ public function getTimeLeftBoxText(float $daysLeft, float $hoursLeft) { - if ($daysLeft > 1) { - return strval(number_format($daysLeft, 0)); + if ($hoursLeft < 1) { + return __('You ran out of Credits'); + } + + $fullDays = (int) floor($daysLeft); + $remainingHours = (int) ceil($hoursLeft - ($fullDays * 24)); + if ($fullDays > 0 && $remainingHours > 0) { + return strval(number_format($fullDays, 0)) . __('d') . ' ' . strval(number_format($remainingHours, 0)) . __('h'); + } + + if ($fullDays > 0) { + return strval(number_format($fullDays, 0)) . __('d'); } - return $hoursLeft < 1 ? __('You ran out of Credits') : strval($hoursLeft); + return strval(number_format($hoursLeft, 0)) . __('h'); } /** Show the application dashboard. */