Files

74 lines
2.2 KiB
PHP
Raw Permalink Normal View History

<?php
namespace App\Services\Runware;
use App\Exceptions\BudgetExceededException;
use App\Models\Generation;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* Enforces the hard monthly EUR spend cap on Runware usage. Every generation
* must go through ensureCanSpend() before the API is called, and the actual
* cost must be reconciled afterward via recordActualUsd() so the running
* total reflects reality (Runware only bills successful generations).
*/
class BudgetGuard
{
protected const LOCK_KEY = 'runware:budget-guard-lock';
protected const LOCK_TIMEOUT_SECONDS = 10;
public function __construct(protected CurrencyRateService $rates) {}
public function capEur(): float
{
return (float) config('runware.monthly_cap_eur');
}
public function spentEurThisMonth(): float
{
return (float) Generation::query()
->where('created_at', '>=', now()->startOfMonth())
->whereNotNull('cost_eur')
->sum('cost_eur');
}
public function remainingEur(): float
{
return max(0.0, $this->capEur() - $this->spentEurThisMonth());
}
public function warnThresholdReached(): bool
{
$cap = $this->capEur();
if ($cap <= 0) {
return false;
}
return ($this->spentEurThisMonth() / $cap) >= (float) config('runware.warn_threshold');
}
/**
* Throws BudgetExceededException if spending $estimatedUsd would push
* this month's total (plus a safety margin) past the cap. Guards the
* check with an atomic lock so concurrent requests can't both slip
* through right at the boundary.
*/
public function ensureCanSpend(float $estimatedUsd): void
{
Cache::lock(self::LOCK_KEY, self::LOCK_TIMEOUT_SECONDS)->block(5, function () use ($estimatedUsd) {
$rate = $this->rates->usdToEur();
$estimatedEur = $estimatedUsd * $rate;
$spent = $this->spentEurThisMonth();
$cap = $this->capEur();
$margin = $cap * (float) config('runware.safety_margin');
if ($spent + $estimatedEur + $margin > $cap) {
throw new BudgetExceededException($spent, $cap, $estimatedEur);
}
});
}
}