222 lines
7.5 KiB
PHP
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;
|
|
}
|
|
}
|