Files
fotospiel-app/app/Services/AiEditing/AiBudgetGuardService.php
Codex Agent 1d2242fb4d
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
tests / ui (push) Waiting to run
feat(ai): finalize AI magic edits epic rollout and operations
2026-02-06 22:41:51 +01:00

222 lines
7.5 KiB
PHP

<?php
namespace App\Services\AiEditing;
use App\Models\AiUsageLedger;
use App\Models\Event;
use App\Models\Tenant;
use App\Models\TenantNotificationReceipt;
use App\Services\Packages\TenantNotificationLogger;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class AiBudgetGuardService
{
/**
* @return array{
* allowed: bool,
* reason_code: ?string,
* message: ?string,
* budget: array{
* period_start: string,
* period_end: string,
* current_spend_usd: float,
* soft_cap_usd: ?float,
* hard_cap_usd: ?float,
* soft_reached: bool,
* hard_reached: bool,
* hard_stop_enabled: bool,
* override_active: bool
* }
* }
*/
public function evaluateForEvent(Event $event): array
{
$now = now();
$periodStart = $now->copy()->startOfMonth();
$periodEnd = $now->copy()->endOfMonth();
$tenantSettings = (array) ($event->tenant?->settings ?? []);
$softCap = $this->resolveCap(
Arr::get($tenantSettings, 'ai_editing.budget.soft_cap_usd'),
config('ai-editing.billing.budget.soft_cap_usd')
);
$hardCap = $this->resolveCap(
Arr::get($tenantSettings, 'ai_editing.budget.hard_cap_usd'),
config('ai-editing.billing.budget.hard_cap_usd')
);
if ($softCap !== null && $hardCap !== null && $softCap > $hardCap) {
[$softCap, $hardCap] = [$hardCap, $softCap];
}
$spendUsd = (float) (AiUsageLedger::query()
->where('tenant_id', $event->tenant_id)
->where('recorded_at', '>=', $periodStart)
->where('recorded_at', '<=', $periodEnd)
->where('entry_type', AiUsageLedger::TYPE_DEBIT)
->sum('amount_usd') ?: 0.0);
$softReached = $softCap !== null && $spendUsd >= $softCap;
$hardReached = $hardCap !== null && $spendUsd >= $hardCap;
$hardStopEnabled = (bool) config('ai-editing.billing.budget.hard_stop_enabled', true);
$overrideUntil = Arr::get($tenantSettings, 'ai_editing.budget.override_until');
$overrideActive = $this->isOverrideActive($overrideUntil, $now);
if ($softReached) {
$this->emitBudgetAlert($event, 'soft_cap_reached', [
'spend_usd' => $spendUsd,
'soft_cap_usd' => $softCap,
'hard_cap_usd' => $hardCap,
]);
}
if ($hardReached) {
$this->emitBudgetAlert($event, 'hard_cap_reached', [
'spend_usd' => $spendUsd,
'soft_cap_usd' => $softCap,
'hard_cap_usd' => $hardCap,
'hard_stop_enabled' => $hardStopEnabled,
'override_active' => $overrideActive,
]);
}
$allowed = ! ($hardReached && $hardStopEnabled && ! $overrideActive);
return [
'allowed' => $allowed,
'reason_code' => $allowed ? null : 'budget_hard_cap_reached',
'message' => $allowed ? null : 'The AI editing budget for this billing period has been exhausted.',
'budget' => [
'period_start' => $periodStart->toDateString(),
'period_end' => $periodEnd->toDateString(),
'current_spend_usd' => round($spendUsd, 5),
'soft_cap_usd' => $softCap,
'hard_cap_usd' => $hardCap,
'soft_reached' => $softReached,
'hard_reached' => $hardReached,
'hard_stop_enabled' => $hardStopEnabled,
'override_active' => $overrideActive,
],
];
}
private function resolveCap(mixed $tenantValue, mixed $defaultValue): ?float
{
if (is_numeric($tenantValue)) {
$resolved = (float) $tenantValue;
return $resolved >= 0 ? $resolved : null;
}
if (is_numeric($defaultValue)) {
$resolved = (float) $defaultValue;
return $resolved >= 0 ? $resolved : null;
}
return null;
}
private function isOverrideActive(mixed $overrideUntil, Carbon $now): bool
{
if (! is_string($overrideUntil) || trim($overrideUntil) === '') {
return false;
}
try {
$deadline = Carbon::parse($overrideUntil);
} catch (\Throwable) {
return false;
}
return $deadline->isFuture();
}
/**
* @param array<string, mixed> $context
*/
private function emitBudgetAlert(Event $event, string $type, array $context): void
{
$cooldownMinutes = max(1, (int) config('ai-editing.billing.budget.alert_cooldown_minutes', 30));
$cacheKey = sprintf('ai-editing:budget-alert:%s:tenant:%d:event:%d', $type, $event->tenant_id, $event->id);
if (! Cache::add($cacheKey, 1, now()->addMinutes($cooldownMinutes))) {
return;
}
Log::warning('AI budget threshold reached', array_merge($context, [
'type' => $type,
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
]));
$this->notifyTenant($event, $type, $context);
}
/**
* @param array<string, mixed> $context
*/
private function notifyTenant(Event $event, string $type, array $context): void
{
/** @var Tenant|null $tenant */
$tenant = $event->tenant()->with('user')->first();
if (! $tenant) {
return;
}
$notificationType = $type === 'hard_cap_reached'
? 'ai_budget_hard_cap'
: 'ai_budget_soft_cap';
$log = app(TenantNotificationLogger::class)->log($tenant, [
'type' => $notificationType,
'channel' => 'system',
'status' => 'sent',
'sent_at' => now(),
'context' => [
'scope' => 'ai',
'threshold' => $type,
'event_id' => (int) $event->id,
'event_slug' => (string) $event->slug,
'event_name' => $this->resolveEventName($event->name),
'spend_usd' => round((float) ($context['spend_usd'] ?? 0), 5),
'soft_cap_usd' => is_numeric($context['soft_cap_usd'] ?? null) ? (float) $context['soft_cap_usd'] : null,
'hard_cap_usd' => is_numeric($context['hard_cap_usd'] ?? null) ? (float) $context['hard_cap_usd'] : null,
'hard_stop_enabled' => (bool) ($context['hard_stop_enabled'] ?? config('ai-editing.billing.budget.hard_stop_enabled', true)),
'override_active' => (bool) ($context['override_active'] ?? false),
],
]);
$userId = (int) ($tenant->user_id ?? 0);
if ($userId > 0) {
TenantNotificationReceipt::query()->create([
'tenant_id' => (int) $tenant->id,
'notification_log_id' => (int) $log->id,
'user_id' => $userId,
'status' => 'delivered',
]);
}
}
private function resolveEventName(mixed $name): ?string
{
if (is_string($name) && trim($name) !== '') {
return trim($name);
}
if (is_array($name)) {
foreach ($name as $candidate) {
if (is_string($candidate) && trim($candidate) !== '') {
return trim($candidate);
}
}
}
return null;
}
}