agent/php/Controllers/Api/Credits/CreditsController.php
Snider dffdad8418 feat(api): implement §3 fleet+credits+subscription+sync+agent-auth routes (#848)
Additive-only — appended to php/Routes/api.php (existing routes
preserved). Existing /v1/fleet/{nodes,heartbeat,stats} +
/v1/agent/auth/provision left untouched.

New routes:
- /v1/agent/auth/register
- /v1/fleet/dispatch + /v1/fleet/stream
- /v1/credits/{balance,deduct,refund,ledger}
- /v1/subscription/{status,upgrade,cancel}
- /v1/agent/sync/{push,pull}

New controllers under php/Controllers/Api/{Fleet,Credits,Subscription,
Sync,AgentAuth}/. Reference FleetService/CreditService/SessionService
when available with fallbacks to current action/model layer (pre #849).

Pest Feature coverage under php/tests/Feature/Api/. pest skipped
(vendor binaries missing in sandbox).

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

176 lines
6.5 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api\Credits;
use Core\Front\Controller;
use Core\Mod\Agentic\Models\CreditEntry;
use Core\Mod\Agentic\Services\CreditService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class CreditsController extends Controller
{
public function balance(Request $request): JsonResponse
{
$workspaceId = (int) $request->attributes->get('workspace_id');
$service = $this->resolveCreditService();
$payload = $service !== null && method_exists($service, 'balance')
? (array) $service->balance($workspaceId)
: $this->fallbackBalance($workspaceId);
return response()->json(['data' => $payload]);
}
public function deduct(Request $request): JsonResponse
{
$validated = $request->validate([
'amount' => 'required|integer|min:1',
'reason' => 'required|string|max:1000',
]);
$workspaceId = (int) $request->attributes->get('workspace_id');
$service = $this->resolveCreditService();
$entry = $service !== null && method_exists($service, 'deduct')
? $service->deduct($workspaceId, (int) $validated['amount'], $validated['reason'])
: $this->recordTransaction($workspaceId, -abs((int) $validated['amount']), 'manual-deduction', $validated['reason']);
return response()->json(['data' => $this->formatEntry($entry)], 201);
}
public function refund(Request $request): JsonResponse
{
$validated = $request->validate([
'amount' => 'required|integer|min:1',
'reason' => 'required|string|max:1000',
]);
$workspaceId = (int) $request->attributes->get('workspace_id');
$service = $this->resolveCreditService();
$entry = $service !== null && method_exists($service, 'refund')
? $service->refund($workspaceId, (int) $validated['amount'], $validated['reason'])
: $this->recordTransaction($workspaceId, abs((int) $validated['amount']), 'manual-refund', $validated['reason']);
return response()->json(['data' => $this->formatEntry($entry)], 201);
}
public function ledger(Request $request): JsonResponse
{
$validated = $request->validate([
'limit' => 'nullable|integer|min:1|max:500',
]);
$workspaceId = (int) $request->attributes->get('workspace_id');
$limit = (int) ($validated['limit'] ?? 50);
$service = $this->resolveCreditService();
$entries = [];
if ($service !== null && method_exists($service, 'ledger')) {
foreach ($service->ledger($workspaceId) as $entry) {
$entries[] = $this->formatEntry($entry);
if (count($entries) >= $limit) {
break;
}
}
} else {
foreach (CreditEntry::query()->where('workspace_id', $workspaceId)->latest('id')->limit($limit)->get() as $entry) {
$entries[] = $this->formatEntry($entry);
}
}
return response()->json([
'data' => $entries,
'total' => count($entries),
]);
}
/**
* @return array<string, mixed>
*/
private function fallbackBalance(int $workspaceId): array
{
$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(),
];
}
private function recordTransaction(int $workspaceId, int $amount, string $taskType, string $reason): CreditEntry
{
return 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 array<string, mixed>
*/
private function formatEntry(object|array $entry): array
{
if (is_array($entry)) {
return [
'id' => isset($entry['id']) ? (int) $entry['id'] : null,
'workspace_id' => isset($entry['workspace_id']) ? (int) $entry['workspace_id'] : null,
'fleet_node_id' => isset($entry['fleet_node_id']) ? (int) $entry['fleet_node_id'] : null,
'task_type' => (string) ($entry['task_type'] ?? ''),
'amount' => (int) ($entry['amount'] ?? 0),
'balance_after' => (int) ($entry['balance_after'] ?? 0),
'description' => isset($entry['description']) ? (string) $entry['description'] : null,
'created_at' => isset($entry['created_at']) ? (string) $entry['created_at'] : null,
];
}
$createdAt = $entry->created_at ?? null;
return [
'id' => isset($entry->id) ? (int) $entry->id : null,
'workspace_id' => isset($entry->workspace_id) ? (int) $entry->workspace_id : null,
'fleet_node_id' => isset($entry->fleet_node_id) ? (int) $entry->fleet_node_id : null,
'task_type' => (string) ($entry->task_type ?? ''),
'amount' => (int) ($entry->amount ?? 0),
'balance_after' => (int) ($entry->balance_after ?? 0),
'description' => isset($entry->description) ? (string) $entry->description : null,
'created_at' => is_object($createdAt) && method_exists($createdAt, 'toIso8601String')
? $createdAt->toIso8601String()
: ($createdAt !== null ? (string) $createdAt : null),
];
}
private function resolveCreditService(): ?object
{
if (! class_exists(CreditService::class)) {
return null;
}
$service = app(CreditService::class);
return is_object($service) ? $service : null;
}
}