whisper-money/app/Mcp/Tools/CreateBudget.php

128 lines
6.2 KiB
PHP

<?php
namespace App\Mcp\Tools;
use App\Enums\BudgetPeriodType;
use App\Enums\RolloverType;
use App\Mcp\Tools\Concerns\PresentsBudgets;
use App\Models\Category;
use App\Models\Label;
use App\Models\User;
use App\Services\BudgetService;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
#[Description('Create a budget: a spending limit per period over a set of categories and/or labels. Transactions already in range are attached in the background, so spent_amount may still be filling in.')]
class CreateBudget extends WriteTool
{
use PresentsBudgets;
/**
* @return array<string, mixed>
*/
public function schema(JsonSchema $schema): array
{
return [
'name' => $schema->string()->description('Budget name.')->required(),
'allocated_amount' => $schema->integer()->description('Limit for each period, in minor units (50000 = 500.00).')->required(),
'period_type' => $schema->string()->enum(array_column(BudgetPeriodType::cases(), 'value'))->description('How often the budget starts over.')->required(),
'rollover_type' => $schema->string()->enum(array_column(RolloverType::cases(), 'value'))->description('"carry_over" adds what is left to the next period, "reset" starts every period at the limit.')->required(),
'period_start_day' => $schema->integer()->min(0)->max(31)->description('Day the period starts: day of the month (1-31) when monthly, day of the week (0 = Sunday, 6 = Saturday) when weekly or biweekly. Ignored when yearly.'),
'category_ids' => $schema->array()->items($schema->string())->description('Categories to track. Tracking a parent also tracks its children. Call list_categories to see valid ids.'),
'label_ids' => $schema->array()->items($schema->string())->description('Labels to track. Call list_labels to see valid ids.'),
'is_catch_all' => $schema->boolean()->description('Track every expense category no other budget tracks. Needs no categories or labels, and only one catch-all budget is allowed.'),
];
}
protected function write(Request $request, User $user): Response
{
$periodType = $request->string('period_type')->toString();
$request->validate([
'name' => ['required', 'string', 'max:255'],
'allocated_amount' => ['required', 'integer', 'min:0'],
'period_type' => ['required', Rule::enum(BudgetPeriodType::class)],
'rollover_type' => ['required', Rule::enum(RolloverType::class)],
// The field means a day of the week for weekly periods and a day of
// the month for monthly ones, so its range depends on period_type.
'period_start_day' => ['sometimes', 'nullable', 'integer', 'min:'.($periodType === 'monthly' ? 1 : 0), 'max:'.(in_array($periodType, ['weekly', 'biweekly'], true) ? 6 : 31)],
'is_catch_all' => ['sometimes', 'boolean'],
]);
// Budgets track their owner's own categories and labels, so an id
// belonging to a co-member of a shared space is rejected even though
// list_categories, being space-scoped, does return it.
$categoryIds = $this->ownedIds($request, 'category_ids', Category::query()->where('user_id', $user->id), 'One or more category_ids are not categories you own. Only your own categories can be budgeted.');
$labelIds = $this->ownedIds($request, 'label_ids', Label::query()->where('user_id', $user->id), 'One or more label_ids are not labels you own. Only your own labels can be budgeted.');
$isCatchAll = $request->boolean('is_catch_all');
$this->assertBudgetTracksSomething($user, $isCatchAll, $categoryIds, $labelIds);
$budget = app(BudgetService::class)->create(
$user,
[
'name' => $request->string('name')->toString(),
'period_type' => $request->string('period_type')->toString(),
'period_start_day' => $request->filled('period_start_day') ? $request->integer('period_start_day') : null,
'rollover_type' => $request->string('rollover_type')->toString(),
'is_catch_all' => $isCatchAll,
],
$request->integer('allocated_amount'),
$categoryIds,
$labelIds,
);
return $this->json(['budget' => $this->presentBudget($budget)]);
}
/**
* A budget needs something to watch: either explicit categories/labels, or
* the single catch-all budget that absorbs whatever the others leave behind.
*
* The web form checks the same two rules (StoreBudgetRequest), with wording
* aimed at someone looking at the create dialog rather than at an agent
* deciding what to send next.
*
* @param array<int, string> $categoryIds
* @param array<int, string> $labelIds
*/
private function assertBudgetTracksSomething(User $user, bool $isCatchAll, array $categoryIds, array $labelIds): void
{
if (! $isCatchAll && $categoryIds === [] && $labelIds === []) {
throw ValidationException::withMessages([
'category_ids' => 'Pass at least one category or label to track, or set is_catch_all to true.',
]);
}
if ($isCatchAll && $user->budgets()->where('is_catch_all', true)->exists()) {
throw ValidationException::withMessages([
'is_catch_all' => 'This account already has a catch-all budget.',
]);
}
}
/**
* The ids passed under $key, asserted to all be reachable through $query so
* an unusable one is reported instead of silently dropped.
*
* @param Builder<covariant Model> $query
* @return list<string>
*/
private function ownedIds(Request $request, string $key, Builder $query, string $failure): array
{
$ids = $this->requestedIds($request, $key);
if ($query->whereIn('id', $ids)->count() !== count($ids)) {
throw ValidationException::withMessages([$key => $failure]);
}
return $ids;
}
}