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 $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 $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; } }