agent/php/Agentic/Services/CreditService.php
Snider 470ce0de99 feat(agentic): implement §9 Services (FleetService + CreditService + SessionService) (#849)
Additive-only — no existing files modified.

- FleetService: wraps fleet actions+models, register/heartbeat/dispatch
  (direct or queued), node health snapshots, typed fleet stats
- CreditService: workspace-level balance/refund/deduct/ledger over
  credit_entries, returns typed CreditTransaction DTOs
- SessionService: RFC-§7 lifecycle session creation + guarded state
  transitions + SSE-style emission via Laravel events

DTOs: FleetStats, CreditTransaction (readonly).
Pest Feature tests _Good/_Bad/_Ugly per AX-10. pest skipped (vendor missing).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=849
2026-04-25 05:28:49 +01:00

99 lines
3.3 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Services;
use Core\Mod\Agentic\Data\CreditTransaction;
use Core\Mod\Agentic\Models\CreditEntry;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
class CreditService
{
public function balance(Workspace|int $workspace): array
{
$workspaceId = $this->resolveWorkspaceId($workspace);
$entries = CreditEntry::query()->where('workspace_id', $workspaceId);
return [
'workspace_id' => $workspaceId,
'balance' => (int) (clone $entries)->sum('amount'),
'total_earned' => (int) (clone $entries)->where('amount', '>', 0)->sum('amount'),
'total_spent' => (int) abs((int) (clone $entries)->where('amount', '<', 0)->sum('amount')),
'entries' => (int) (clone $entries)->count(),
];
}
public function deduct(Workspace|int $workspace, int $amount, string $reason): CreditTransaction
{
return $this->record($workspace, -abs($amount), 'manual-deduction', $reason);
}
public function refund(Workspace|int $workspace, int $amount, string $reason): CreditTransaction
{
return $this->record($workspace, abs($amount), 'manual-refund', $reason);
}
/**
* @return Collection<int, CreditTransaction>
*/
public function ledger(Workspace|int $workspace): Collection
{
$workspaceId = $this->resolveWorkspaceId($workspace);
return CreditEntry::query()
->where('workspace_id', $workspaceId)
->latest('id')
->get()
->map(static fn (CreditEntry $entry): CreditTransaction => CreditTransaction::fromModel($entry));
}
private function record(Workspace|int $workspace, int $amount, string $taskType, string $reason): CreditTransaction
{
$workspaceId = $this->resolveWorkspaceId($workspace);
$reason = trim($reason);
if ($amount === 0) {
throw new InvalidArgumentException('amount must be greater than zero');
}
if ($reason === '') {
throw new InvalidArgumentException('reason is required');
}
$entry = DB::transaction(function () use ($workspaceId, $amount, $taskType, $reason): CreditEntry {
$previousBalance = (int) CreditEntry::query()
->where('workspace_id', $workspaceId)
->lockForUpdate()
->latest('id')
->value('balance_after');
return CreditEntry::query()->create([
'workspace_id' => $workspaceId,
'fleet_node_id' => null,
'task_type' => $taskType,
'amount' => $amount,
'balance_after' => $previousBalance + $amount,
'description' => $reason,
]);
});
return CreditTransaction::fromModel($entry);
}
private function resolveWorkspaceId(Workspace|int $workspace): int
{
$workspaceId = $workspace instanceof Workspace ? (int) $workspace->id : (int) $workspace;
if ($workspaceId <= 0) {
throw new InvalidArgumentException('workspace_id is required');
}
return $workspaceId;
}
}