diff --git a/.github/workflows/build-hosted-image.yml b/.github/workflows/build-hosted-image.yml deleted file mode 100644 index 138c8825..00000000 --- a/.github/workflows/build-hosted-image.yml +++ /dev/null @@ -1,137 +0,0 @@ -name: Build Hosted Image - -on: - workflow_call: - inputs: - plugins: - description: 'JSON array of plugins to bundle, e.g. [{"repo":"/","ref":"main"}]' - type: string - required: true - base_image: - description: 'Public base image to stack on' - type: string - default: 'ghcr.io/trakli/webservice:latest' - image_name: - description: 'Target hosted image' - type: string - default: 'ghcr.io/trakli/webservice-hosted' - tag: - description: 'Tag for the hosted image' - type: string - default: 'latest' - secrets: - PLUGINS_TOKEN: - description: 'Token with read access to the private plugin repositories' - required: true - workflow_dispatch: - inputs: - plugins: - description: 'JSON array of plugins to bundle' - type: string - required: true - base_image: - type: string - default: 'ghcr.io/trakli/webservice:latest' - image_name: - type: string - default: 'ghcr.io/trakli/webservice-hosted' - tag: - type: string - default: 'latest' - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout webservice - uses: actions/checkout@v4 - - - name: Set up PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - tools: composer:v2 - - - name: Clone private plugins - env: - PLUGINS_TOKEN: ${{ secrets.PLUGINS_TOKEN }} - run: | - set -euo pipefail - mkdir -p plugins - while read -r plugin; do - repo=$(echo "$plugin" | jq -r '.repo') - ref=$(echo "$plugin" | jq -r '.ref // "main"') - tmp=$(mktemp -d ./plugins/.clone.XXXXXX) - git clone --depth 1 --branch "$ref" \ - "https://x-access-token:${PLUGINS_TOKEN}@github.com/${repo}.git" "$tmp" - rm -rf "$tmp/.git" - if [ ! -f "$tmp/plugin.json" ]; then - echo "::error::${repo} has no plugin.json; it is not a plugin-engine plugin" >&2 - exit 1 - fi - id=$(jq -r '.id // empty' "$tmp/plugin.json") - if [ -z "$id" ]; then - echo "::error::${repo} plugin.json is missing an 'id'" >&2 - exit 1 - fi - rm -rf "plugins/${id}" - mv "$tmp" "plugins/${id}" - done < <(echo '${{ inputs.plugins }}' | jq -c '.[]') - - - name: Install core dependencies - run: | - composer install --no-dev --no-interaction --no-scripts --ignore-platform-reqs --prefer-dist --optimize-autoloader - - - name: Verify bundled plugins carry no unbundled dependencies - run: | - set -euo pipefail - for d in plugins/*/; do - cj="${d}composer.json" - [ -f "$cj" ] || continue - extra=$(jq -r '.require // {} | keys[]' "$cj" | grep -ivE '^(php|laravel/framework)$' || true) - if [ -n "$extra" ]; then - echo "::error::Plugin '${d}' declares dependencies the bundler does not install:" >&2 - echo "$extra" >&2 - echo "::error::Extend this workflow to install plugin dependencies before bundling it." >&2 - exit 1 - fi - done - - - name: Prepare application environment - run: | - cp .env.example .env - php artisan key:generate - - - name: Enable and cache bundled plugins - run: | - set -euo pipefail - for d in plugins/*/; do - [ -f "${d}plugin.json" ] || continue - php artisan plugin:enable "$(jq -r '.id' "${d}plugin.json")" - done - php artisan plugin:cache - - - name: Keep prepared vendor and plugin cache in the build context - run: | - sed -i '/^vendor$/d; /^bootstrap\/cache/d' .dockerignore - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push hosted image - uses: docker/build-push-action@v6 - with: - context: . - file: docker/hosted/Dockerfile - push: true - build-args: | - BASE_IMAGE=${{ inputs.base_image }} - tags: ${{ inputs.image_name }}:${{ inputs.tag }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 45ff8910..7d67a9b5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -41,5 +41,8 @@ jobs: - name: Run database migrations run: flatrun deployment exec trakli-staging app -- php artisan migrate + - name: Verify schema conformance + run: flatrun deployment exec trakli-staging app -- php artisan schema:verify + - name: Optimize application caches run: flatrun deployment exec trakli-staging app -- php artisan optimize diff --git a/AGENTS.md b/AGENTS.md index ebccd09d..76a86cd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,53 +33,98 @@ without tools leaves a feature the AI is blind to. When you add an Eloquent model / table that holds user data (or a meaningful new field on one), in the same change also do the following. -### 1. Read tool +### 1. Declare the resource on the model -Add a read tool under `app/Ai/Tools/Read/` so the assistant can see the data, -scoped to the authenticated user (`$context->user`). Mirror -`app/Ai/Tools/Read/ListWalletsTool.php` (or `ListHoldingsTool.php`): extend -`Whilesmart\Agents\Tools\AbstractTool`, `permission()` returns -`ToolPermission::READ`, return a plain array. +Implement `Whilesmart\Agents\Contracts\HasAgentResource` and return an +`AgentResource` describing the model once: its name and aliases, the column that +reads as a row's label, how rows are tied to an owner, the fields an agent may +read, and the fields it may write. `app/Models/Transfer.php` and +`app/Models/Budget.php` are the worked examples. -### 2. Write tool (when users create/change it conversationally) +Mark internal plumbing (`user_id`, `owner_type`) with `ResourceField::internal()` +so it never reaches an answer, and give foreign keys a `references` so a raw id +can be resolved to a name. -If users would naturally say "add a ...", add a write tool under -`app/Ai/Tools/Write/` extending `AbstractWriteTool`. Write tools only *propose* -an action the user confirms; execution goes through `ProposedActionExecutor` -(add the new `*.create` action type there). Mirror -`app/Ai/Tools/Write/RecordTransactionTool.php`. +Ownership is what keeps one user out of another's data, and every form fails +closed: -### 3. Register the tool +| Declaration | Use for | +|---|---| +| `ownerKey: 'user_id'` | The ordinary case | +| `ownerConstants: ['owner_type' => User::class]` | Polymorphic owners, where the id alone is ambiguous (budgets, holdings) | +| `scopeThrough: new ThroughScope(...)` | No owner column; a parent record owns it (refunds, recurring rules) | +| `global: true` | Reference data belonging to nobody (exchange rates) | -Add the class to the `tools` array in `config/agents.php`. A tool that isn't -listed there is never offered to the assistant. +Add the model to `resources.models` in `config/agents.php`. A model that is not +listed there is invisible to the assistant no matter what it declares. -### 4. smartql.yml (if the table should be queryable) +That alone gives it a `list_` read tool, scoped to the acting user and +returning only the non-internal fields. Do **not** hand-write a read tool as +well. A model that already has one (transactions, wallets, categories, parties) +declares `readTool: false`: it is listed for the schema and for scoping its +children, not for a duplicate tool. -If the assistant should be able to query the table ad hoc (totals, filters, -joins), add it to `smartql.yml` under `semantic_layer.entities`: the real table -name, a description, `aliases` the user might say, and the columns with types and -descriptions. Add new *columns* on existing tables too (e.g. a new enum field), -and any `relationships`. The SmartQL tool can only reach declared tables/columns. +### 2. Write tool (when users create it conversationally) -### 5. Analytics (when relevant) +If users would naturally say "add a ...", set `writeEnabled: true` and list the +fields under `writable` with their validation rules. `CreateResourceTool` builds +the tool from that declaration, so most models need no write-tool class at all +(groups and reminders work this way). + +Write a bespoke tool extending `AbstractWriteTool` only when creating the record +means more than setting columns: linking two records, or syncing a pivot. See +`CreateBudgetTool` (targets), `RecordRefundTool` and `CreateRecurringRuleTool`, +and register those in the `tools` array of `config/agents.php`. + +Either way, a write tool only *proposes* an action the user confirms. Execution +goes through `ProposedActionExecutor`, so add the new `*.create` action type +there, and add its editable fields to `AiController::allowedOverrideKeys()`. Keep +the field that proves ownership out of that list: an overridable +`transaction_id` would let a confirmed action point at someone else's record. + +### 3. smartql.yml + +Regenerate the semantic layer rather than editing it by hand: + +``` +php artisan agents:export-schema --output=storage/app/exported.yml +``` + +The entities, relationships, `allowed_tables` and `required_filters` come from +the resource declarations. Merge the result into `smartql.yml`, which also holds +the parts with no model behind them: connection and LLM settings, business +rules, prompt examples, and the `holdings` and `categorizables` entities. + +`SmartqlSchemaTest` fails if the file drifts from the models, if a readable table +has no tenant filter, or if a polymorphic owner is missing its type pin. + +### 4. Analytics (when relevant) If the model feeds a headline number, expose it through a `GetStatsTool` section (`app/Ai/Tools/Read/GetStatsTool.php` + `StatsService`) rather than expecting the assistant to compute it. +### 5. Tell the assistant it exists + +A tool the system prompt never mentions goes unused. Add a short section to +`TrakliHarness::systemPrompt()` saying what the model is for and which tool +reaches it, in the style of the existing Transfers and Budgets sections. + ## Reference: holdings -`whilesmart/eloquent-holdings` is the worked example: `ListHoldingsTool` -(read), the `holdings` entity in `smartql.yml`, the `position` section in +`whilesmart/eloquent-holdings` is the one model that cannot declare a resource, +because it lives in a package: `ListHoldingsTool` (read), a hand-written +`holdings` entity in `smartql.yml`, and the `position` section in `GetStatsTool`/`StatsService` for net worth. A record/write tool for holdings is -the outstanding piece and should follow the same pattern. +the outstanding piece. ## Checklist for a new model -- [ ] Read tool in `app/Ai/Tools/Read/`, user-scoped -- [ ] Write tool in `app/Ai/Tools/Write/` (+ `ProposedActionExecutor` action) if user-created -- [ ] Registered in `config/agents.php` -- [ ] `smartql.yml` entity / new columns / relationships +- [ ] `agentResource()` on the model, with ownership and internal fields declared +- [ ] Listed in `resources.models` in `config/agents.php` +- [ ] `writeEnabled` + `writable` fields if user-created, or a bespoke write tool + (+ `ProposedActionExecutor` action and `allowedOverrideKeys` entry) +- [ ] `smartql.yml` regenerated and merged - [ ] Stats section if it drives a headline figure +- [ ] A section in the harness system prompt - [ ] Tests covering the tool through the user boundary diff --git a/CHANGELOG.md b/CHANGELOG.md index 2907f372..8087a55f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0-beta.2] - 2026-08-12 + +Codename: Ailanthus, second beta. + +### Added + +- Transactions and period statements download as CSV, XLSX or PDF; a transaction + download carries the same filters as the listing, and a statement carries the + same figures as the analytics screen +- The assistant reaches budgets, refunds, reminders, groups and transfers: it can + say how a budget is doing and record money coming back as a refund rather than + fresh income +- The assistant proposes a change across many transactions at once, confirmed as + one sweep instead of a card per row +- Reminder columns that existed only in the conformance spec now have a migration + behind them + +### Changed + +- The assistant answers in the user's own currency, converting at the same rates + the rest of the app uses, and asks when the currency cannot be known +- Confirmation cards name the record being changed rather than its internal id +- A transaction takes a single category; one saved earlier keeps its first + category and drops the rest when saved again +- Feature gating moved to the shared entitlements package; behaviour is unchanged, + with every feature allowed and nothing metered +- An export larger than a format can render is refused up front, naming the limit + that applied and what the other formats allow + +### Fixed + +- A database that falls behind its migrations answers with a clear error naming + what is missing, instead of accepting writes it cannot complete +- Every table the assistant can read is scoped to the person asking, and transfer + rows no longer inflate the income and spending it reports +- Boolean flags sent as text by form and multipart requests are accepted, so + recurring transactions can be created from the mobile app +- Transfers keep their decimal precision, so cents no longer vanish and small + conversion rates no longer produce zero amounts +- A second device can no longer overwrite the client id created by the first +- Imported rows without a usable amount fail with a reason instead of importing as + zero, and transfer rows import as transfers again +- Deleted records report as changed, so clients apply the deletion +- A holding created with automatic pricing is priced at creation instead of + reporting zero until the next repricing run + ## [2.0.0-beta.1] - 2026-07-11 Codename: Ailanthus, first of the tree-name series. diff --git a/README.md b/README.md index 054b7878..bf32edae 100644 --- a/README.md +++ b/README.md @@ -7,20 +7,45 @@ [![Tests](https://github.com/trakli/webservice/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/trakli/webservice/actions/workflows/tests.yml) [![Lint & Sniffs](https://github.com/trakli/webservice/actions/workflows/lint-sniffs.yml/badge.svg?branch=main)](https://github.com/trakli/webservice/actions/workflows/lint-sniffs.yml) -## Overview +Trakli gets your money under control by showing you where it actually goes. Every wallet, balance, and +holding you have, in as many accounts and currencies as you keep them, adds up to your real net worth, +not just what you spent this month. And you barely type any of it: say what happened, or hand over a +receipt, and the assistant does the logging. -Trakli is a personal income tracking application built using Laravel. The application allows users to manage and categorize their income and expenses under various groups and categories. +This is the Laravel API behind it, and what the web and mobile apps talk to. ## Features +AI native: + +- **An assistant that acts:** say what happened ("log $40 dinner at Mama's, split under food") and it + records, categorizes, transfers, and cleans up your books in bulk, proposing every change for you to + approve before anything is written. +- **Reads your paperwork:** hand it receipts, statements, CSVs, or PDFs and it turns them into + transactions. +- **Reports on a canvas:** ask for a report, watch it get built, then take it with you. +- **MCP built in:** Trakli runs an MCP server, so Claude or any MCP client can read your books and record + transactions through tokens you mint and revoke yourself. + +For pro users: + +- **Wallets that match reality:** cash, mobile money, bank and card, side by side. +- **Any currency, at your rate:** forty-eight of them, held at once; transfers convert at the rate you + set, not one scraped from a market you cannot get. +- **Offline-first:** the phone works with no signal, and changes settle cleanly when it reconnects. +- **Yours to run:** open source end to end; audit it, fork it, self-host it, with no one else holding the + ledger of what you earn. + +A full finance app, not a spreadsheet: + - **Transactions:** Income and expenses across multiple wallets, with attachments and recurring rules. - **Transfers:** Move money between wallets, including cross-currency at user-set rates. +- **Holdings:** Crypto, stocks, property, any asset; crypto priced live, everything else at the price you set. - **Budgets:** Scoped to categories, groups, or wallets; weekly / monthly / yearly / custom range; optional rollover; threshold and forecast alerts. - **Refunds:** Mark an income as refunding an earlier expense; matching budgets adjust automatically. - **Reminders:** Bills, budget alerts, and custom events with pause, resume, and snooze. - **Imports:** Pull transactions from CSVs, PDFs, and photos of receipts. -- **Insights & AI:** Dashboard stats, digest emails, and a chat assistant for your finances. -- **Offline-first:** Changes made on mobile sync cleanly when the device reconnects. +- **Insights:** Dashboard stats and digest emails. ## Setup instructions diff --git a/app/Ai/BlockBuilder.php b/app/Ai/BlockBuilder.php index efa49e2c..1098b708 100644 --- a/app/Ai/BlockBuilder.php +++ b/app/Ai/BlockBuilder.php @@ -108,6 +108,19 @@ public function proposedAction(array $proposal): array return array_merge(['type' => 'proposed_action'], $proposal); } + /** + * Several actions proposed together and confirmed as one unit, so a request + * touching many records costs the user one decision instead of one per row. + * Members keep their own confirm/reject urls for the odd one out. + * + * @param array $batch + * @return array + */ + public function proposedActionBatch(array $batch): array + { + return array_merge(['type' => 'proposed_action_batch'], $batch); + } + /** * A canvas artifact: a titled document whose body is an ordered list of * blocks (markdown sections interleaved with charts/kpis/tables) composed by diff --git a/app/Ai/Harnesses/TrakliHarness.php b/app/Ai/Harnesses/TrakliHarness.php index 526c13ba..824a49f8 100644 --- a/app/Ai/Harnesses/TrakliHarness.php +++ b/app/Ai/Harnesses/TrakliHarness.php @@ -3,8 +3,12 @@ namespace App\Ai\Harnesses; use App\Ai\UiToolCatalog; +use App\Models\User; +use App\Support\ConfigurationKeys; use Whilesmart\Agents\Enums\ToolPermission; use Whilesmart\Agents\Harness\AbstractHarness; +use Whilesmart\Agents\Registries\ModelResourceRegistry; +use Whilesmart\Agents\ValueObjects\ToolContext; /** * The single Trakli agent: one brain behind every AI surface. It reads the @@ -18,10 +22,11 @@ public function name(): string return 'trakli'; } - public function systemPrompt(): string + public function systemPrompt(?ToolContext $context = null): string { $rendering = app(UiToolCatalog::class)->systemPromptSection(); $today = now()->format('l, j F Y'); + $about = $this->aboutTheUser($context); return <<user; + + if (! $user instanceof User) { + return <<<'PROMPT' +Money: +- You do not know this user's currency. Never assume dollars: call + `get_user_defaults` before stating any amount, and label figures with the + currency it reports. +PROMPT; + } + + $currency = $user->getConfigValue(ConfigurationKeys::DEFAULT_CURRENCY); + $wallets = $user->wallets()->pluck('currency')->filter()->unique()->values(); + + // A user with no configured currency still has wallets, and a single + // wallet currency is a better answer than the model's own default. + $currency = $currency ?: ($wallets->count() === 1 ? $wallets->first() : null); + + if ($currency === null) { + $known = $wallets->implode(', '); + + return <<reject(fn (string $code): bool => $code === $currency); + $mixed = $others->isEmpty() + ? '' + : "\n- Some wallets are held in other currencies ({$others->implode(', ')}). An amount" + . "\n read from one of those is in THAT currency, not {$currency}. Convert it with" + . "\n `convert_currency` before comparing or totalling, and never mix currencies" + . "\n in one total."; + + return <<toolNames(); + + return array_values(array_unique(array_merge($generated, [ 'clock', 'calculator', 'smartql.query', @@ -120,6 +227,8 @@ public function toolNames(): array 'list_wallets', 'list_categories', 'list_parties', + 'get_user_defaults', + 'convert_currency', 'get_exchange_rate', 'get_asset_price', 'render_kpi', @@ -137,11 +246,18 @@ public function toolNames(): array 'create_wallet', 'create_category', 'create_party', - 'categorize_transaction', + 'categorize_transactions', + 'assign_transaction_categories', 'attach_to_transaction', 'import_document', 'extract_receipt', - ]; + 'list_holdings', + 'create_budget', + 'create_recurring_rule', + 'record_refund', + 'create_group', + 'create_reminder', + ]))); } public function allowedPermissions(): array diff --git a/app/Ai/Tools/Read/ConvertCurrencyTool.php b/app/Ai/Tools/Read/ConvertCurrencyTool.php new file mode 100644 index 00000000..4b10b80d --- /dev/null +++ b/app/Ai/Tools/Read/ConvertCurrencyTool.php @@ -0,0 +1,88 @@ +user; + + if (! $user instanceof User) { + return ['error' => 'No authenticated user in context.']; + } + + $source = strtoupper(trim((string) ($arguments['from'] ?? ''))); + $target = strtoupper(trim((string) ($arguments['to'] ?? ''))); + + if ($source === '' || $target === '') { + return ['error' => 'Both a source and a target currency are required.']; + } + + if (! is_numeric($arguments['amount'] ?? null)) { + return ['error' => 'A numeric amount is required.']; + } + + $amount = (float) $arguments['amount']; + $converted = $this->exchangeRates->convert($amount, $source, $target, $user); + + if ($converted === null) { + return ['error' => "No exchange rate is available from {$source} to {$target}. " + . 'Tell the user rather than estimating one; they can set a manual rate in settings.']; + } + + $rate = $this->exchangeRates->getRate($source, $target, $user); + + return [ + 'amount' => $amount, + 'from' => $source, + 'to' => $target, + 'rate' => $rate, + 'converted' => round((float) $converted, 2), + ]; + } +} diff --git a/app/Ai/Tools/Read/GetExchangeRateTool.php b/app/Ai/Tools/Read/GetExchangeRateTool.php index 7e4774b9..682ee16f 100644 --- a/app/Ai/Tools/Read/GetExchangeRateTool.php +++ b/app/Ai/Tools/Read/GetExchangeRateTool.php @@ -36,8 +36,8 @@ public function description(): string public function parameters(): array { return [ - ParameterSpec::string('base', 'Base currency code, e.g. "USD".'), - ParameterSpec::arrayOf('targets', 'Target currency codes to convert into, e.g. ["EUR", "GBP"].', ParameterType::STRING), + ParameterSpec::string('base', 'Base currency code (ISO 4217, 3 letters).'), + ParameterSpec::arrayOf('targets', 'Target currency codes to convert into (ISO 4217, 3 letters).', ParameterType::STRING), ]; } diff --git a/app/Ai/Tools/Read/GetUserDefaultsTool.php b/app/Ai/Tools/Read/GetUserDefaultsTool.php new file mode 100644 index 00000000..21712b24 --- /dev/null +++ b/app/Ai/Tools/Read/GetUserDefaultsTool.php @@ -0,0 +1,105 @@ +user; + if ($user === null) { + return ['error' => 'No authenticated user in context.']; + } + + $wallets = $user->wallets()->get(['id', 'name', 'type', 'currency']); + $currency = $user->getConfigValue(ConfigurationKeys::DEFAULT_CURRENCY); + $walletCurrencies = $wallets->pluck('currency')->filter()->unique()->values(); + + // An unset currency is not a reason to fall back on dollars: one wallet + // currency answers it outright, and several means the answer is "ask". + $inferred = null; + if (! $currency && $walletCurrencies->count() === 1) { + $inferred = $walletCurrencies->first(); + } + + $defaultWallet = $this->resolveDefaultWallet($user, $wallets); + + return array_filter([ + 'currency' => $currency ?: $inferred, + 'currency_is_set' => (bool) $currency, + 'currency_source' => $currency ? 'user setting' : ($inferred ? 'their only wallet' : null), + 'currency_note' => $currency || $inferred + ? null + : 'The user has not set a currency and holds wallets in ' . $walletCurrencies->implode(', ') + . '. Ask which they want rather than assuming.', + 'wallet_currencies' => $walletCurrencies->all(), + 'default_wallet' => $defaultWallet ? [ + 'id' => $defaultWallet->id, + 'name' => $defaultWallet->name, + 'currency' => $defaultWallet->currency, + ] : null, + 'default_group' => $user->getConfigValue(ConfigurationKeys::DEFAULT_GROUP), + 'language' => $user->getConfigValue(ConfigurationKeys::DEFAULT_LANG), + 'timezone' => $user->getConfigValue(ConfigurationKeys::TIMEZONE), + ], fn ($value): bool => $value !== null); + } + + /** + * The stored value is either a primary key or the client-generated id the + * device minted, because the clients disagree: the web app writes the id + * while mobile and registration write the client id. Both are matched here + * rather than assuming one, so a default wallet resolves whichever wrote it. + * + * @param Collection $wallets + */ + private function resolveDefaultWallet($user, $wallets): ?Wallet + { + $configured = $user->getConfigValue(ConfigurationKeys::DEFAULT_WALLET); + if (! $configured) { + return null; + } + + $configured = (string) $configured; + + $byId = $wallets->first(fn (Wallet $wallet): bool => (string) $wallet->id === $configured); + if ($byId !== null) { + return $byId; + } + + return $user->wallets() + ->with('syncState.device') + ->get() + ->first(fn (Wallet $wallet): bool => $wallet->client_generated_id === $configured); + } +} diff --git a/app/Ai/Tools/Write/AbstractWriteTool.php b/app/Ai/Tools/Write/AbstractWriteTool.php index 042d31c2..805828b4 100644 --- a/app/Ai/Tools/Write/AbstractWriteTool.php +++ b/app/Ai/Tools/Write/AbstractWriteTool.php @@ -41,6 +41,19 @@ abstract public function actionType(): string; */ abstract protected function buildPayload(array $arguments, ToolContext $context): array; + /** + * Every payload one call proposes. A tool acting on many records at once + * overrides this to return one payload per record; they are then proposed as + * a single batch the user confirms in one go, rather than a card each. + * + * @param array $arguments + * @return array> + */ + protected function buildPayloads(array $arguments, ToolContext $context): array + { + return [$this->buildPayload($arguments, $context)]; + } + /** * A one-line, human-facing description of what will happen on confirm. * @@ -67,16 +80,16 @@ public function handle(array $arguments, ToolContext $context): string|array } try { - $payload = $this->buildPayload($arguments, $context); + $payloads = $this->buildPayloads($arguments, $context); } catch (InvalidArgumentException $e) { return ['error' => $e->getMessage()]; } - $proposal = AgentProposedAction::create([ - 'owner_type' => $user->getMorphClass(), - 'owner_id' => $user->getAuthIdentifier(), - 'source_type' => (new ChatSession())->getMorphClass(), - 'source_id' => $sessionId, + if ($payloads === []) { + return ['error' => 'Nothing to propose.']; + } + + $attributes = array_map(fn (array $payload): array => [ 'action_type' => $this->actionType(), 'payload' => $payload, 'summary' => $this->summarize($payload, $context), @@ -88,23 +101,83 @@ public function handle(array $arguments, ToolContext $context): string|array 'chat_message_id' => $context->get('chat_message_id'), 'tool_name' => $this->name(), ], - ]); + ], $payloads); + + $shared = [ + 'source_type' => (new ChatSession())->getMorphClass(), + 'source_id' => $sessionId, + ]; + + // One record stays a plain proposal: a batch of one would give the user + // a "confirm all" over a single row for no reason. + if (count($attributes) === 1) { + $proposal = $user->agentActions()->create($attributes[0] + $shared); + + app(BlockCollector::class)->add( + app(BlockBuilder::class)->proposedAction( + $this->blockFor($proposal, $payloads[0], $context, $sessionId) + ) + ); + + return "Proposed {$proposal->action_type}, awaiting the user's confirmation: {$proposal->summary}"; + } + + $proposals = $user->proposeActionBatch($attributes, $shared); + $batch = $proposals->first()->batch; app(BlockCollector::class)->add( - app(BlockBuilder::class)->proposedAction([ - 'id' => $proposal->id, - 'action_type' => $proposal->action_type, - 'summary' => $proposal->summary, - 'risk' => $proposal->risk->value, - 'status' => $proposal->status->value, - 'payload' => $proposal->payload, - 'fields' => $this->reviewFields($payload, $context), - 'confirm_url' => "/api/v1/ai/chats/{$sessionId}/actions/{$proposal->id}/confirm", - 'reject_url' => "/api/v1/ai/chats/{$sessionId}/actions/{$proposal->id}/reject", + app(BlockBuilder::class)->proposedActionBatch([ + 'batch' => $batch, + 'action_type' => $this->actionType(), + 'summary' => $this->summarizeBatch($payloads, $context), + 'risk' => $this->risk()->value, + 'status' => ActionStatus::Proposed->value, + 'actions' => $proposals + ->map(fn ($proposal, $index) => $this->blockFor($proposal, $payloads[$index], $context, $sessionId)) + ->all(), + 'confirm_url' => "/api/v1/ai/chats/{$sessionId}/actions/batches/{$batch}/confirm", + 'reject_url' => "/api/v1/ai/chats/{$sessionId}/actions/batches/{$batch}/reject", ]) ); - return "Proposed {$proposal->action_type}, awaiting the user's confirmation: {$proposal->summary}"; + $count = $proposals->count(); + + return "Proposed {$count} {$this->actionType()} actions as one batch, awaiting the user's confirmation. " + . 'Tell them what you have proposed and that they can confirm or dismiss them together.'; + } + + /** + * The client-facing shape of one proposal. + * + * @param array $payload + * @return array + */ + protected function blockFor(AgentProposedAction $proposal, array $payload, ToolContext $context, int|string $sessionId): array + { + return [ + 'id' => $proposal->id, + 'action_type' => $proposal->action_type, + 'summary' => $proposal->summary, + 'risk' => $proposal->risk->value, + 'status' => $proposal->status->value, + 'payload' => $proposal->payload, + 'fields' => $this->reviewFields($payload, $context), + 'confirm_url' => "/api/v1/ai/chats/{$sessionId}/actions/{$proposal->id}/confirm", + 'reject_url' => "/api/v1/ai/chats/{$sessionId}/actions/{$proposal->id}/reject", + ]; + } + + /** + * One line describing what a whole batch will do. Override for wording that + * reads better than a count. + * + * @param array> $payloads + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + protected function summarizeBatch(array $payloads, ToolContext $context): string + { + return count($payloads) . ' changes'; } /** diff --git a/app/Ai/Tools/Write/AssignTransactionCategoriesTool.php b/app/Ai/Tools/Write/AssignTransactionCategoriesTool.php new file mode 100644 index 00000000..615d628f --- /dev/null +++ b/app/Ai/Tools/Write/AssignTransactionCategoriesTool.php @@ -0,0 +1,131 @@ +buildPayloads($arguments, $context)[0]; + } + + protected function buildPayloads(array $arguments, ToolContext $context): array + { + $user = $context->user; + + $assignments = collect($arguments['assignments'] ?? []) + ->filter(fn ($entry): bool => is_array($entry)) + ->map(fn (array $entry): array => [ + 'transaction_id' => (int) ($entry['transaction_id'] ?? 0), + 'category_name' => trim((string) ($entry['category_name'] ?? '')), + ]) + ->filter(fn (array $entry): bool => $entry['transaction_id'] > 0 && $entry['category_name'] !== '') + // The last word wins if the model names the same transaction twice, + // rather than proposing two conflicting changes to one row. + ->keyBy('transaction_id') + ->values(); + + if ($assignments->isEmpty()) { + throw new InvalidArgumentException('At least one transaction id paired with a category name is required.'); + } + + $owned = $user->transactions()->whereKey($assignments->pluck('transaction_id'))->pluck('id'); + $missing = $assignments->pluck('transaction_id')->diff($owned); + + if ($missing->isNotEmpty()) { + throw new InvalidArgumentException( + 'These transactions were not found among your records: ' . $missing->implode(', ') . '.' + ); + } + + $names = $assignments->pluck('category_name')->unique()->values()->all(); + $byName = $this->categoryIdsByName($user, $names); + + // Reported together rather than one at a time, so the agent can fix a + // whole sweep in one turn instead of failing on each name in sequence. + $unknown = collect($names)->reject(fn (string $name): bool => isset($byName[mb_strtolower($name)])); + if ($unknown->isNotEmpty()) { + throw new InvalidArgumentException( + 'These categories do not exist yet: ' . $unknown->implode(', ') + . '. Propose creating them first, or use ones that exist.' + ); + } + + return $assignments + ->map(fn (array $entry): array => [ + 'transaction_id' => $entry['transaction_id'], + 'categories' => [$byName[mb_strtolower($entry['category_name'])]], + ]) + ->all(); + } + + protected function summarizeBatch(array $payloads, ToolContext $context): string + { + $count = count($payloads); + $categories = collect($payloads) + ->map(fn (array $payload): ?string => $this->categoryName($context, $payload['categories'][0] ?? null)) + ->filter() + ->unique(); + + $noun = $count === 1 ? 'transaction' : 'transactions'; + $across = $categories->count() === 1 + ? "as {$categories->first()}" + : "across {$categories->count()} categories"; + + return "Categorize {$count} {$noun} {$across}"; + } + + /** + * @param array $names + * @return array + */ + private function categoryIdsByName($user, array $names): array + { + return $user->categories() + ->get(['id', 'name']) + ->mapWithKeys(fn ($category): array => [mb_strtolower($category->name) => $category->id]) + ->only(array_map('mb_strtolower', $names)) + ->all(); + } +} diff --git a/app/Ai/Tools/Write/CategorizeTransactionTool.php b/app/Ai/Tools/Write/CategorizeTransactionTool.php deleted file mode 100644 index c702bed4..00000000 --- a/app/Ai/Tools/Write/CategorizeTransactionTool.php +++ /dev/null @@ -1,67 +0,0 @@ -user; - - $transactionId = (int) ($arguments['transaction_id'] ?? 0); - if ($transactionId <= 0 || ! $user->transactions()->whereKey($transactionId)->exists()) { - throw new InvalidArgumentException('That transaction was not found among your records.'); - } - - $categoryIds = $this->resolveCategoryIds($user, (array) ($arguments['category_names'] ?? [])); - if ($categoryIds === []) { - throw new InvalidArgumentException('At least one existing category is required.'); - } - - return [ - 'transaction_id' => $transactionId, - 'categories' => $categoryIds, - ]; - } - - protected function summarize(array $payload, ToolContext $context): string - { - $count = count($payload['categories']); - - return "Tag transaction #{$payload['transaction_id']} with {$count} categor" . ($count === 1 ? 'y' : 'ies') . '.'; - } -} diff --git a/app/Ai/Tools/Write/CategorizeTransactionsTool.php b/app/Ai/Tools/Write/CategorizeTransactionsTool.php new file mode 100644 index 00000000..28c3526b --- /dev/null +++ b/app/Ai/Tools/Write/CategorizeTransactionsTool.php @@ -0,0 +1,97 @@ +buildPayloads($arguments, $context)[0]; + } + + protected function buildPayloads(array $arguments, ToolContext $context): array + { + $user = $context->user; + + $ids = collect($arguments['transaction_ids'] ?? []) + ->map(fn ($id): int => (int) $id) + ->filter(fn (int $id): bool => $id > 0) + ->unique() + ->values(); + + if ($ids->isEmpty()) { + throw new InvalidArgumentException('At least one transaction id is required.'); + } + + $owned = $user->transactions()->whereKey($ids)->pluck('id'); + $missing = $ids->diff($owned); + + if ($missing->isNotEmpty()) { + throw new InvalidArgumentException( + 'These transactions were not found among your records: ' . $missing->implode(', ') . '.' + ); + } + + $categoryIds = $this->resolveCategoryIds($user, [(string) ($arguments['category_name'] ?? '')]); + if ($categoryIds === []) { + throw new InvalidArgumentException('That category does not exist yet. Propose creating it first.'); + } + + return $owned + ->map(fn (int $id): array => [ + 'transaction_id' => $id, + 'categories' => [$categoryIds[0]], + ]) + ->all(); + } + + protected function summarizeBatch(array $payloads, ToolContext $context): string + { + $count = count($payloads); + $name = $this->categoryName($context, $payloads[0]['categories'][0] ?? null) ?? 'a category'; + $noun = $count === 1 ? 'transaction' : 'transactions'; + + return "Categorize {$count} {$noun} as {$name}"; + } +} diff --git a/app/Ai/Tools/Write/CreateBudgetTool.php b/app/Ai/Tools/Write/CreateBudgetTool.php new file mode 100644 index 00000000..39ddfb18 --- /dev/null +++ b/app/Ai/Tools/Write/CreateBudgetTool.php @@ -0,0 +1,251 @@ +user; + + $name = trim((string) ($arguments['name'] ?? '')); + if ($name === '') { + throw new InvalidArgumentException('A budget name is required.'); + } + + $periodType = $this->resolvePeriodType($arguments); + [$startDate, $endDate] = $this->resolveDates($arguments, $periodType); + + return array_filter([ + 'name' => $name, + 'amount' => $this->resolveAmount($arguments), + 'currency' => $this->resolveCurrency($arguments, $user), + 'period_type' => $periodType, + 'start_date' => $startDate, + 'end_date' => $endDate, + 'description' => $arguments['description'] ?? null, + 'threshold_percent' => isset($arguments['threshold_percent']) ? (int) $arguments['threshold_percent'] : null, + 'rollover_enabled' => $arguments['rollover_enabled'] ?? null, + 'targets' => $this->resolveTargets($arguments, $user), + ], fn ($value) => $value !== null); + } + + /** + * @param array $arguments + */ + private function resolveAmount(array $arguments): float + { + $amount = (float) ($arguments['amount'] ?? 0); + + if ($amount <= 0) { + throw new InvalidArgumentException('The budget amount must be greater than zero.'); + } + + return $amount; + } + + /** + * @param array $arguments + */ + private function resolvePeriodType(array $arguments): string + { + $periodType = $arguments['period_type'] ?? null; + + if (! in_array($periodType, Budget::PERIODS, true)) { + throw new InvalidArgumentException('The period must be one of ' . implode(', ', Budget::PERIODS) . '.'); + } + + return $periodType; + } + + /** + * @param array $arguments + */ + private function resolveCurrency(array $arguments, $user): string + { + $currency = strtoupper(trim((string) ($arguments['currency'] ?? ''))); + + if ($currency === '') { + $currency = strtoupper((string) $this->defaultCurrency($user)); + } + + if (strlen($currency) !== 3) { + throw new InvalidArgumentException( + 'Currency must be a 3-letter ISO 4217 code. Ask the user which currency the budget is in.' + ); + } + + return $currency; + } + + /** + * @param array $arguments + * @return array{0: string, 1: string|null} + */ + private function resolveDates(array $arguments, string $periodType): array + { + $startDate = $this->parseDate($arguments['start_date'] ?? null) ?? Carbon::today()->toDateString(); + $endDate = $this->parseDate($arguments['end_date'] ?? null); + + if ($periodType === Budget::PERIOD_CUSTOM && $endDate === null) { + throw new InvalidArgumentException('A custom period needs an end date.'); + } + + if ($endDate !== null && $endDate < $startDate) { + throw new InvalidArgumentException('The end date cannot be before the start date.'); + } + + return [$startDate, $endDate]; + } + + /** + * @param array $arguments + * @return array|null + */ + private function resolveTargets(array $arguments, $user): ?array + { + $targets = $this->targets->resolve($user, $arguments['targets'] ?? []); + + return $targets === [] ? null : $targets; + } + + private function defaultCurrency($user): ?string + { + $currency = $user->getConfigValue(ConfigurationKeys::DEFAULT_CURRENCY); + + if ($currency) { + return $currency; + } + + $currencies = $user->wallets()->pluck('currency')->filter()->unique(); + + return $currencies->count() === 1 ? $currencies->first() : null; + } + + private function parseDate(mixed $value): ?string + { + if ($value === null || trim((string) $value) === '') { + return null; + } + + try { + return Carbon::parse((string) $value)->toDateString(); + } catch (\Throwable) { + throw new InvalidArgumentException("\"{$value}\" is not a date I can read. Use a plain date like 2026-03-01."); + } + } + + protected function summarize(array $payload, ToolContext $context): string + { + $summary = "Budget {$payload['amount']} {$payload['currency']} {$payload['period_type']} for \"{$payload['name']}\""; + + $targets = $payload['targets'] ?? []; + if ($targets !== []) { + $summary .= ', covering ' . count($targets) . ' ' . (count($targets) === 1 ? 'target' : 'targets'); + } + + return $summary . '.'; + } + + protected function reviewFields(array $payload, ToolContext $context): array + { + $fields = [ + ['key' => 'name', 'label' => 'Name', 'type' => 'text', 'value' => $payload['name'] ?? '', 'display' => (string) ($payload['name'] ?? '')], + ['key' => 'amount', 'label' => 'Limit', 'type' => 'number', 'value' => $payload['amount'] ?? 0, 'display' => (string) ($payload['amount'] ?? '')], + [ + 'key' => 'currency', 'label' => 'Currency', 'type' => 'text', + 'value' => $payload['currency'] ?? '', 'display' => (string) ($payload['currency'] ?? ''), + ], + [ + 'key' => 'period_type', 'label' => 'Resets', 'type' => 'text', + 'value' => $payload['period_type'] ?? '', 'display' => (string) ($payload['period_type'] ?? ''), + ], + [ + 'key' => 'start_date', 'label' => 'Starts', 'type' => 'date', + 'value' => $payload['start_date'] ?? null, 'display' => (string) ($payload['start_date'] ?? ''), + ], + ]; + + if (! empty($payload['end_date'])) { + $fields[] = ['key' => 'end_date', 'label' => 'Ends', 'type' => 'date', 'value' => $payload['end_date'], 'display' => (string) $payload['end_date']]; + } + + if (! empty($payload['targets'])) { + $display = implode(', ', array_map( + fn (array $target): string => "{$target['type']} #{$target['id']}", + $payload['targets'], + )); + + $fields[] = ['key' => 'targets', 'label' => 'Applies to', 'type' => 'list', 'value' => $payload['targets'], 'display' => $display]; + } + + return $fields; + } +} diff --git a/app/Ai/Tools/Write/CreateRecurringRuleTool.php b/app/Ai/Tools/Write/CreateRecurringRuleTool.php new file mode 100644 index 00000000..aeaa8926 --- /dev/null +++ b/app/Ai/Tools/Write/CreateRecurringRuleTool.php @@ -0,0 +1,171 @@ +user; + + $transactionId = (int) ($arguments['transaction_id'] ?? 0); + $transaction = $user->transactions()->find($transactionId); + + if ($transaction === null) { + throw new InvalidArgumentException('That transaction was not found. Find the transaction first, then pass its id.'); + } + + if ($transaction->recurringTransactionRule()->exists()) { + throw new InvalidArgumentException('That transaction already repeats. Tell the user it is already set up.'); + } + + $period = $arguments['recurrence_period'] ?? null; + if (! in_array($period, RecurringTransactionRule::RECURRENCE_PERIODS, true)) { + throw new InvalidArgumentException('The recurrence must be one of ' . implode(', ', RecurringTransactionRule::RECURRENCE_PERIODS) . '.'); + } + + $interval = isset($arguments['recurrence_interval']) ? (int) $arguments['recurrence_interval'] : 1; + if ($interval < 1) { + throw new InvalidArgumentException('The interval must be at least 1.'); + } + + $next = $this->parseDateTime($arguments['next_scheduled_at'] ?? null) + ?? $this->nextAfter($transaction->datetime ?? now(), $period, $interval); + + $endsAt = $this->parseDateTime($arguments['recurrence_ends_at'] ?? null); + + if ($endsAt !== null && $endsAt <= $next) { + throw new InvalidArgumentException('The end date must be after the next occurrence.'); + } + + return array_filter([ + 'transaction_id' => $transactionId, + 'recurrence_period' => $period, + 'recurrence_interval' => $interval, + 'next_scheduled_at' => $next->toIso8601String(), + 'recurrence_ends_at' => $endsAt?->toIso8601String(), + ], fn ($value) => $value !== null); + } + + private function nextAfter(mixed $from, string $period, int $interval): Carbon + { + $start = Carbon::parse((string) $from); + + return match ($period) { + 'daily' => $start->addDays($interval), + 'weekly' => $start->addWeeks($interval), + 'yearly' => $start->addYears($interval), + default => $start->addMonths($interval), + }; + } + + private function parseDateTime(mixed $value): ?Carbon + { + if ($value === null || trim((string) $value) === '') { + return null; + } + + try { + return Carbon::parse((string) $value); + } catch (\Throwable) { + throw new InvalidArgumentException("\"{$value}\" is not a date I can read."); + } + } + + protected function summarize(array $payload, ToolContext $context): string + { + $transaction = $context->user->transactions()->find($payload['transaction_id']); + $what = $transaction?->description ?: "transaction #{$payload['transaction_id']}"; + $interval = (int) ($payload['recurrence_interval'] ?? 1); + $every = $interval === 1 + ? $this->adverb($payload['recurrence_period']) + : "every {$interval} " . $payload['recurrence_period'] . 's'; + + return "Repeat \"{$what}\" {$every}."; + } + + private function adverb(string $period): string + { + return match ($period) { + 'daily' => 'daily', + 'weekly' => 'weekly', + 'yearly' => 'yearly', + default => 'monthly', + }; + } + + protected function reviewFields(array $payload, ToolContext $context): array + { + $transaction = $context->user->transactions()->find($payload['transaction_id'] ?? null); + + return [ + [ + 'key' => 'transaction_id', 'label' => 'Transaction', 'type' => 'transaction', + 'value' => $payload['transaction_id'] ?? null, + 'display' => (string) ($transaction?->description ?? ('#' . ($payload['transaction_id'] ?? ''))), + ], + [ + 'key' => 'recurrence_period', 'label' => 'Repeats', 'type' => 'text', + 'value' => $payload['recurrence_period'] ?? '', 'display' => (string) ($payload['recurrence_period'] ?? ''), + ], + [ + 'key' => 'recurrence_interval', 'label' => 'Every', 'type' => 'number', + 'value' => $payload['recurrence_interval'] ?? 1, 'display' => (string) ($payload['recurrence_interval'] ?? 1), + ], + [ + 'key' => 'next_scheduled_at', 'label' => 'Next due', 'type' => 'datetime', + 'value' => $payload['next_scheduled_at'] ?? null, 'display' => (string) ($payload['next_scheduled_at'] ?? ''), + ], + [ + 'key' => 'recurrence_ends_at', 'label' => 'Until', 'type' => 'datetime', + 'value' => $payload['recurrence_ends_at'] ?? null, 'display' => (string) ($payload['recurrence_ends_at'] ?? 'No end date'), + ], + ]; + } +} diff --git a/app/Ai/Tools/Write/CreateResourceTool.php b/app/Ai/Tools/Write/CreateResourceTool.php new file mode 100644 index 00000000..0f3609af --- /dev/null +++ b/app/Ai/Tools/Write/CreateResourceTool.php @@ -0,0 +1,132 @@ +resource; + } + + public function name(): string + { + return 'create_' . $this->singular(); + } + + public function actionType(): string + { + return $this->singular() . '.create'; + } + + public function description(): string + { + $required = array_map( + fn (ResourceField $field): string => $field->name, + array_filter($this->resource->writable, fn (ResourceField $field): bool => $field->required), + ); + + $needs = $required === [] ? '' : ' Needs ' . implode(', ', $required) . '.'; + + return "Propose creating a {$this->singular()}. {$this->resource->description}{$needs} " + . 'The user confirms before it is created.'; + } + + public function parameters(): array + { + return array_map( + fn (ResourceField $field): ParameterSpec => $field->toParameterSpec(), + $this->resource->writable, + ); + } + + protected function buildPayload(array $arguments, ToolContext $context): array + { + $payload = []; + + foreach ($this->resource->writable as $field) { + $value = $arguments[$field->name] ?? null; + + if (is_string($value)) { + $value = trim($value); + } + + if ($value === null || $value === '') { + continue; + } + + $payload[$field->name] = $value; + } + + $this->validate($payload); + + return $payload; + } + + /** + * Run the resource's own validation rules. Doing it here rather than at + * confirm time means the model is told what it got wrong while it can still + * fix it, instead of the user confirming an action that then fails. + * + * @param array $payload + */ + private function validate(array $payload): void + { + $rules = []; + + foreach ($this->resource->writable as $field) { + if ($field->rules !== [] && $field->rules !== '') { + $rules[$field->name] = $field->rules; + } + } + + if ($rules === []) { + return; + } + + $validator = Validator::make($payload, $rules); + + if ($validator->fails()) { + throw new InvalidArgumentException(implode(' ', $validator->errors()->all())); + } + } + + protected function summarize(array $payload, ToolContext $context): string + { + $label = $this->resource->labelColumn; + $name = $label !== null ? ($payload[$label] ?? null) : null; + + return $name !== null + ? "Create the {$this->singular()} \"{$name}\"." + : "Create a {$this->singular()}."; + } + + /** + * Resource names are plural; a tool name and an action type read as singular. + */ + private function singular(): string + { + return Str::singular($this->resource->name); + } +} diff --git a/app/Ai/Tools/Write/CreateWalletTool.php b/app/Ai/Tools/Write/CreateWalletTool.php index a6ab51ab..34e349fc 100644 --- a/app/Ai/Tools/Write/CreateWalletTool.php +++ b/app/Ai/Tools/Write/CreateWalletTool.php @@ -32,7 +32,7 @@ public function parameters(): array return [ ParameterSpec::string('name', 'The wallet name, e.g. "Cash".'), ParameterSpec::enum('type', 'The wallet type.', ['bank', 'cash', 'credit_card', 'mobile']), - ParameterSpec::string('currency', 'ISO 4217 currency code, 3 letters, e.g. "USD".'), + ParameterSpec::string('currency', "ISO 4217 currency code, 3 letters. Default to the user's own currency when they do not name one."), ParameterSpec::string('description', 'Optional description.', required: false), ]; } @@ -51,7 +51,7 @@ protected function buildPayload(array $arguments, ToolContext $context): array $currency = strtoupper(trim((string) ($arguments['currency'] ?? ''))); if (strlen($currency) !== 3) { - throw new InvalidArgumentException('Currency must be a 3-letter code, e.g. USD.'); + throw new InvalidArgumentException('Currency must be a 3-letter ISO 4217 code.'); } return array_filter([ diff --git a/app/Ai/Tools/Write/DescribesTransactionCategories.php b/app/Ai/Tools/Write/DescribesTransactionCategories.php new file mode 100644 index 00000000..12358ed2 --- /dev/null +++ b/app/Ai/Tools/Write/DescribesTransactionCategories.php @@ -0,0 +1,104 @@ +user === null) { + return null; + } + + return $context->user->categories()->whereKey($categoryId)->value('name'); + } + + protected function transactionFor(ToolContext $context, ?int $transactionId): ?Transaction + { + if ($transactionId === null || $context->user === null) { + return null; + } + + return $context->user->transactions()->with('wallet')->find($transactionId); + } + + /** + * How a transaction reads on a card: what it was, for how much, when. Enough + * for the user to recognise the row without opening it. + */ + protected function describeTransaction(?Transaction $transaction, ?int $fallbackId): string + { + if ($transaction === null) { + return $fallbackId ? "Transaction #{$fallbackId}" : 'Transaction'; + } + + $parts = array_filter([ + $transaction->description ?: 'Untitled', + $transaction->amount !== null + ? trim(($transaction->wallet->currency ?? '') . ' ' . $transaction->amount) + : null, + $transaction->datetime?->format('j M Y'), + ]); + + return implode(' · ', $parts); + } + + /** + * @param array $payload + * @return array> + */ + protected function reviewFields(array $payload, ToolContext $context): array + { + $transactionId = isset($payload['transaction_id']) ? (int) $payload['transaction_id'] : null; + $categoryId = $payload['categories'][0] ?? null; + $categoryId = $categoryId !== null ? (int) $categoryId : null; + + $transaction = $this->transactionFor($context, $transactionId); + $current = $transaction?->categories->first()?->name; + + return array_values(array_filter([ + [ + // Read-only: the agent chose which transaction, and letting the + // user swap it here would silently retarget the whole action. + 'key' => 'transaction_id', + 'label' => 'Transaction', + 'type' => 'readonly', + 'value' => $transactionId, + 'display' => $this->describeTransaction($transaction, $transactionId), + ], + $current ? [ + 'key' => 'current_category', + 'label' => 'Currently', + 'type' => 'readonly', + 'value' => $current, + 'display' => $current, + ] : null, + [ + 'key' => 'categories', + 'label' => 'Category', + 'type' => 'category', + 'value' => $categoryId, + 'display' => $this->categoryName($context, $categoryId) ?? '', + ], + ])); + } + + /** + * @param array $payload + */ + protected function summarize(array $payload, ToolContext $context): string + { + $name = $this->categoryName($context, $payload['categories'][0] ?? null) ?? 'a category'; + $transaction = $this->transactionFor($context, (int) ($payload['transaction_id'] ?? 0)); + + return 'Categorize ' . $this->describeTransaction($transaction, $payload['transaction_id'] ?? null) . " as {$name}"; + } +} diff --git a/app/Ai/Tools/Write/RecordRefundTool.php b/app/Ai/Tools/Write/RecordRefundTool.php new file mode 100644 index 00000000..f309b210 --- /dev/null +++ b/app/Ai/Tools/Write/RecordRefundTool.php @@ -0,0 +1,126 @@ +user; + + $refundId = (int) ($arguments['refund_transaction_id'] ?? 0); + $refund = $user->transactions()->find($refundId); + + if ($refund === null) { + throw new InvalidArgumentException('That refund transaction was not found. Find the incoming transaction first, then pass its id.'); + } + + if ($refund->type !== TransactionType::INCOME->value) { + throw new InvalidArgumentException('A refund must be the incoming transaction (an income), not the expense it reverses.'); + } + + if ($refund->refund()->exists()) { + throw new InvalidArgumentException('That transaction is already marked as a refund.'); + } + + $originalId = null; + + if (! empty($arguments['original_transaction_id'])) { + $originalId = (int) $arguments['original_transaction_id']; + $original = $user->transactions()->find($originalId); + + if ($original === null) { + throw new InvalidArgumentException('That original transaction was not found.'); + } + + if ($original->type !== TransactionType::EXPENSE->value) { + throw new InvalidArgumentException('The transaction being refunded must be an expense.'); + } + + if ($original->id === $refund->id) { + throw new InvalidArgumentException('A transaction cannot refund itself.'); + } + } + + return array_filter([ + 'refund_transaction_id' => $refundId, + 'original_transaction_id' => $originalId, + ], fn ($value) => $value !== null); + } + + protected function summarize(array $payload, ToolContext $context): string + { + $user = $context->user; + $refund = $user->transactions()->find($payload['refund_transaction_id']); + $amount = $refund?->amount ?? ''; + $what = $refund?->description ?: "transaction #{$payload['refund_transaction_id']}"; + + if (empty($payload['original_transaction_id'])) { + return trim("Mark {$amount} \"{$what}\" as a refund."); + } + + $original = $user->transactions()->find($payload['original_transaction_id']); + $originalWhat = $original?->description ?: "transaction #{$payload['original_transaction_id']}"; + + return trim("Mark {$amount} \"{$what}\" as a refund of \"{$originalWhat}\"."); + } + + protected function reviewFields(array $payload, ToolContext $context): array + { + $user = $context->user; + $refund = $user->transactions()->find($payload['refund_transaction_id'] ?? null); + $original = $user->transactions()->find($payload['original_transaction_id'] ?? null); + + return [ + [ + 'key' => 'refund_transaction_id', 'label' => 'Money received', 'type' => 'transaction', + 'value' => $payload['refund_transaction_id'] ?? null, + 'display' => (string) ($refund?->description ?? ('#' . ($payload['refund_transaction_id'] ?? ''))), + ], + [ + 'key' => 'original_transaction_id', 'label' => 'Refund of', 'type' => 'transaction', + 'value' => $payload['original_transaction_id'] ?? null, + 'display' => (string) ($original?->description ?? 'Not linked to a specific expense'), + ], + ]; + } +} diff --git a/app/Contracts/Entitlements.php b/app/Contracts/Entitlements.php deleted file mode 100644 index b4645ded..00000000 --- a/app/Contracts/Entitlements.php +++ /dev/null @@ -1,23 +0,0 @@ -title]); + + foreach ($document->meta as $label => $value) { + fputcsv($handle, [$label, $this->scalar($value)]); + } + + foreach ($document->notices as $notice) { + fputcsv($handle, [$notice]); + } + + foreach ($document->sections as $section) { + fputcsv($handle, []); + fputcsv($handle, [$section->heading]); + + if ($section->note !== null) { + fputcsv($handle, [$section->note]); + } + + foreach ($section->summary as $label => $value) { + fputcsv($handle, [$label, $this->scalar($value)]); + } + + if (! $section->hasTable()) { + continue; + } + + fputcsv($handle, $section->columns); + + foreach ($section->rows as $row) { + fputcsv($handle, array_map(fn ($cell) => $this->scalar($cell), $row)); + } + } + + rewind($handle); + $contents = (string) stream_get_contents($handle); + fclose($handle); + + // Excel reads a UTF-8 CSV as the local codepage unless it sees a BOM, + // which mangles currency symbols and accented category names. + return "\u{FEFF}" . $contents; + } + + private function scalar(mixed $value): string + { + if (is_bool($value)) { + return $value ? 'yes' : 'no'; + } + + return (string) ($value ?? ''); + } +} diff --git a/app/Exports/ExportDocument.php b/app/Exports/ExportDocument.php new file mode 100644 index 00000000..1af5c0e4 --- /dev/null +++ b/app/Exports/ExportDocument.php @@ -0,0 +1,24 @@ + $sections + * @param array $meta Header lines (period, currency, generated at) + * @param array $notices Caveats that must travel with the numbers + */ + public function __construct( + public readonly string $title, + public readonly array $sections, + public readonly array $meta = [], + public readonly array $notices = [], + ) { + } +} diff --git a/app/Exports/ExportSection.php b/app/Exports/ExportSection.php new file mode 100644 index 00000000..21a147a7 --- /dev/null +++ b/app/Exports/ExportSection.php @@ -0,0 +1,30 @@ + $columns + * @param iterable> $rows + * @param array $summary + */ + public function __construct( + public readonly string $heading, + public readonly array $columns = [], + public readonly iterable $rows = [], + public readonly array $summary = [], + public readonly ?string $note = null, + ) { + } + + public function hasTable(): bool + { + return ! empty($this->columns); + } +} diff --git a/app/Exports/Exporter.php b/app/Exports/Exporter.php new file mode 100644 index 00000000..b41f19ab --- /dev/null +++ b/app/Exports/Exporter.php @@ -0,0 +1,28 @@ + */ + private array $exporters = []; + + public function __construct() + { + $this->register(new CsvExporter()); + $this->register(new XlsxExporter()); + $this->register(new PdfExporter()); + } + + public function register(Exporter $exporter): void + { + $this->exporters[$exporter->key()] = $exporter; + } + + public function has(string $format): bool + { + return isset($this->exporters[$format]); + } + + public function for(string $format): Exporter + { + if (! isset($this->exporters[$format])) { + throw new InvalidArgumentException("No exporter for format: {$format}"); + } + + return $this->exporters[$format]; + } + + /** + * @return array + */ + public function formats(): array + { + return array_keys($this->exporters); + } + + /** + * @return array + */ + public function rowLimits(): array + { + return array_map(fn (Exporter $exporter) => $exporter->maxRows(), $this->exporters); + } +} diff --git a/app/Exports/PdfExporter.php b/app/Exports/PdfExporter.php new file mode 100644 index 00000000..b4f9595b --- /dev/null +++ b/app/Exports/PdfExporter.php @@ -0,0 +1,60 @@ + [ + 'heading' => $section->heading, + 'note' => $section->note, + 'summary' => $section->summary, + 'columns' => $section->columns, + // dompdf renders the whole document in one pass, so rows cannot + // stay lazy; a generator would be consumed before layout runs. + 'rows' => $this->materialise($section->rows), + ], + $document->sections + ); + + return Pdf::loadView('exports.document', [ + 'title' => $document->title, + 'meta' => $document->meta, + 'notices' => $document->notices, + 'sections' => $sections, + ])->setPaper('a4', 'landscape')->output(); + } + + /** + * @param iterable> $rows + * @return array> + */ + private function materialise(iterable $rows): array + { + return is_array($rows) ? $rows : iterator_to_array($rows, false); + } +} diff --git a/app/Exports/ReportExportBuilder.php b/app/Exports/ReportExportBuilder.php new file mode 100644 index 00000000..09812b68 --- /dev/null +++ b/app/Exports/ReportExportBuilder.php @@ -0,0 +1,177 @@ + $stats The return value of StatsService::compute() + * @param array $meta + */ + public function build(array $stats, string $title, array $meta = []): ExportDocument + { + $currency = (string) ($stats['currency'] ?? 'USD'); + + $sections = array_values(array_filter([ + $this->overview($stats, $currency), + $this->position($stats, $currency), + $this->categories($stats, $currency, 'expenses', __('Top expense categories')), + $this->categories($stats, $currency, 'income', __('Top income categories')), + $this->largestTransactions($stats, $currency), + ])); + + return new ExportDocument( + title: $title, + sections: $sections, + meta: $meta + [__('Currency') => $currency], + notices: $this->notices($stats), + ); + } + + /** + * @param array $stats + * @return array + */ + private function notices(array $stats): array + { + if (empty($stats['partial'])) { + return []; + } + + // Without this line the reader has no way to tell that some amounts + // were left out of the totals rather than being zero. + return [__('Some amounts could not be converted and are excluded from these totals. Affected currencies: :currencies', [ + 'currencies' => implode(', ', (array) ($stats['unconverted_currencies'] ?? [])), + ])]; + } + + /** + * @param array $stats + */ + private function overview(array $stats, string $currency): ?ExportSection + { + if (! is_array($stats['overview'] ?? null)) { + return null; + } + + $overview = $stats['overview']; + + return new ExportSection( + heading: __('Overview'), + summary: [ + __('Total balance') => $this->money($overview['total_balance'] ?? 0, $currency), + __('Total income') => $this->money($overview['total_income'] ?? 0, $currency), + __('Total expenses') => $this->money($overview['total_expenses'] ?? 0, $currency), + __('Net cash flow') => $this->money($overview['net_cash_flow'] ?? 0, $currency), + __('Average monthly income') => $this->money($overview['avg_monthly_income'] ?? 0, $currency), + __('Average monthly expenses') => $this->money($overview['avg_monthly_expenses'] ?? 0, $currency), + __('Savings rate') => number_format((float) ($overview['savings_rate'] ?? 0), 1) . '%', + ], + ); + } + + /** + * @param array $stats + */ + private function position(array $stats, string $currency): ?ExportSection + { + if (! is_array($stats['position'] ?? null)) { + return null; + } + + $position = $stats['position']; + + return new ExportSection( + heading: __('Financial position'), + summary: [ + __('Earned income') => $this->money($position['earned_income'] ?? 0, $currency), + __('Discretionary spend') => $this->money($position['discretionary_spend'] ?? 0, $currency), + __('Cash balance') => $this->money($position['cash_balance'] ?? 0, $currency), + __('Holdings value') => $this->money($position['holdings_value'] ?? 0, $currency), + __('Total net worth') => $this->money($position['total_net_worth'] ?? 0, $currency), + __('Loans and debt (net)') => $this->money($position['loans_debt_net'] ?? 0, $currency), + __('Investment principal') => $this->money($position['investment_principal'] ?? 0, $currency), + __('Investment returns') => $this->money($position['investment_returns'] ?? 0, $currency), + __('Gifts received') => $this->money($position['gifts_received'] ?? 0, $currency), + __('Net worth change') => $this->money($position['net_worth_delta'] ?? 0, $currency), + ], + note: __('Borrowed money and investment purchases are balance-neutral and excluded from the net worth change.'), + ); + } + + /** + * @param array $stats + */ + private function categories(array $stats, string $currency, string $bucket, string $heading): ?ExportSection + { + $categories = $stats['top_categories'][$bucket] ?? null; + + if (! is_array($categories) || $categories === []) { + return null; + } + + $rows = []; + foreach ($categories as $category) { + $rows[] = [ + (string) ($category['name'] ?? __('Uncategorized')), + $this->money($category['amount'] ?? 0, $currency), + (int) ($category['transaction_count'] ?? 0), + ]; + } + + return new ExportSection( + heading: $heading, + columns: [__('Category'), __('Amount'), __('Transactions')], + rows: $rows, + ); + } + + /** + * @param array $stats + */ + private function largestTransactions(array $stats, string $currency): ?ExportSection + { + $largest = $stats['largest_transactions'] ?? null; + + if (! is_array($largest)) { + return null; + } + + $rows = []; + foreach (['income', 'expense'] as $type) { + if (! is_array($largest[$type] ?? null)) { + continue; + } + + $rows[] = [ + Str::ucfirst($type), + (string) ($largest[$type]['date'] ?? ''), + (string) ($largest[$type]['description'] ?? ''), + (string) ($largest[$type]['category'] ?? ''), + $this->money($largest[$type]['amount'] ?? 0, $currency), + ]; + } + + if ($rows === []) { + return null; + } + + return new ExportSection( + heading: __('Largest transactions'), + columns: [__('Type'), __('Date'), __('Description'), __('Category'), __('Amount')], + rows: $rows, + ); + } + + private function money(mixed $value, string $currency): string + { + return number_format((float) $value, 2) . ' ' . $currency; + } +} diff --git a/app/Exports/TransactionExportBuilder.php b/app/Exports/TransactionExportBuilder.php new file mode 100644 index 00000000..e57ee16c --- /dev/null +++ b/app/Exports/TransactionExportBuilder.php @@ -0,0 +1,101 @@ + $meta + */ + public function build(Builder|Relation $query, string $title, array $meta = []): ExportDocument + { + $query = $query->with(['wallet', 'party', 'categories']); + + $totals = [ + 'income' => (float) (clone $query)->where('type', 'income')->sum('amount'), + 'expenses' => (float) (clone $query)->where('type', 'expense')->sum('amount'), + ]; + + $section = new ExportSection( + heading: __('Transactions'), + columns: [ + __('Date'), + __('Type'), + __('Intent'), + __('Amount'), + __('Currency'), + __('Wallet'), + __('Category'), + __('Party'), + __('Description'), + __('Transfer leg'), + ], + rows: $this->rows($query), + summary: [ + __('Total income') => $this->amount($totals['income']), + __('Total expenses') => $this->amount($totals['expenses']), + __('Net') => $this->amount($totals['income'] - $totals['expenses']), + ], + note: __('Amounts are shown in each transaction\'s own wallet currency and are not converted.'), + ); + + return new ExportDocument( + title: $title, + sections: [$section], + meta: $meta, + ); + } + + public function countRows(Builder|Relation $query): int + { + return (clone $query)->count(); + } + + /** + * Streamed so a large export does not hold every model in memory at once. + * + * @return iterable> + */ + private function rows(Builder|Relation $query): iterable + { + foreach ($query->lazy() as $transaction) { + yield $this->row($transaction); + } + } + + /** + * @return array + */ + private function row(Transaction $transaction): array + { + return [ + $transaction->datetime ? Carbon::parse($transaction->datetime)->toDateTimeString() : null, + $transaction->type, + str_replace('_', ' ', (string) $transaction->intent), + (float) $transaction->amount, + $transaction->wallet?->currency, + $transaction->wallet?->name, + $transaction->categories->pluck('name')->implode(', '), + $transaction->party?->name, + $transaction->description, + // A transfer writes an expense and an income leg. Flagging them + // keeps a reader from adding both into a total that double-counts. + $transaction->transfer_id ? __('yes') : '', + ]; + } + + private function amount(float $value): string + { + return number_format($value, 2, '.', ''); + } +} diff --git a/app/Exports/XlsxExporter.php b/app/Exports/XlsxExporter.php new file mode 100644 index 00000000..258b31ca --- /dev/null +++ b/app/Exports/XlsxExporter.php @@ -0,0 +1,151 @@ +removeSheetByIndex(0); + + $sections = $document->sections; + if (empty($sections)) { + $sections = [new ExportSection(__('Summary'))]; + } + + foreach (array_values($sections) as $index => $section) { + $sheet = $spreadsheet->createSheet(); + $sheet->setTitle($this->sheetTitle($section->heading, $index)); + + $rows = $index === 0 + ? $this->headerRows($document) + : []; + + $rows[] = [$section->heading]; + + if ($section->note !== null) { + $rows[] = [$section->note]; + } + + foreach ($section->summary as $label => $value) { + $rows[] = [$label, $value]; + } + + if ($section->hasTable()) { + $rows[] = []; + $rows[] = $section->columns; + + foreach ($section->rows as $row) { + $rows[] = array_values($row); + } + } + + $sheet->fromArray($rows, null, 'A1', true); + $this->autoSize($sheet, $rows); + } + + $spreadsheet->setActiveSheetIndex(0); + + return $this->render($spreadsheet); + } + + /** + * @return array> + */ + private function headerRows(ExportDocument $document): array + { + $rows = [[$document->title]]; + + foreach ($document->meta as $label => $value) { + $rows[] = [$label, $value]; + } + + foreach ($document->notices as $notice) { + $rows[] = [$notice]; + } + + $rows[] = []; + + return $rows; + } + + /** + * Excel rejects sheet names over 31 characters or containing []:*?/\, and + * silently breaks on duplicates, so every title is normalised and suffixed. + */ + private function sheetTitle(string $heading, int $index): string + { + $title = preg_replace('/[\[\]:*?\/\\\\]/', ' ', $heading) ?? 'Sheet'; + $title = trim(preg_replace('/\s+/', ' ', $title) ?? ''); + + if ($title === '') { + $title = 'Sheet'; + } + + $suffix = ' ' . ($index + 1); + + return mb_substr($title, 0, 31 - mb_strlen($suffix)) . $suffix; + } + + /** + * @param array> $rows + */ + private function autoSize(Worksheet $sheet, array $rows): void + { + $widest = 0; + foreach ($rows as $row) { + $widest = max($widest, count($row)); + } + + for ($column = 1; $column <= $widest; $column++) { + $sheet->getColumnDimensionByColumn($column)->setAutoSize(true); + } + } + + private function render(Spreadsheet $spreadsheet): string + { + // The xlsx writer builds a zip archive, which needs a real path rather + // than an in-memory stream. + $path = tempnam(sys_get_temp_dir(), 'trakli-export-'); + + if ($path === false) { + throw new RuntimeException('Unable to allocate a temporary file for the export.'); + } + + try { + (new Xlsx($spreadsheet))->save($path); + + return (string) file_get_contents($path); + } finally { + if (is_file($path)) { + unlink($path); + } + } + } +} diff --git a/app/Http/Controllers/API/ApiController.php b/app/Http/Controllers/API/ApiController.php index ccfd0878..a29cad05 100644 --- a/app/Http/Controllers/API/ApiController.php +++ b/app/Http/Controllers/API/ApiController.php @@ -66,7 +66,7 @@ public function userCanAccessResource($resource, $ownerKey = 'user_id') */ protected function validateRequest(Request $request, array $rules): array { - $validator = Validator::make($request->all(), $rules); + $validator = Validator::make($this->castBooleanInput($request->all(), $rules), $rules); if ($validator->fails()) { $errors = $validator->errors(); @@ -91,6 +91,51 @@ protected function validateRequest(Request $request, array $rules): array return ['isValidated' => true, 'data' => $validator->validated()]; } + /** + * Cast textual booleans for attributes the rules declare as boolean. + * + * Unrecognised values are left untouched so the boolean rule still rejects them + * instead of silently reading as false. + */ + private function castBooleanInput(array $data, array $rules): array + { + foreach ($rules as $attribute => $rule) { + if (! array_key_exists($attribute, $data) || ! is_string($data[$attribute])) { + continue; + } + + if (! $this->expectsBoolean($rule)) { + continue; + } + + $casted = filter_var($data[$attribute], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + if (! is_null($casted)) { + $data[$attribute] = $casted; + } + } + + return $data; + } + + /** + * Determine whether a rule set holds the boolean rule. + * + * @param mixed $rule + */ + private function expectsBoolean($rule): bool + { + $rules = is_array($rule) ? $rule : explode('|', (string) $rule); + + foreach ($rules as $singleRule) { + if (is_string($singleRule) && strtolower(explode(':', $singleRule)[0]) === 'boolean') { + return true; + } + } + + return false; + } + /** * Return a success response. * diff --git a/app/Http/Controllers/API/v1/AiController.php b/app/Http/Controllers/API/v1/AiController.php index b946558c..cda5a3ca 100644 --- a/app/Http/Controllers/API/v1/AiController.php +++ b/app/Http/Controllers/API/v1/AiController.php @@ -11,9 +11,11 @@ use App\Models\ChatSession; use App\Models\User; use App\Services\AiService; +use App\Services\ChatActionBlocks; use App\Services\FileService; use App\Services\ProposedActionExecutor; -use App\Services\TransactionWriter; +use App\Services\ProposedActionOverrides; +use Illuminate\Database\Eloquent\Collection; use Symfony\Component\HttpKernel\Exception\HttpException; use Throwable; use Whilesmart\Activities\Models\Activity; @@ -34,7 +36,8 @@ class AiController extends ApiController { public function __construct( - protected AiService $aiService + protected AiService $aiService, + protected ChatActionBlocks $blocks ) { } @@ -238,11 +241,11 @@ public function confirmAction( } $described = null; if (is_array($overrides) && $overrides !== []) { - $allowed = array_flip($this->allowedOverrideKeys($action->action_type)); - $merged = array_merge($action->payload, array_intersect_key($overrides, $allowed)); + $policy = app(ProposedActionOverrides::class); + $merged = $policy->merge($action->action_type, $action->payload, $overrides); try { - $this->revalidateOverride($user, $action->action_type, $merged); + $policy->revalidate($user, $action->action_type, $merged); } catch (HttpException $e) { return $this->failure($e->getMessage(), $e->getStatusCode()); } @@ -278,7 +281,7 @@ public function confirmAction( ]); $this->recordActivity($user, $chat, $action, $resource); - $this->markActionBlockStatus($action, ActionStatus::Executed, $described); + $this->blocks->markStatus($action, ActionStatus::Executed, $described); $this->continueChainAfterCreate($chat, $action); return $this->success(['action' => $action->fresh(), 'resource' => $resource], __('Action completed.')); @@ -346,11 +349,143 @@ public function rejectAction(Request $request, ChatSession $chat, AgentProposedA } $action->update(['status' => ActionStatus::Rejected]); - $this->markActionBlockStatus($action, ActionStatus::Rejected); + $this->blocks->markStatus($action, ActionStatus::Rejected); return $this->success(['action' => $action->fresh()], __('Action dismissed.')); } + #[OA\Post( + path: '/ai/chats/{chat}/actions/batches/{batch}/confirm', + summary: 'Confirm every action proposed together as one batch', + tags: ['AI'], + parameters: [ + new OA\Parameter(name: 'chat', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'batch', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid')), + ], + responses: [ + new OA\Response(response: 200, description: 'Batch processed'), + new OA\Response(response: 404, description: 'Batch not found'), + ] + )] + public function confirmActionBatch( + Request $request, + ChatSession $chat, + string $batch, + ProposedActionExecutor $executor + ): JsonResponse { + $user = $request->user(); + $this->authorizeOwnership($user, $chat); + + $actions = $this->batchMembers($user, $chat, $batch); + + if ($actions->isEmpty()) { + return $this->failure(__('Batch not found.'), Response::HTTP_NOT_FOUND); + } + + $executed = 0; + $failed = []; + + foreach ($actions as $action) { + // Already-executed members are skipped rather than re-run, so a + // retried confirm is safe, and one failure never stops the rest. + if ($action->status === ActionStatus::Executed) { + $executed++; + + continue; + } + + if (! in_array($action->status, [ActionStatus::Proposed, ActionStatus::Confirmed], true)) { + continue; + } + + try { + $resource = $executor->execute($action); + } catch (Throwable $e) { + $action->update(['status' => ActionStatus::Failed, 'error' => $e->getMessage()]); + $failed[] = $action->id; + + continue; + } + + $action->update([ + 'status' => ActionStatus::Executed, + 'confirmed_at' => now(), + 'executed_at' => now(), + 'executed_resource_type' => $resource->getMorphClass(), + 'executed_resource_id' => $resource->getKey(), + ]); + $this->recordActivity($user, $chat, $action, $resource); + $executed++; + } + + $this->blocks->syncBatch($batch); + + return $this->success( + ['batch' => $batch, 'executed' => $executed, 'failed' => $failed], + $failed === [] + ? trans_choice('{1}Action completed.|[2,*]:count actions completed.', $executed, ['count' => $executed]) + : __(':done done, :failed could not be completed.', ['done' => $executed, 'failed' => count($failed)]) + ); + } + + #[OA\Post( + path: '/ai/chats/{chat}/actions/batches/{batch}/reject', + summary: 'Dismiss every action proposed together as one batch', + tags: ['AI'], + parameters: [ + new OA\Parameter(name: 'chat', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'batch', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid')), + ], + responses: [ + new OA\Response(response: 200, description: 'Batch dismissed'), + new OA\Response(response: 404, description: 'Batch not found'), + ] + )] + public function rejectActionBatch(Request $request, ChatSession $chat, string $batch): JsonResponse + { + $user = $request->user(); + $this->authorizeOwnership($user, $chat); + + $actions = $this->batchMembers($user, $chat, $batch); + + if ($actions->isEmpty()) { + return $this->failure(__('Batch not found.'), Response::HTTP_NOT_FOUND); + } + + $dismissed = 0; + foreach ($actions as $action) { + // An executed member stays executed: dismissing the rest of a batch + // is not a licence to undo what the user already ran. + if ($action->status === ActionStatus::Executed) { + continue; + } + + $action->update(['status' => ActionStatus::Rejected]); + $dismissed++; + } + + $this->blocks->syncBatch($batch); + + return $this->success(['batch' => $batch, 'dismissed' => $dismissed], __('Actions dismissed.')); + } + + /** + * The owned members of a batch within this chat. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + private function batchMembers(User $user, ChatSession $chat, string $batch): Collection + { + return AgentProposedAction::query() + ->forBatch($batch) + ->where('owner_type', $user->getMorphClass()) + ->where('owner_id', $user->getAuthIdentifier()) + ->where('source_type', (new ChatSession())->getMorphClass()) + ->where('source_id', $chat->id) + ->orderBy('id') + ->get(); + } + #[OA\Post( path: '/ai/chats/{chat}/messages/{message}/files', summary: 'Attach files to a chat message (for receipts, statements, etc.)', @@ -463,74 +598,6 @@ private function authorizeAction(User $user, ChatSession $chat, AgentProposedAct } } - /** - * Re-validate an edited (overridden) payload before executing. Ownership is - * the security-critical check; field rules mirror the tool's own validation. - * - * @param array $payload - * - * @throws HttpException - */ - /** - * Keys a user is allowed to override on confirm, per action type. Anything - * else (notably user_id) is dropped before merging. - * - * @return array - */ - private function allowedOverrideKeys(string $actionType): array - { - return match ($actionType) { - 'transaction.create' => ['amount', 'type', 'wallet_id', 'party_id', 'description', 'datetime', 'categories'], - 'transaction.categorize' => ['categories'], - 'transfer.create' => ['amount', 'from_wallet_id', 'to_wallet_id', 'exchange_rate', 'datetime'], - 'wallet.create' => ['name', 'type', 'currency', 'description'], - 'category.create' => ['name', 'type', 'description'], - 'party.create' => ['name', 'type', 'description'], - default => [], - }; - } - - private function revalidateOverride(User $user, string $actionType, array $payload): void - { - if (in_array($actionType, ['transaction.create', 'transaction.categorize'], true)) { - $this->revalidateTransactionOverride($user, $payload); - } - - if ($actionType === 'transfer.create') { - $this->revalidateTransferOverride($user, $payload); - } - } - - private function revalidateTransactionOverride(User $user, array $payload): void - { - app(TransactionWriter::class)->validateOwnership($user, $payload, $payload['categories'] ?? []); - - if (isset($payload['amount']) && (float) $payload['amount'] <= 0) { - throw new HttpException(Response::HTTP_UNPROCESSABLE_ENTITY, 'Amount must be greater than zero.'); - } - if (isset($payload['type']) && ! in_array($payload['type'], ['income', 'expense'], true)) { - throw new HttpException(Response::HTTP_UNPROCESSABLE_ENTITY, 'Type must be income or expense.'); - } - } - - private function revalidateTransferOverride(User $user, array $payload): void - { - foreach (['from_wallet_id', 'to_wallet_id'] as $key) { - if (! empty($payload[$key]) && ! $user->wallets()->whereKey($payload[$key])->exists()) { - throw new HttpException(Response::HTTP_FORBIDDEN, 'The selected wallet does not belong to you.'); - } - } - if (! empty($payload['from_wallet_id']) && $payload['from_wallet_id'] === ($payload['to_wallet_id'] ?? null)) { - throw new HttpException(Response::HTTP_UNPROCESSABLE_ENTITY, 'The source and destination wallets must be different.'); - } - if (isset($payload['amount']) && (float) $payload['amount'] <= 0) { - throw new HttpException(Response::HTTP_UNPROCESSABLE_ENTITY, 'Amount must be greater than zero.'); - } - if (isset($payload['exchange_rate']) && (float) $payload['exchange_rate'] <= 0) { - throw new HttpException(Response::HTTP_UNPROCESSABLE_ENTITY, 'Exchange rate must be greater than zero.'); - } - } - private function recordActivity(User $user, ChatSession $chat, AgentProposedAction $action, $resource): void { Activity::create([ @@ -618,47 +685,4 @@ private function describeProposal(AgentProposedAction $action, User $user): ?arr return null; } } - - /** - * @param array{summary?: string, fields?: array}|null $described Regenerated - * summary/fields to reflect confirmed edits on the rendered block. - */ - private function markActionBlockStatus(AgentProposedAction $action, ActionStatus $status, ?array $described = null): void - { - $message = ChatMessage::find($action->metadata['chat_message_id'] ?? null); - - if ($message === null) { - return; - } - - $result = $message->result ?? []; - $blocks = $result['blocks'] ?? []; - - if (! is_array($blocks)) { - return; - } - - $changed = false; - foreach ($blocks as &$block) { - if ( - is_array($block) - && ($block['type'] ?? null) === 'proposed_action' - && (int) ($block['id'] ?? 0) === (int) $action->id - ) { - $block['status'] = $status->value; - if ($described !== null) { - $block['summary'] = $described['summary']; - $block['fields'] = $described['fields']; - $block['payload'] = $action->payload; - } - $changed = true; - } - } - unset($block); - - if ($changed) { - $result['blocks'] = $blocks; - $message->update(['result' => $result]); - } - } } diff --git a/app/Http/Controllers/API/v1/CategoryController.php b/app/Http/Controllers/API/v1/CategoryController.php index 60cd6505..511ef9c9 100644 --- a/app/Http/Controllers/API/v1/CategoryController.php +++ b/app/Http/Controllers/API/v1/CategoryController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\API\v1; -use App\Contracts\Entitlements; +use Whilesmart\Entitlements\Contracts\Entitlements; use App\Http\Controllers\API\ApiController; use App\Http\Traits\ApiQueryable; use App\Models\Category; diff --git a/app/Http/Controllers/API/v1/ExportController.php b/app/Http/Controllers/API/v1/ExportController.php new file mode 100644 index 00000000..38e37213 --- /dev/null +++ b/app/Http/Controllers/API/v1/ExportController.php @@ -0,0 +1,229 @@ +query('format', 'csv'); + if (! $this->exporters->has($format)) { + return $this->unsupportedFormat(); + } + + $type = $request->query('type'); + if (! empty($type) && ! in_array($type, ['income', 'expense'], true)) { + return $this->failure(__('Invalid transaction type'), 422); + } + + /** @var User $user */ + $user = $request->user(); + + $query = $user->transactions() + ->orderBy('datetime', 'desc') + ->orderBy('created_at', 'desc'); + + if (! empty($type)) { + $query->where('type', $type); + } + + $this->applyTransactionFilters($query, $request); + + $exporter = $this->exporters->for($format); + + $count = $this->transactionBuilder->countRows($query); + if ($count > $exporter->maxRows()) { + return $this->failure( + __( + 'This export covers :count transactions, over the limit of :max for :format files. ' + . 'Narrow the date range or wallets, or pick a format that holds more.', + [ + 'count' => $count, + 'max' => $exporter->maxRows(), + 'format' => strtoupper($format), + ] + ), + 422, + [ + 'count' => $count, + 'max_rows' => $exporter->maxRows(), + 'format' => $format, + 'format_limits' => $this->exporters->rowLimits(), + ] + ); + } + + $document = $this->transactionBuilder->build($query, __('Transactions'), [ + __('Generated') => now()->toDayDateTimeString(), + __('Transactions') => $count, + ]); + + return $this->download($document, $format, 'transactions'); + } + + #[OA\Get( + path: '/reports/export', + summary: 'Download a financial statement as a file', + description: 'Renders the same analytics as the stats endpoint into a formatted statement.', + tags: ['Exports'], + parameters: [ + new OA\Parameter( + name: 'format', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string', default: 'pdf', enum: ['csv', 'xlsx', 'pdf']) + ), + new OA\Parameter(name: 'start_date', in: 'query', required: false, schema: new OA\Schema(type: 'string', format: 'date')), + new OA\Parameter(name: 'end_date', in: 'query', required: false, schema: new OA\Schema(type: 'string', format: 'date')), + new OA\Parameter( + name: 'preset', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string', enum: ['all_time', 'current_week', 'current_month', 'last_3_months']) + ), + new OA\Parameter( + name: 'wallet_ids', + description: 'Comma-separated wallet ids', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'period', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string', default: 'month', enum: ['day', 'week', 'month', 'year']) + ), + ], + responses: [ + new OA\Response(response: 200, description: 'The statement as a file download'), + new OA\Response(response: 422, description: 'Unsupported format or invalid wallet ids'), + ] + )] + public function report(Request $request): Response|JsonResponse + { + $format = (string) $request->query('format', 'pdf'); + if (! $this->exporters->has($format)) { + return $this->unsupportedFormat(); + } + + $user = $request->user(); + + [$startDate, $endDate] = $this->resolveDateRange($request); + + $walletIds = $this->resolveWalletIds($request, $user); + if ($walletIds instanceof JsonResponse) { + return $walletIds; + } + + $stats = $this->statsService->compute( + $user, + $startDate, + $endDate, + $walletIds, + (string) $request->input('period', 'month'), + $user->getConfigValue('default-currency') ?? 'USD' + ); + + $document = $this->reportBuilder->build($stats, __('Financial statement'), [ + __('Period') => $startDate->toDateString() . ' to ' . $endDate->toDateString(), + __('Generated') => now()->toDayDateTimeString(), + ]); + + return $this->download($document, $format, 'statement'); + } + + private function download(ExportDocument $document, string $format, string $basename): Response + { + $exporter = $this->exporters->for($format); + $filename = Str::slug($basename . ' ' . now()->toDateString()) . '.' . $exporter->extension(); + + return response($exporter->export($document), Response::HTTP_OK, [ + 'Content-Type' => $exporter->mimeType(), + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ]); + } + + private function unsupportedFormat(): JsonResponse + { + return $this->failure(__('Unsupported export format.'), Response::HTTP_UNPROCESSABLE_ENTITY, [ + 'supported_formats' => $this->exporters->formats(), + ]); + } +} diff --git a/app/Http/Controllers/API/v1/IntegrationController.php b/app/Http/Controllers/API/v1/IntegrationController.php index f1ffe46f..4f20af6f 100644 --- a/app/Http/Controllers/API/v1/IntegrationController.php +++ b/app/Http/Controllers/API/v1/IntegrationController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\API\v1; -use App\Contracts\Entitlements; +use Whilesmart\Entitlements\Contracts\Entitlements; use App\Contracts\Integration; use App\Contracts\IntegrationUi; use App\Http\Controllers\API\ApiController; diff --git a/app/Http/Controllers/API/v1/StatsController.php b/app/Http/Controllers/API/v1/StatsController.php index fa85e22b..adfa0c48 100644 --- a/app/Http/Controllers/API/v1/StatsController.php +++ b/app/Http/Controllers/API/v1/StatsController.php @@ -3,9 +3,8 @@ namespace App\Http\Controllers\API\v1; use App\Http\Controllers\API\ApiController; -use App\Models\Wallet; +use App\Http\Traits\ResolvesStatsQuery; use App\Services\StatsService; -use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -14,6 +13,8 @@ #[OA\Tag(name: 'Statistics', description: 'Financial statistics and analytics')] class StatsController extends ApiController { + use ResolvesStatsQuery; + public function __construct( protected StatsService $statsService ) { @@ -148,57 +149,4 @@ public function index(Request $request): JsonResponse return $this->success($data); } - - private function resolveDateRange(Request $request): array - { - $endDate = Carbon::now()->endOfDay(); - $startDate = Carbon::now()->subDays(30)->startOfDay(); - - if ($request->has('preset')) { - $endDate = Carbon::now()->endOfDay(); - - $startDate = match ($request->input('preset')) { - 'all_time' => Carbon::parse('2000-01-01')->startOfDay(), - 'current_week' => Carbon::now()->startOfWeek()->startOfDay(), - 'current_month' => Carbon::now()->startOfMonth()->startOfDay(), - 'last_3_months' => Carbon::now()->subMonths(3)->startOfDay(), - default => Carbon::now()->subDays(30)->startOfDay(), - }; - } else { - if ($request->has('start_date')) { - $startDate = Carbon::parse($request->input('start_date'))->startOfDay(); - } - if ($request->has('end_date')) { - $endDate = Carbon::parse($request->input('end_date'))->endOfDay(); - } - } - - return [$startDate, $endDate]; - } - - /** - * @return array|JsonResponse - */ - private function resolveWalletIds(Request $request, $user): array|JsonResponse - { - if (! $request->has('wallet_ids')) { - return []; - } - - $walletIds = array_filter(array_map('intval', explode(',', $request->input('wallet_ids')))); - - $validWalletIds = Wallet::where('user_id', $user->id) - ->whereIn('id', $walletIds) - ->pluck('id') - ->toArray(); - - $invalidWalletIds = array_diff($walletIds, $validWalletIds); - if (! empty($invalidWalletIds)) { - return $this->failure(__('One or more wallet IDs are invalid or do not belong to the user.'), 422, [ - 'invalid_wallet_ids' => array_values($invalidWalletIds), - ]); - } - - return $walletIds; - } } diff --git a/app/Http/Controllers/API/v1/TransactionController.php b/app/Http/Controllers/API/v1/TransactionController.php index aab62b1f..4a6be25f 100644 --- a/app/Http/Controllers/API/v1/TransactionController.php +++ b/app/Http/Controllers/API/v1/TransactionController.php @@ -5,6 +5,7 @@ use App\Enums\TransactionIntent; use App\Http\Controllers\API\ApiController; use App\Http\Traits\ApiQueryable; +use App\Http\Traits\FiltersTransactions; use App\Jobs\RecurrentTransactionJob; use App\Models\RecurringTransactionRule; use App\Models\Transaction; @@ -25,6 +26,7 @@ class TransactionController extends ApiController { use ApiQueryable; + use FiltersTransactions; public function __construct( private RecurringTransactionService $recurringTransactionService, @@ -324,7 +326,7 @@ public function store(Request $request): JsonResponse 'group_id' => 'nullable|integer|exists:groups,id', 'party_id' => 'nullable|integer|exists:parties,id', 'wallet_id' => 'required|integer|exists:wallets,id', - 'categories' => 'nullable|array', + 'categories' => 'nullable|array|max:1', 'is_recurring' => 'nullable|boolean', 'recurrence_period' => 'nullable|string|in:daily,weekly,monthly,yearly', 'recurrence_interval' => 'nullable|integer|min:1', @@ -638,7 +640,7 @@ public function update(Request $request, $transactionId): JsonResponse 'party_id' => 'nullable|integer|exists:parties,id', 'wallet_id' => 'sometimes|integer|exists:wallets,id', 'group_id' => 'nullable|integer|exists:groups,id', - 'categories' => 'nullable|array', + 'categories' => 'nullable|array|max:1', 'categories.*' => 'integer|exists:categories,id', 'is_recurring' => 'nullable|boolean', 'recurrence_period' => 'nullable|string|in:daily,weekly,monthly,yearly', @@ -727,7 +729,7 @@ public function update(Request $request, $transactionId): JsonResponse } $user = $request->user(); - if (isset($request['client_id']) && ! $transaction->client_id) { + if (isset($request['client_id']) && ! $transaction->client_generated_id) { $transaction->setClientGeneratedId($request['client_id'], $user); } @@ -841,88 +843,4 @@ public function destroy($transactionId): JsonResponse return $this->success(['message' => __('Transaction deleted successfully')]); } - - /** - * Apply optional filtering query parameters (date range, wallets, - * categories, search) to the given transaction query. - */ - private function applyTransactionFilters($query, Request $request): void - { - if ($request->filled('date_from')) { - $query->whereDate('datetime', '>=', $request->query('date_from')); - } - - if ($request->filled('date_to')) { - $query->whereDate('datetime', '<=', $request->query('date_to')); - } - - $walletIds = $this->listParam($request, 'wallet_ids'); - if (! empty($walletIds)) { - $query->whereIn('wallet_id', $walletIds); - } - - $categoryIds = $this->listParam($request, 'category_ids'); - if (! empty($categoryIds)) { - $query->whereHas('categories', function ($q) use ($categoryIds) { - $q->whereIn('categories.id', $categoryIds); - }); - } - - $this->applyIntentFilters($query, $request); - - if ($request->filled('search')) { - $this->applySearchFilter($query, (string) $request->query('search')); - } - } - - private function applyIntentFilters($query, Request $request): void - { - $intents = array_values(array_intersect( - $this->listParam($request, 'intent'), - TransactionIntent::values() - )); - if (! empty($intents)) { - $query->whereIn('intent', $intents); - } - - if ($request->boolean('exclude_transfers')) { - $query->nonTransfer(); - } - } - - /** - * Parse a list query parameter that may arrive either as an array - * (key[]=a&key[]=b) or a comma-separated string (key=a,b), returning a - * trimmed list with empty entries removed. - */ - private function listParam(Request $request, string $key): array - { - $value = $request->query($key); - if ($value === null || $value === '') { - return []; - } - - $items = is_array($value) ? $value : explode(',', (string) $value); - - return array_values(array_filter( - array_map('trim', $items), - fn ($item) => $item !== '' - )); - } - - /** - * Apply a free-text search filter that matches against the description - * and, when the query contains a number, also the exact amount. - */ - private function applySearchFilter($query, string $search): void - { - $query->where(function ($q) use ($search) { - $q->where('description', 'LIKE', '%' . $search . '%'); - - $numeric = preg_replace('/[^0-9.]/', '', $search); - if ($numeric !== '' && is_numeric($numeric)) { - $q->orWhere('amount', $numeric); - } - }); - } } diff --git a/app/Http/Controllers/API/v1/TransferController.php b/app/Http/Controllers/API/v1/TransferController.php index 8d447f35..b963b132 100644 --- a/app/Http/Controllers/API/v1/TransferController.php +++ b/app/Http/Controllers/API/v1/TransferController.php @@ -223,7 +223,13 @@ public function store(Request $request): JsonResponse $exchangeRate = $data['exchange_rate']; } - $amountToReceive = bcmul($data['amount'], $exchangeRate); + // bcmul's default scale is 0, which would truncate the received amount + // to a whole number; 4 matches the amount and balance column scales. + $amountToReceive = bcmul(sprintf('%.8F', $data['amount']), sprintf('%.8F', $exchangeRate), 4); + + if (bccomp($amountToReceive, '0', 4) <= 0) { + return $this->failure(__('The amount received rounds to zero at this exchange rate'), 422); + } $datetime = isset($data['datetime']) ? format_iso8601_to_sql($data['datetime']) : null; diff --git a/app/Http/Controllers/API/v1/WalletController.php b/app/Http/Controllers/API/v1/WalletController.php index 71b9da70..15e9313f 100644 --- a/app/Http/Controllers/API/v1/WalletController.php +++ b/app/Http/Controllers/API/v1/WalletController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\API\v1; -use App\Contracts\Entitlements; +use Whilesmart\Entitlements\Contracts\Entitlements; use App\Http\Controllers\API\ApiController; use App\Http\Traits\ApiQueryable; use App\Models\Wallet; diff --git a/app/Http/Traits/FiltersTransactions.php b/app/Http/Traits/FiltersTransactions.php new file mode 100644 index 00000000..510af705 --- /dev/null +++ b/app/Http/Traits/FiltersTransactions.php @@ -0,0 +1,98 @@ +filled('date_from')) { + $query->whereDate('datetime', '>=', $request->query('date_from')); + } + + if ($request->filled('date_to')) { + $query->whereDate('datetime', '<=', $request->query('date_to')); + } + + $walletIds = $this->listParam($request, 'wallet_ids'); + if (! empty($walletIds)) { + $query->whereIn('wallet_id', $walletIds); + } + + $categoryIds = $this->listParam($request, 'category_ids'); + if (! empty($categoryIds)) { + $query->whereHas('categories', function ($q) use ($categoryIds) { + $q->whereIn('categories.id', $categoryIds); + }); + } + + $this->applyIntentFilters($query, $request); + + if ($request->filled('search')) { + $this->applySearchFilter($query, (string) $request->query('search')); + } + } + + protected function applyIntentFilters($query, Request $request): void + { + $intents = array_values(array_intersect( + $this->listParam($request, 'intent'), + TransactionIntent::values() + )); + if (! empty($intents)) { + $query->whereIn('intent', $intents); + } + + if ($request->boolean('exclude_transfers')) { + $query->nonTransfer(); + } + } + + /** + * Parse a list query parameter that may arrive either as an array + * (key[]=a&key[]=b) or a comma-separated string (key=a,b), returning a + * trimmed list with empty entries removed. + */ + protected function listParam(Request $request, string $key): array + { + $value = $request->query($key); + if ($value === null || $value === '') { + return []; + } + + $items = is_array($value) ? $value : explode(',', (string) $value); + + return array_values(array_filter( + array_map('trim', $items), + fn ($item) => $item !== '' + )); + } + + /** + * Apply a free-text search filter that matches against the description + * and, when the query contains a number, also the exact amount. + */ + protected function applySearchFilter($query, string $search): void + { + $query->where(function ($q) use ($search) { + $q->where('description', 'LIKE', '%' . $search . '%'); + + $numeric = preg_replace('/[^0-9.]/', '', $search); + if ($numeric !== '' && is_numeric($numeric)) { + $q->orWhere('amount', $numeric); + } + }); + } +} diff --git a/app/Http/Traits/ResolvesStatsQuery.php b/app/Http/Traits/ResolvesStatsQuery.php new file mode 100644 index 00000000..587b64ee --- /dev/null +++ b/app/Http/Traits/ResolvesStatsQuery.php @@ -0,0 +1,71 @@ +endOfDay(); + $startDate = Carbon::now()->subDays(30)->startOfDay(); + + if ($request->has('preset')) { + $endDate = Carbon::now()->endOfDay(); + + $startDate = match ($request->input('preset')) { + 'all_time' => Carbon::parse('2000-01-01')->startOfDay(), + 'current_week' => Carbon::now()->startOfWeek()->startOfDay(), + 'current_month' => Carbon::now()->startOfMonth()->startOfDay(), + 'last_3_months' => Carbon::now()->subMonths(3)->startOfDay(), + default => Carbon::now()->subDays(30)->startOfDay(), + }; + } else { + if ($request->has('start_date')) { + $startDate = Carbon::parse($request->input('start_date'))->startOfDay(); + } + if ($request->has('end_date')) { + $endDate = Carbon::parse($request->input('end_date'))->endOfDay(); + } + } + + return [$startDate, $endDate]; + } + + /** + * @return array|JsonResponse + */ + protected function resolveWalletIds(Request $request, $user): array|JsonResponse + { + if (! $request->has('wallet_ids')) { + return []; + } + + $walletIds = array_filter(array_map('intval', explode(',', $request->input('wallet_ids')))); + + $validWalletIds = Wallet::where('user_id', $user->id) + ->whereIn('id', $walletIds) + ->pluck('id') + ->toArray(); + + $invalidWalletIds = array_diff($walletIds, $validWalletIds); + if (! empty($invalidWalletIds)) { + return $this->failure(__('One or more wallet IDs are invalid or do not belong to the user.'), 422, [ + 'invalid_wallet_ids' => array_values($invalidWalletIds), + ]); + } + + return $walletIds; + } +} diff --git a/app/Jobs/ProcessChatMessageJob.php b/app/Jobs/ProcessChatMessageJob.php index fed9d385..cec3df31 100644 --- a/app/Jobs/ProcessChatMessageJob.php +++ b/app/Jobs/ProcessChatMessageJob.php @@ -2,7 +2,7 @@ namespace App\Jobs; -use App\Contracts\Entitlements; +use Whilesmart\Entitlements\Contracts\Entitlements; use App\Models\ChatMessage; use App\Services\AgentRunner; use Whilesmart\AgentMetrics\Facades\TokenMeter; diff --git a/app/Mcp/Tools/CreateWalletTool.php b/app/Mcp/Tools/CreateWalletTool.php index d884c8bf..7b9f2124 100644 --- a/app/Mcp/Tools/CreateWalletTool.php +++ b/app/Mcp/Tools/CreateWalletTool.php @@ -4,7 +4,7 @@ namespace App\Mcp\Tools; -use App\Contracts\Entitlements; +use Whilesmart\Entitlements\Contracts\Entitlements; use App\Mcp\Auth\McpGateRegistrar; use App\Models\Wallet; use Illuminate\Contracts\JsonSchema\JsonSchema; diff --git a/app/Models/Budget.php b/app/Models/Budget.php index 839691d8..c5ed5020 100644 --- a/app/Models/Budget.php +++ b/app/Models/Budget.php @@ -16,6 +16,10 @@ use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Database\Eloquent\SoftDeletes; use OpenApi\Attributes as OA; +use Whilesmart\Agents\Contracts\HasAgentResource; +use Whilesmart\Agents\Resources\AgentResource; +use Whilesmart\Agents\Resources\ResourceField; +use Whilesmart\Agents\Resources\ResourceRelationship; #[OA\Schema( schema: 'Budget', @@ -72,7 +76,7 @@ ], type: 'object' )] -class Budget extends Model +class Budget extends Model implements HasAgentResource { use HasClientCreatedAt; use HasFactory; @@ -237,4 +241,47 @@ public function scopeActive(Builder $query): Builder { return $query->where('is_active', true); } + + public static function agentResource(): AgentResource + { + return new AgentResource( + name: 'budgets', + model: self::class, + table: 'budgets', + description: 'Spending limits the user sets for a period, optionally targeting categories, groups or wallets.', + aliases: ['budget', 'spending limits', 'caps'], + labelColumn: 'name', + ownerKey: 'owner_id', + ownerParam: 'user_id', + ownerConstants: ['owner_type' => User::class], + readable: [ + ResourceField::key(), + ResourceField::string('name', 'Budget name'), + ResourceField::text('description', 'What the budget covers'), + ResourceField::decimal('amount', 'The spending limit for one period'), + ResourceField::string('currency', 'Currency of the limit'), + ResourceField::enum('period_type', self::PERIODS, 'How often the budget resets'), + ResourceField::date('start_date', 'First day the budget applies'), + ResourceField::date('end_date', 'Last day the budget applies, if it ends'), + ResourceField::boolean('rollover_enabled', 'Whether unspent money carries into the next period'), + ResourceField::integer('threshold_percent', 'Percentage used at which the user is warned'), + ResourceField::boolean('is_active', 'Whether the budget is currently in force'), + ResourceField::internal('owner_id'), + ResourceField::internal('owner_type', 'string'), + ResourceField::datetime('created_at'), + ], + relationships: [ + ResourceRelationship::hasMany('budget_period_states', 'budget_period_states', 'budget_id', 'Closed periods of a budget'), + ResourceRelationship::belongsToMany( + 'budget_targets', + 'categories', + 'budgetables', + 'budget_id', + 'budgetable_id', + 'Categories, groups or wallets a budget applies to', + ), + ], + writeEnabled: true, + ); + } } diff --git a/app/Models/BudgetPeriodState.php b/app/Models/BudgetPeriodState.php index 3d6c45b1..83c279de 100644 --- a/app/Models/BudgetPeriodState.php +++ b/app/Models/BudgetPeriodState.php @@ -7,6 +7,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use OpenApi\Attributes as OA; +use Whilesmart\Agents\Contracts\HasAgentResource; +use Whilesmart\Agents\Resources\AgentResource; +use Whilesmart\Agents\Resources\ResourceField; +use Whilesmart\Agents\Resources\ResourceRelationship; +use Whilesmart\Agents\Resources\ThroughScope; #[OA\Schema( schema: 'BudgetPeriodState', @@ -26,7 +31,7 @@ ], type: 'object' )] -class BudgetPeriodState extends Model +class BudgetPeriodState extends Model implements HasAgentResource { use HasFactory; use Syncable; @@ -59,4 +64,36 @@ public function budget(): BelongsTo { return $this->belongsTo(Budget::class); } + + public static function agentResource(): AgentResource + { + return new AgentResource( + name: 'budget_period_states', + model: self::class, + table: 'budget_period_states', + description: 'What a budget actually spent in a period once that period closed. Written by the system, never by the user.', + aliases: ['budget history', 'budget periods'], + ownerKey: null, + scopeThrough: new ThroughScope( + relation: 'budget', + resource: 'budgets', + column: 'budget_id', + references: 'budgets.id', + ), + readable: [ + ResourceField::key(), + ResourceField::reference('budget_id', 'budgets.id', 'Budget this period belongs to'), + ResourceField::date('period_start', 'First day of the period'), + ResourceField::date('period_end', 'Last day of the period'), + ResourceField::decimal('net_spent', 'Spending in the period after refunds'), + ResourceField::decimal('rollover_in', 'Unspent money carried in from the previous period'), + ResourceField::decimal('rollover_out', 'Unspent money carried out to the next period'), + ResourceField::datetime('closed_at', 'When the period was closed'), + ], + relationships: [ + ResourceRelationship::belongsTo('period_state_budget', 'budgets', 'budget_id'), + ], + orderColumn: 'period_start', + ); + } } diff --git a/app/Models/Category.php b/app/Models/Category.php index 0f2c20e6..7b8dd9d4 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -10,6 +10,9 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use OpenApi\Attributes as OA; +use Whilesmart\Agents\Contracts\HasAgentResource; +use Whilesmart\Agents\Resources\AgentResource; +use Whilesmart\Agents\Resources\ResourceField; #[OA\Schema( schema: 'Category', @@ -36,7 +39,7 @@ ], type: 'object' )] -class Category extends Model +class Category extends Model implements HasAgentResource { use HasClientCreatedAt; use HasFactory; @@ -75,4 +78,26 @@ public function sluggable(): array ], ]; } + + public static function agentResource(): AgentResource + { + return new AgentResource( + name: 'categories', + model: self::class, + table: 'categories', + description: 'Labels the user classifies transactions under.', + aliases: ['category', 'tags', 'labels'], + labelColumn: 'name', + ownerKey: 'user_id', + ownerBypassRoles: ['admin'], + readable: [ + ResourceField::key(), + ResourceField::string('name', 'Category name'), + ResourceField::enum('type', ['income', 'expense', 'invoice'], 'What the category applies to'), + ResourceField::text('description', 'What the category covers'), + ResourceField::internal('user_id'), + ], + readTool: false, + ); + } } diff --git a/app/Models/ExchangeRate.php b/app/Models/ExchangeRate.php index 1f4872fe..aa2c62f9 100644 --- a/app/Models/ExchangeRate.php +++ b/app/Models/ExchangeRate.php @@ -3,8 +3,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Whilesmart\Agents\Contracts\HasAgentResource; +use Whilesmart\Agents\Resources\AgentResource; +use Whilesmart\Agents\Resources\ResourceField; -class ExchangeRate extends Model +class ExchangeRate extends Model implements HasAgentResource { protected $fillable = [ 'base_currency', @@ -17,4 +20,25 @@ class ExchangeRate extends Model 'rate' => 'decimal:8', 'fetched_at' => 'datetime', ]; + + public static function agentResource(): AgentResource + { + return new AgentResource( + name: 'exchange_rates', + model: self::class, + table: 'exchange_rates', + description: 'Reference conversion rates between currencies. The same for every user.', + aliases: ['rates', 'fx', 'currency rates'], + ownerKey: null, + global: true, + readable: [ + ResourceField::key(), + ResourceField::string('base_currency', 'Currency being converted from'), + ResourceField::string('target_currency', 'Currency being converted to'), + ResourceField::decimal('rate', 'Target units per one base unit'), + ResourceField::datetime('fetched_at', 'When the rate was last refreshed'), + ], + orderColumn: 'fetched_at', + ); + } } diff --git a/app/Models/Group.php b/app/Models/Group.php index d6c21ebd..b69bd7cb 100644 --- a/app/Models/Group.php +++ b/app/Models/Group.php @@ -10,6 +10,9 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use OpenApi\Attributes as OA; +use Whilesmart\Agents\Contracts\HasAgentResource; +use Whilesmart\Agents\Resources\AgentResource; +use Whilesmart\Agents\Resources\ResourceField; #[OA\Schema( schema: 'Group', @@ -35,7 +38,7 @@ ], type: 'object' )] -class Group extends Model +class Group extends Model implements HasAgentResource { use HasClientCreatedAt; use HasFactory; @@ -73,4 +76,30 @@ public function user() { return $this->belongsTo(User::class); } + + public static function agentResource(): AgentResource + { + return new AgentResource( + name: 'groups', + model: self::class, + table: 'groups', + description: 'Groupings the user files transactions and budgets under, such as a household or a project.', + aliases: ['group', 'projects', 'households'], + labelColumn: 'name', + ownerKey: 'user_id', + readable: [ + ResourceField::key(), + ResourceField::string('name', 'Group name'), + ResourceField::string('slug', 'URL-safe form of the name'), + ResourceField::text('description', 'What the group covers'), + ResourceField::internal('user_id'), + ResourceField::datetime('created_at'), + ], + writable: [ + ResourceField::string('name', 'Group name', required: true, rules: 'required|string|max:255'), + ResourceField::text('description', 'What the group covers', rules: 'nullable|string'), + ], + writeEnabled: true, + ); + } } diff --git a/app/Models/Notification.php b/app/Models/Notification.php index 11215359..d174b0c6 100644 --- a/app/Models/Notification.php +++ b/app/Models/Notification.php @@ -8,6 +8,9 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use OpenApi\Attributes as OA; +use Whilesmart\Agents\Contracts\HasAgentResource; +use Whilesmart\Agents\Resources\AgentResource; +use Whilesmart\Agents\Resources\ResourceField; #[OA\Schema( schema: 'Notification', @@ -22,7 +25,7 @@ ], type: 'object' )] -class Notification extends Model +class Notification extends Model implements HasAgentResource { use HasFactory; use Syncable; @@ -82,4 +85,26 @@ public function scopeOfType($query, NotificationType $type) { return $query->where('type', $type); } + + public static function agentResource(): AgentResource + { + return new AgentResource( + name: 'notifications', + model: self::class, + table: 'notifications', + description: 'Messages Trakli has sent the user, such as a budget warning. Generated by the system.', + aliases: ['alerts', 'messages'], + labelColumn: 'title', + ownerKey: 'user_id', + readable: [ + ResourceField::key(), + ResourceField::string('type', 'What kind of notification it is'), + ResourceField::string('title', 'Notification headline'), + ResourceField::text('body', 'Notification text'), + ResourceField::datetime('read_at', 'When the user read it, empty if unread'), + ResourceField::internal('user_id'), + ResourceField::datetime('created_at', 'When it was sent'), + ], + ); + } } diff --git a/app/Models/Party.php b/app/Models/Party.php index 3e8b1827..3e13dadc 100644 --- a/app/Models/Party.php +++ b/app/Models/Party.php @@ -9,6 +9,9 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use OpenApi\Attributes as OA; +use Whilesmart\Agents\Contracts\HasAgentResource; +use Whilesmart\Agents\Resources\AgentResource; +use Whilesmart\Agents\Resources\ResourceField; #[OA\Schema( schema: 'Party', @@ -25,7 +28,7 @@ ], type: 'object' )] -class Party extends Model +class Party extends Model implements HasAgentResource { use HasClientCreatedAt; use HasFactory; @@ -46,4 +49,26 @@ class Party extends Model ]; protected $appends = ['last_synced_at', 'client_generated_id', 'icon']; + + public static function agentResource(): AgentResource + { + return new AgentResource( + name: 'parties', + model: self::class, + table: 'parties', + description: 'The people and businesses on the other side of a transaction.', + aliases: ['vendors', 'merchants', 'payees', 'payers', 'contacts'], + labelColumn: 'name', + ownerKey: 'user_id', + ownerBypassRoles: ['admin'], + readable: [ + ResourceField::key(), + ResourceField::string('name', 'Party name'), + ResourceField::string('type', 'How the party is classified'), + ResourceField::text('description', 'Notes about the party'), + ResourceField::internal('user_id'), + ], + readTool: false, + ); + } } diff --git a/app/Models/RecurringTransactionRule.php b/app/Models/RecurringTransactionRule.php index d12d3af5..34db4ccd 100644 --- a/app/Models/RecurringTransactionRule.php +++ b/app/Models/RecurringTransactionRule.php @@ -6,6 +6,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use OpenApi\Attributes as OA; +use Whilesmart\Agents\Contracts\HasAgentResource; +use Whilesmart\Agents\Resources\AgentResource; +use Whilesmart\Agents\Resources\ResourceField; +use Whilesmart\Agents\Resources\ResourceRelationship; +use Whilesmart\Agents\Resources\ThroughScope; #[OA\Schema( schema: 'RecurringTransactionRule', @@ -37,10 +42,12 @@ ], type: 'object' )] -class RecurringTransactionRule extends Model +class RecurringTransactionRule extends Model implements HasAgentResource { use HasFactory; + public const RECURRENCE_PERIODS = ['daily', 'weekly', 'monthly', 'yearly']; + protected $fillable = [ 'recurrence_period', 'recurrence_interval', @@ -52,10 +59,42 @@ class RecurringTransactionRule extends Model protected $casts = [ 'next_scheduled_at' => 'datetime', 'recurrence_ends_at' => 'datetime', + 'recurrence_interval' => 'integer', ]; public function transaction(): BelongsTo { return $this->belongsTo(Transaction::class); } + + public static function agentResource(): AgentResource + { + return new AgentResource( + name: 'recurring_rules', + model: self::class, + table: 'recurring_transaction_rules', + description: 'Rules that repeat a transaction on a schedule (rent, salary, subscriptions).', + aliases: ['recurring transactions', 'repeats', 'subscriptions', 'standing orders'], + ownerKey: null, + scopeThrough: new ThroughScope( + relation: 'transaction', + resource: 'transactions', + column: 'transaction_id', + references: 'transactions.id', + ), + readable: [ + ResourceField::key(), + ResourceField::reference('transaction_id', 'transactions.id', 'The transaction being repeated'), + ResourceField::enum('recurrence_period', self::RECURRENCE_PERIODS, 'Unit the rule repeats on'), + ResourceField::integer('recurrence_interval', 'How many periods between occurrences'), + ResourceField::datetime('next_scheduled_at', 'When the next occurrence is due'), + ResourceField::datetime('recurrence_ends_at', 'When the rule stops repeating'), + ], + relationships: [ + ResourceRelationship::belongsTo('recurring_rule_transaction', 'transactions', 'transaction_id'), + ], + writeEnabled: true, + orderColumn: 'next_scheduled_at', + ); + } } diff --git a/app/Models/Refund.php b/app/Models/Refund.php index 932c0707..4a79a277 100644 --- a/app/Models/Refund.php +++ b/app/Models/Refund.php @@ -7,6 +7,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use OpenApi\Attributes as OA; +use Whilesmart\Agents\Contracts\HasAgentResource; +use Whilesmart\Agents\Resources\AgentResource; +use Whilesmart\Agents\Resources\ResourceField; +use Whilesmart\Agents\Resources\ResourceRelationship; +use Whilesmart\Agents\Resources\ThroughScope; /** * A Refund row marks an income transaction as refunding money received @@ -28,7 +33,7 @@ ], type: 'object' )] -class Refund extends Model +class Refund extends Model implements HasAgentResource { use HasFactory; use Syncable; @@ -52,4 +57,33 @@ public function originalTransaction(): BelongsTo { return $this->belongsTo(Transaction::class, 'original_transaction_id'); } + + public static function agentResource(): AgentResource + { + return new AgentResource( + name: 'refunds', + model: self::class, + table: 'refunds', + description: 'Money returned for an earlier expense, linking the incoming transaction to the one it reverses.', + aliases: ['returns', 'reimbursements', 'money back'], + ownerKey: null, + scopeThrough: new ThroughScope( + relation: 'refundTransaction', + resource: 'transactions', + column: 'refund_transaction_id', + references: 'transactions.id', + ), + readable: [ + ResourceField::key(), + ResourceField::reference('refund_transaction_id', 'transactions.id', 'The income transaction carrying the refunded money'), + ResourceField::reference('original_transaction_id', 'transactions.id', 'The expense being refunded'), + ResourceField::datetime('created_at', 'When the refund was recorded'), + ], + relationships: [ + ResourceRelationship::belongsTo('refund_transaction', 'transactions', 'refund_transaction_id'), + ResourceRelationship::belongsTo('refunded_transaction', 'transactions', 'original_transaction_id'), + ], + writeEnabled: true, + ); + } } diff --git a/app/Models/Reminder.php b/app/Models/Reminder.php index 67745c18..aeb66bee 100644 --- a/app/Models/Reminder.php +++ b/app/Models/Reminder.php @@ -12,6 +12,9 @@ use Illuminate\Database\Eloquent\SoftDeletes; use OpenApi\Attributes as OA; use RRule\RRule; +use Whilesmart\Agents\Contracts\HasAgentResource; +use Whilesmart\Agents\Resources\AgentResource; +use Whilesmart\Agents\Resources\ResourceField; #[OA\Schema( schema: 'Reminder', @@ -51,7 +54,7 @@ enum: ['active', 'paused', 'snoozed', 'completed', 'cancelled'] ], type: 'object' )] -class Reminder extends Model +class Reminder extends Model implements HasAgentResource { use HasFactory; use SoftDeletes; @@ -203,4 +206,39 @@ public function scopeDue($query) ->whereNotNull('next_trigger_at') ->where('next_trigger_at', '<=', now()); } + + public static function agentResource(): AgentResource + { + return new AgentResource( + name: 'reminders', + model: self::class, + table: 'reminders', + description: 'Things the user asked to be reminded about, such as a bill due date.', + aliases: ['alerts', 'nudges', 'to-dos'], + labelColumn: 'title', + ownerKey: 'user_id', + readable: [ + ResourceField::key(), + ResourceField::string('title', 'What the reminder is about'), + ResourceField::text('description', 'Longer note attached to the reminder'), + ResourceField::string('type', 'What kind of reminder it is'), + ResourceField::string('status', 'Whether the reminder is active, done or dismissed'), + ResourceField::datetime('trigger_at', 'When the reminder first fires'), + ResourceField::datetime('due_at', 'When the thing being remembered is due'), + ResourceField::datetime('next_trigger_at', 'When it fires next'), + ResourceField::integer('priority', 'How important it is, higher is more urgent'), + ResourceField::internal('user_id'), + ResourceField::datetime('created_at'), + ], + writable: [ + ResourceField::string('title', 'What the reminder is about', required: true, rules: 'required|string|max:255'), + ResourceField::text('description', 'Longer note', rules: 'nullable|string'), + ResourceField::datetime('trigger_at', 'When it should fire, ISO 8601', rules: 'nullable|date'), + ResourceField::datetime('due_at', 'When the thing being remembered is due, ISO 8601', rules: 'nullable|date'), + ResourceField::integer('priority', 'How important it is, 0 is normal', rules: 'nullable|integer|min:0|max:255'), + ], + writeEnabled: true, + orderColumn: 'created_at', + ); + } } diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index e45a5643..ba443f15 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -14,6 +14,10 @@ use Illuminate\Database\Eloquent\SoftDeletes; use InvalidArgumentException; use OpenApi\Attributes as OA; +use Whilesmart\Agents\Contracts\HasAgentResource; +use Whilesmart\Agents\Resources\AgentResource; +use Whilesmart\Agents\Resources\ResourceField; +use Whilesmart\Agents\Resources\ResourceRelationship; use Whilesmart\UserDevices\Models\Device; #[OA\Schema( @@ -82,7 +86,7 @@ enum: ['income', 'expense'] ], type: 'object' )] -class Transaction extends Model +class Transaction extends Model implements HasAgentResource { use Groupable; use HasClientCreatedAt; @@ -129,7 +133,9 @@ protected static function boot() */ protected $casts = [ 'datetime' => 'datetime', - 'amount' => 'decimal:2', + // Matches the column scale; 2 would round sub-cent amounts + // (crypto transfer legs) in every response. + 'amount' => 'decimal:4', 'metadata' => 'array', ]; @@ -283,4 +289,45 @@ public function delete() return parent::delete(); } + + public static function agentResource(): AgentResource + { + return new AgentResource( + name: 'transactions', + model: self::class, + table: 'transactions', + description: 'Money in and out. An income or expense against one wallet.', + aliases: ['spending', 'expenses', 'income', 'payments', 'purchases'], + labelColumn: 'description', + ownerKey: 'user_id', + ownerBypassRoles: ['admin'], + readable: [ + ResourceField::key(), + ResourceField::decimal('amount', "Amount in the wallet's currency"), + ResourceField::enum('type', ['income', 'expense'], 'Whether money came in or went out'), + ResourceField::string( + 'intent', + 'What the movement is: regular, loan_received, loan_repayment, debt_owed, ' + . 'debt_settled, investment_buy, investment_return, gift', + ), + ResourceField::datetime('datetime', 'When the transaction happened'), + ResourceField::text('description', 'What it was for'), + ResourceField::reference('wallet_id', 'wallets.id', 'Wallet the money moved through'), + ResourceField::reference('party_id', 'parties.id', 'Who it was with'), + ResourceField::reference( + 'transfer_id', + 'transfers.id', + 'Set when this row is one leg of a wallet-to-wallet transfer rather than real income or spending', + ), + ResourceField::internal('user_id'), + ], + relationships: [ + ResourceRelationship::belongsTo('transaction_wallet', 'wallets', 'wallet_id', 'Each transaction belongs to a wallet'), + ResourceRelationship::belongsTo('transaction_party', 'parties', 'party_id', 'Each transaction may have a counterparty'), + ResourceRelationship::belongsTo('transaction_transfer', 'transfers', 'transfer_id', 'A transfer leg points at its transfer'), + ], + orderColumn: 'datetime', + readTool: false, + ); + } } diff --git a/app/Models/Transfer.php b/app/Models/Transfer.php index 0438cffd..42684a71 100644 --- a/app/Models/Transfer.php +++ b/app/Models/Transfer.php @@ -11,6 +11,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use OpenApi\Attributes as OA; +use Whilesmart\Agents\Contracts\HasAgentResource; +use Whilesmart\Agents\Resources\AgentResource; +use Whilesmart\Agents\Resources\ResourceField; +use Whilesmart\Agents\Resources\ResourceRelationship; #[OA\Schema( schema: 'Transfer', @@ -31,7 +35,7 @@ ], type: 'object' )] -class Transfer extends Model +class Transfer extends Model implements HasAgentResource { use HasClientCreatedAt; use HasFactory; @@ -124,4 +128,32 @@ public function transactions(): HasMany { return $this->hasMany(Transaction::class); } + + public static function agentResource(): AgentResource + { + return new AgentResource( + name: 'transfers', + model: self::class, + table: 'transfers', + description: "Money moved between two of the user's own wallets. Not income or expense.", + aliases: ['moves', 'wallet transfers'], + ownerKey: 'user_id', + ownerRelation: 'transfers', + readable: [ + ResourceField::key(), + ResourceField::decimal('amount', 'Amount taken from the source wallet'), + ResourceField::decimal('exchange_rate', 'Destination currency units per source unit'), + ResourceField::reference('from_wallet_id', 'wallets.id', 'Wallet the money left'), + ResourceField::reference('to_wallet_id', 'wallets.id', 'Wallet the money arrived in'), + ResourceField::datetime('datetime', 'When the transfer happened'), + ResourceField::internal('user_id'), + ResourceField::datetime('created_at', 'When the transfer was recorded'), + ], + relationships: [ + ResourceRelationship::belongsTo('transfer_source_wallet', 'wallets', 'from_wallet_id', 'Wallet the money left'), + ResourceRelationship::belongsTo('transfer_destination_wallet', 'wallets', 'to_wallet_id', 'Wallet the money arrived in'), + ], + orderColumn: 'datetime', + ); + } } diff --git a/app/Models/User.php b/app/Models/User.php index f5248b59..4efdd0be 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -14,11 +14,13 @@ use Whilesmart\ModelConfiguration\Traits\Configurable; use Whilesmart\Outreach\Traits\SendsOutreach; use Whilesmart\Roles\Traits\HasRoles; +use Whilesmart\AgentActions\Traits\HasAgentActions; use Whilesmart\UserDevices\Traits\HasDevices; class User extends Authenticatable implements HasLocalePreference { use Configurable; + use HasAgentActions; use HasApiTokens; use HasTokenUsage; use HasDevices; diff --git a/app/Models/Wallet.php b/app/Models/Wallet.php index 08dcae2a..d97fea2d 100644 --- a/app/Models/Wallet.php +++ b/app/Models/Wallet.php @@ -11,6 +11,9 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use OpenApi\Attributes as OA; +use Whilesmart\Agents\Contracts\HasAgentResource; +use Whilesmart\Agents\Resources\AgentResource; +use Whilesmart\Agents\Resources\ResourceField; #[OA\Schema( schema: 'Wallet', @@ -41,7 +44,7 @@ ], type: 'object' )] -class Wallet extends Model +class Wallet extends Model implements HasAgentResource { use HasClientCreatedAt; use HasFactory; @@ -99,4 +102,28 @@ public function transactions() { return $this->hasMany(Transaction::class); } + + public static function agentResource(): AgentResource + { + return new AgentResource( + name: 'wallets', + model: self::class, + table: 'wallets', + description: "The user's accounts: bank accounts, cash, credit cards, mobile money.", + aliases: ['accounts', 'bank accounts', 'cards'], + labelColumn: 'name', + ownerKey: 'user_id', + ownerBypassRoles: ['admin'], + readable: [ + ResourceField::key(), + ResourceField::string('name', 'Wallet name'), + ResourceField::decimal('balance', 'Current balance'), + ResourceField::string('currency', 'Currency code (USD, EUR, XAF, ...)'), + ResourceField::enum('type', ['bank', 'cash', 'credit_card', 'mobile'], 'What kind of account it is'), + ResourceField::text('description', 'Notes about the wallet'), + ResourceField::internal('user_id'), + ], + readTool: false, + ); + } } diff --git a/app/Providers/AiServiceProvider.php b/app/Providers/AiServiceProvider.php new file mode 100644 index 00000000..15f8a6c2 --- /dev/null +++ b/app/Providers/AiServiceProvider.php @@ -0,0 +1,43 @@ +app->booted(function (): void { + $resources = $this->app->make(ModelResourceRegistry::class); + $tools = $this->app->make(ToolRegistry::class); + + foreach ($resources->all() as $resource) { + $tool = new CreateResourceTool($resource); + + if ($this->needsGenericWriteTool($resource) && ! $tools->has($tool->name())) { + $tools->register($tool); + } + } + }); + } + + private function needsGenericWriteTool(AgentResource $resource): bool + { + return $resource->writeEnabled && $resource->writable !== []; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 235a822a..5da9e6ae 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,14 +2,12 @@ namespace App\Providers; -use App\Contracts\Entitlements; use App\Contracts\OwnerResolver; use App\Services\DocumentProcessorManager; use App\Services\DocumentProcessors\CsvProcessor; use App\Services\DocumentProcessors\RemoteDocumentProcessor; use App\Services\IntegrationRegistry; use App\Services\SchemaConformance\SchemaConformanceService; -use App\Support\AllowAllEntitlements; use App\Support\UserOwnerResolver; use Illuminate\Database\Events\MigrationsEnded; use Illuminate\Support\Carbon; @@ -40,8 +38,6 @@ public function register(): void \App\Holdings\CoingeckoPriceProvider::class ); - $this->app->singleton(Entitlements::class, AllowAllEntitlements::class); - $this->app->singleton(IntegrationRegistry::class); $this->app->singleton(DocumentProcessorManager::class, function ($app) { diff --git a/app/Services/AgentRunner.php b/app/Services/AgentRunner.php index 6cbc1718..28de930b 100644 --- a/app/Services/AgentRunner.php +++ b/app/Services/AgentRunner.php @@ -126,14 +126,23 @@ private function progressLabel(string $tool): ?string return __('Putting your report together'); } + // Every generated list_* read tool looks the same to the user, so one + // label covers them and new resources need no change here. + if (str_starts_with($tool, 'list_')) { + return __('Checking your accounts'); + } + + if (str_starts_with($tool, 'create_') || str_starts_with($tool, 'record_')) { + return __('Preparing your changes'); + } + return match ($tool) { 'smartql.query' => __('Looking through your records'), 'get_stats' => __('Crunching the numbers'), - 'list_wallets', 'list_categories', 'list_parties' => __('Checking your accounts'), - 'get_exchange_rate', 'get_asset_price' => __('Fetching current rates'), + 'get_exchange_rate', 'get_asset_price', 'convert_currency' => __('Fetching current rates'), + 'get_user_defaults' => __('Checking your settings'), 'calculator' => __('Working out the figures'), - 'record_transaction', 'record_transfer', 'create_wallet', - 'create_category', 'create_party', 'categorize_transaction', + 'categorize_transactions', 'assign_transaction_categories', 'attach_to_transaction' => __('Preparing your changes'), 'import_document', 'extract_receipt' => __('Reading your document'), default => null, diff --git a/app/Services/BudgetTargetResolver.php b/app/Services/BudgetTargetResolver.php new file mode 100644 index 00000000..553598a6 --- /dev/null +++ b/app/Services/BudgetTargetResolver.php @@ -0,0 +1,106 @@ + 'categories', + 'group' => 'groups', + 'wallet' => 'wallets', + ]; + + /** + * Normalise targets to [['type' => ..., 'id' => ...], ...]. + * + * @param array|string> $targets + * @return array + */ + public function resolve(User $user, array $targets): array + { + $resolved = []; + + foreach ($targets as $target) { + if (! is_array($target)) { + throw new InvalidArgumentException('Each budget target needs a type (category, group or wallet) and a name or id.'); + } + + $type = strtolower(trim((string) ($target['type'] ?? ''))); + + if (! isset(self::RELATIONS[$type])) { + throw new InvalidArgumentException("Budget targets must be a category, group or wallet; \"{$type}\" is none of those."); + } + + $resolved[] = ['type' => $type, 'id' => $this->resolveId($user, $type, $target)]; + } + + return $resolved; + } + + /** + * Attach resolved targets to a budget. + * + * @param array $targets + */ + public function apply(Budget $budget, array $targets): void + { + $byRelation = []; + + foreach ($targets as $target) { + $byRelation[self::RELATIONS[$target['type']]][] = $target['id']; + } + + foreach ($byRelation as $relation => $ids) { + $budget->{$relation}()->syncWithoutDetaching($ids); + } + + if ($byRelation !== []) { + // sync() leaves the parent alone, so a budget whose only change is + // its targets would keep a stale updated_at. + $budget->touch(); + } + } + + /** + * @param array $target + */ + private function resolveId(User $user, string $type, array $target): int + { + $relation = self::RELATIONS[$type]; + + if (! empty($target['id'])) { + $targetId = (int) $target['id']; + + if (! $user->{$relation}()->whereKey($targetId)->exists()) { + throw new InvalidArgumentException("That {$type} does not belong to you."); + } + + return $targetId; + } + + $name = trim((string) ($target['name'] ?? '')); + + if ($name === '') { + throw new InvalidArgumentException("Each budget target needs a {$type} name or id."); + } + + $record = $user->{$relation}()->whereRaw('LOWER(name) = ?', [mb_strtolower($name)])->first(); + + if ($record === null) { + throw new InvalidArgumentException("No {$type} named \"{$name}\" was found. Confirm it with the user or create it first."); + } + + return (int) $record->id; + } +} diff --git a/app/Services/ChatActionBlocks.php b/app/Services/ChatActionBlocks.php new file mode 100644 index 00000000..6e91cd05 --- /dev/null +++ b/app/Services/ChatActionBlocks.php @@ -0,0 +1,179 @@ +metadata['chat_message_id'] ?? null); + if ($message === null) { + return; + } + + $patch = fn (array $block): array => $this->applyStatus($block, $action, $status, $described); + $changed = $this->rewriteBlocks($message, fn (array $block): array => $this->markInBlock($block, (int) $action->id, $patch)); + + // The batch card's own status is derived from its members, so it has to + // be recomputed once one of them changes underneath it. + if ($changed && $action->batch) { + $this->syncBatch((string) $action->batch); + } + } + + /** + * Rewrite a batch card from the ledger: each member's status and the card's + * own rolled-up status. + */ + public function syncBatch(string $batch): void + { + $actions = AgentProposedAction::query()->forBatch($batch)->get()->keyBy('id'); + $message = ChatMessage::find($actions->first()?->metadata['chat_message_id'] ?? null); + if ($message === null) { + return; + } + + $this->rewriteBlocks($message, fn (array $block): array => $this->syncBatchInBlock($block, $batch, $actions)); + } + + /** + * Run a mutator over every block on a message, persisting once if any block + * changed. Comparing by value keeps each mutator simple and side-effect-free. + */ + private function rewriteBlocks(ChatMessage $message, callable $mutator): bool + { + $result = $message->result ?? []; + $blocks = $result['blocks'] ?? []; + + if (! is_array($blocks)) { + return false; + } + + $changed = false; + foreach ($blocks as $i => $block) { + if (! is_array($block)) { + continue; + } + $next = $mutator($block); + if ($next !== $block) { + $blocks[$i] = $next; + $changed = true; + } + } + + if ($changed) { + $result['blocks'] = $blocks; + $message->update(['result' => $result]); + } + + return $changed; + } + + /** + * @param array $block + * @return array + */ + private function markInBlock(array $block, int $actionId, callable $patch): array + { + $type = $block['type'] ?? null; + + if ($type === 'proposed_action' && (int) ($block['id'] ?? 0) === $actionId) { + return $patch($block); + } + + if ($type === 'proposed_action_batch' && is_array($block['actions'] ?? null)) { + $block['actions'] = array_map( + fn ($member) => is_array($member) && (int) ($member['id'] ?? 0) === $actionId ? $patch($member) : $member, + $block['actions'] + ); + } + + return $block; + } + + /** + * @param array $block + * @param Collection $actions + * @return array + */ + private function syncBatchInBlock(array $block, string $batch, Collection $actions): array + { + if (($block['type'] ?? null) !== 'proposed_action_batch' || ($block['batch'] ?? null) !== $batch) { + return $block; + } + + if (is_array($block['actions'] ?? null)) { + $block['actions'] = array_map(function ($member) use ($actions) { + $current = is_array($member) ? $actions->get((int) ($member['id'] ?? 0)) : null; + if ($current !== null) { + $member['status'] = $current->status->value; + } + + return $member; + }, $block['actions']); + } + + $block['status'] = $this->batchStatus($actions); + + return $block; + } + + /** + * @param array $block + * @return array + */ + private function applyStatus(array $block, AgentProposedAction $action, ActionStatus $status, ?array $described): array + { + $block['status'] = $status->value; + + if ($described !== null) { + $block['summary'] = $described['summary']; + $block['fields'] = $described['fields']; + $block['payload'] = $action->payload; + } + + return $block; + } + + /** + * A batch is settled only once no member is still awaiting a decision. + * + * @param Collection $actions + */ + private function batchStatus(Collection $actions): string + { + $statuses = $actions->map(fn (AgentProposedAction $action): ActionStatus => $action->status); + + if ($statuses->contains(ActionStatus::Proposed) || $statuses->contains(ActionStatus::Confirmed)) { + return ActionStatus::Proposed->value; + } + + if ($statuses->contains(ActionStatus::Executed)) { + return ActionStatus::Executed->value; + } + + if ($statuses->every(fn (ActionStatus $status): bool => $status === ActionStatus::Rejected)) { + return ActionStatus::Rejected->value; + } + + return ActionStatus::Failed->value; + } +} diff --git a/app/Services/FileImportService.php b/app/Services/FileImportService.php index b23b98f9..18330719 100644 --- a/app/Services/FileImportService.php +++ b/app/Services/FileImportService.php @@ -89,8 +89,8 @@ public function processImports(string $path, FileImport $fileImport): void ); Log::error($e); } - } elseif ($transactionType == '+Transfer') { - if (isset($csvData[$i + 1]) && $csvData[$i + 1][2] == '-Transfer') { + } elseif ($transactionType == '+transfer') { + if (isset($csvData[$i + 1]) && strtolower(trim($csvData[$i + 1][2])) == '-transfer') { try { $this->importTransfer($data, $csvData[$i + 1], $user); } catch (FileImportException $e) { @@ -190,6 +190,9 @@ public function importTransaction( // get the data we need $amount = floatval($data[0]); + if ($amount <= 0) { + throw new FileImportException(__('Amount must be a number greater than zero')); + } $currency = $data[1]; $party = $data[3]; $wallet = $data[4]; @@ -282,6 +285,11 @@ public function importTransactionFromConfirm( bool $linkFee = false, ): void { DB::transaction(function () use ($merged, $transactionType, $user, $autoCreateWallets, $autoCreateParties, $autoCreateCategories, $linkFee) { + $amount = (float) ($merged['amount'] ?? 0); + if ($amount <= 0) { + throw new FileImportException(__('Amount must be a number greater than zero')); + } + $wallet = $this->resolveWalletForConfirm( $user, $merged['wallet_id'] ?? null, @@ -304,7 +312,7 @@ public function importTransactionFromConfirm( ); $transaction = $user->transactions()->create([ - 'amount' => (float) ($merged['amount'] ?? 0), + 'amount' => $amount, 'description' => $merged['description'] ?? null, 'datetime' => $merged['date'] ?? null, 'type' => $transactionType, diff --git a/app/Services/ProposedActionExecutor.php b/app/Services/ProposedActionExecutor.php index be45366b..8e730557 100644 --- a/app/Services/ProposedActionExecutor.php +++ b/app/Services/ProposedActionExecutor.php @@ -3,6 +3,8 @@ namespace App\Services; use App\Models\AgentProposedAction; +use App\Models\Budget; +use App\Models\Transaction; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\DB; @@ -16,8 +18,11 @@ */ class ProposedActionExecutor { - public function __construct(protected TransactionWriter $writer, protected TransferService $transfers) - { + public function __construct( + protected TransactionWriter $writer, + protected TransferService $transfers, + protected BudgetTargetResolver $budgetTargets, + ) { } public function execute(AgentProposedAction $action): Model @@ -30,10 +35,80 @@ public function execute(AgentProposedAction $action): Model 'wallet.create' => $this->createOwned($action, $action->owner->wallets()), 'category.create' => $this->createOwned($action, $action->owner->categories()), 'party.create' => $this->createOwned($action, $action->owner->parties()), + 'group.create' => $this->createOwned($action, $action->owner->groups()), + 'reminder.create' => $this->createOwned($action, $action->owner->reminders()), + 'budget.create' => $this->createBudget($action), + 'recurring_rule.create' => $this->createRecurringRule($action), + 'refund.create' => $this->createRefund($action), default => throw new RuntimeException("Unsupported action type: {$action->action_type}"), }); } + /** + * Create a budget and attach its targets. Targets are re-resolved against + * the owner here rather than trusted from the payload, because the user may + * have edited it between proposal and confirmation. + */ + private function createBudget(AgentProposedAction $action): Model + { + $user = $action->owner; + $payload = $action->payload; + $targets = $payload['targets'] ?? []; + unset($payload['targets']); + + /** @var Budget $budget */ + $budget = $user->budgets()->create($payload); + + if ($targets !== []) { + $this->budgetTargets->apply($budget, $this->budgetTargets->resolve($user, $targets)); + } + + $budget->setClientGeneratedId($action->idempotency_key, $user); + $budget->markAsSynced(); + + return $budget; + } + + /** + * Attach a recurrence rule to one of the owner's transactions. The rule + * hangs off the transaction, so the transaction is what proves ownership. + */ + private function createRecurringRule(AgentProposedAction $action): Model + { + $user = $action->owner; + $payload = $action->payload; + + /** @var Transaction $transaction */ + $transaction = $user->transactions()->findOrFail($payload['transaction_id']); + unset($payload['transaction_id']); + + return $transaction->recurringTransactionRule()->updateOrCreate([], $payload); + } + + /** + * Link an income transaction to the expense it reverses. Both sides are + * looked up through the owner, so a refund can never point at someone + * else's transaction. + */ + private function createRefund(AgentProposedAction $action): Model + { + $user = $action->owner; + $payload = $action->payload; + + /** @var Transaction $refundTransaction */ + $refundTransaction = $user->transactions()->findOrFail($payload['refund_transaction_id']); + + $original = empty($payload['original_transaction_id']) + ? null + : $user->transactions()->findOrFail($payload['original_transaction_id']); + + $refund = $refundTransaction->markAsRefund($original); + $refund->setClientGeneratedId($action->idempotency_key, $user); + $refund->markAsSynced(); + + return $refund; + } + private function createTransfer(AgentProposedAction $action): Model { $user = $action->owner; diff --git a/app/Services/ProposedActionOverrides.php b/app/Services/ProposedActionOverrides.php new file mode 100644 index 00000000..6655cd8b --- /dev/null +++ b/app/Services/ProposedActionOverrides.php @@ -0,0 +1,204 @@ + + */ + public function allowedKeys(string $actionType): array + { + return match ($actionType) { + 'transaction.create' => ['amount', 'type', 'wallet_id', 'party_id', 'description', 'datetime', 'categories'], + 'transaction.categorize' => ['categories'], + 'transfer.create' => ['amount', 'from_wallet_id', 'to_wallet_id', 'exchange_rate', 'datetime'], + 'wallet.create' => ['name', 'type', 'currency', 'description'], + 'category.create' => ['name', 'type', 'description'], + 'party.create' => ['name', 'type', 'description'], + 'group.create' => ['name', 'description'], + 'reminder.create' => ['title', 'description', 'trigger_at', 'due_at', 'priority'], + 'budget.create' => [ + 'name', 'amount', 'currency', 'period_type', 'start_date', 'end_date', + 'description', 'threshold_percent', 'rollover_enabled', 'targets', + ], + 'recurring_rule.create' => ['recurrence_period', 'recurrence_interval', 'next_scheduled_at', 'recurrence_ends_at'], + // The transaction a rule or refund hangs off is what proves the user + // owns it, so it is deliberately not editable at confirm time. + 'refund.create' => ['original_transaction_id'], + default => [], + }; + } + + /** + * Merge the caller's edits into a proposal, keeping only allowed keys. + * + * @param array $payload + * @param array $overrides + * @return array + */ + public function merge(string $actionType, array $payload, array $overrides): array + { + $allowed = array_flip($this->allowedKeys($actionType)); + + return array_merge($payload, array_intersect_key($overrides, $allowed)); + } + + /** + * @param array $payload + * + * @throws HttpException when an edit would make the action invalid or unauthorized. + */ + public function revalidate(User $user, string $actionType, array $payload): void + { + if (in_array($actionType, ['transaction.create', 'transaction.categorize'], true)) { + $this->checkTransaction($user, $payload); + } + + match ($actionType) { + 'transfer.create' => $this->checkTransfer($user, $payload), + 'budget.create' => $this->checkBudget($user, $payload), + 'recurring_rule.create' => $this->checkRecurringRule($payload), + 'refund.create' => $this->checkRefund($user, $payload), + 'reminder.create' => $this->checkReminder($payload), + default => null, + }; + } + + /** + * @param array $payload + */ + private function checkTransaction(User $user, array $payload): void + { + app(TransactionWriter::class)->validateOwnership($user, $payload, $payload['categories'] ?? []); + + if (isset($payload['amount']) && (float) $payload['amount'] <= 0) { + $this->reject('Amount must be greater than zero.'); + } + if (isset($payload['type']) && ! in_array($payload['type'], ['income', 'expense'], true)) { + $this->reject('Type must be income or expense.'); + } + } + + /** + * @param array $payload + */ + private function checkTransfer(User $user, array $payload): void + { + foreach (['from_wallet_id', 'to_wallet_id'] as $key) { + if (! empty($payload[$key]) && ! $user->wallets()->whereKey($payload[$key])->exists()) { + $this->deny('The selected wallet does not belong to you.'); + } + } + if (! empty($payload['from_wallet_id']) && $payload['from_wallet_id'] === ($payload['to_wallet_id'] ?? null)) { + $this->reject('The source and destination wallets must be different.'); + } + if (isset($payload['amount']) && (float) $payload['amount'] <= 0) { + $this->reject('Amount must be greater than zero.'); + } + if (isset($payload['exchange_rate']) && (float) $payload['exchange_rate'] <= 0) { + $this->reject('Exchange rate must be greater than zero.'); + } + } + + /** + * @param array $payload + */ + private function checkBudget(User $user, array $payload): void + { + if (isset($payload['amount']) && (float) $payload['amount'] <= 0) { + $this->reject('The budget amount must be greater than zero.'); + } + if (isset($payload['period_type']) && ! in_array($payload['period_type'], Budget::PERIODS, true)) { + $this->reject('The period must be one of ' . implode(', ', Budget::PERIODS) . '.'); + } + if (! empty($payload['end_date']) && ! empty($payload['start_date']) && $payload['end_date'] < $payload['start_date']) { + $this->reject('The end date cannot be before the start date.'); + } + + // Edited targets could name another user's records; resolving them is + // what proves they do not. + if (! empty($payload['targets'])) { + try { + app(BudgetTargetResolver::class)->resolve($user, $payload['targets']); + } catch (InvalidArgumentException $e) { + $this->reject($e->getMessage()); + } + } + } + + /** + * @param array $payload + */ + private function checkRecurringRule(array $payload): void + { + $periods = RecurringTransactionRule::RECURRENCE_PERIODS; + + if (isset($payload['recurrence_period']) && ! in_array($payload['recurrence_period'], $periods, true)) { + $this->reject('The recurrence must be one of ' . implode(', ', $periods) . '.'); + } + if (isset($payload['recurrence_interval']) && (int) $payload['recurrence_interval'] < 1) { + $this->reject('The interval must be at least 1.'); + } + } + + /** + * @param array $payload + */ + private function checkRefund(User $user, array $payload): void + { + if (empty($payload['original_transaction_id'])) { + return; + } + + $original = $user->transactions()->find($payload['original_transaction_id']); + + if ($original === null) { + $this->deny('That transaction does not belong to you.'); + } + if ($original->type !== TransactionType::EXPENSE->value) { + $this->reject('The transaction being refunded must be an expense.'); + } + } + + /** + * @param array $payload + */ + private function checkReminder(array $payload): void + { + if (array_key_exists('title', $payload) && trim((string) $payload['title']) === '') { + $this->reject('A reminder needs a title.'); + } + } + + private function reject(string $message): never + { + throw new HttpException(Response::HTTP_UNPROCESSABLE_ENTITY, $message); + } + + private function deny(string $message): never + { + throw new HttpException(Response::HTTP_FORBIDDEN, $message); + } +} diff --git a/app/Support/AllowAllEntitlements.php b/app/Support/AllowAllEntitlements.php deleted file mode 100644 index 4e6c0c8f..00000000 --- a/app/Support/AllowAllEntitlements.php +++ /dev/null @@ -1,32 +0,0 @@ - now(), ]); }); + + // Only soft deletes leave a row behind to sync; a hard delete would + // just recreate an orphaned sync state here. + static::deleted(function ($model) { + if (method_exists($model, 'isForceDeleting') && ! $model->isForceDeleting()) { + $model->markAsSynced(); + } + }); } public function syncState() diff --git a/composer.json b/composer.json index 5503ead6..a0c3a1c1 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "trakli/webservice", - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "type": "project", "description": "Trakli Webservice", "keywords": [ @@ -11,22 +11,25 @@ "license": "proprietary", "require": { "php": "^8.2", + "barryvdh/laravel-dompdf": "^3.1", "cviebrock/eloquent-sluggable": "^11.0", "guzzlehttp/guzzle": "^7.2", "laravel/framework": "^11.45.1", + "laravel/mcp": "^0.8", "laravel/reverb": "^1.10", "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0", - "laravel/mcp": "^0.8", + "phpoffice/phpspreadsheet": "^5.9", "prism-php/prism": "^0.100.1", "rlanvin/php-rrule": "^2.6", "smartpings/php-sdk": "dev-main", "whilesmart/eloquent-activities": "^1.0", - "whilesmart/eloquent-agent-actions": "dev-dev", + "whilesmart/eloquent-agent-actions": "^1.0", "whilesmart/eloquent-agent-metrics": "^1.0", - "whilesmart/eloquent-agents": "dev-main", + "whilesmart/eloquent-agents": "^1.0", "whilesmart/eloquent-engagement": "dev-dev", - "whilesmart/eloquent-holdings": "^1.1", + "whilesmart/eloquent-entitlements": "^2.0", + "whilesmart/eloquent-holdings": "^1.2", "whilesmart/eloquent-model-configuration": "^1.0.9", "whilesmart/eloquent-outreach": "dev-dev", "whilesmart/eloquent-roles": "dev-dev", diff --git a/composer.lock b/composer.lock index 46bf3e29..295844f5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,85 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f8628a919636e715388549d7466ebfb0", + "content-hash": "49f959d2e2b0512279fe0d62fbd85c96", "packages": [ + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.1.2", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11|^12|^13.0", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7|^3.0", + "orchestra/testbench": "^7|^8|^9.16|^10|^11.0", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2026-02-21T08:51:10+00:00" + }, { "name": "beste/clock", "version": "3.0.0", @@ -541,6 +618,85 @@ }, "time": "2025-11-27T18:57:36+00:00" }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, { "name": "cuyz/valinor", "version": "2.3.2", @@ -933,6 +1089,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.6", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.6" + }, + "time": "2026-07-20T12:29:38+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -4063,6 +4374,258 @@ ], "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.1.2", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.2" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.16", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^11.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-01-27T12:07:53+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.10.1", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" + }, + "time": "2026-06-23T18:43:15+00:00" + }, { "name": "moneyphp/money", "version": "v4.9.0", @@ -4817,37 +5380,146 @@ "php": ">= 7" }, "require-dev": { - "phpunit/phpunit": "4.*|5.*", - "vimeo/psalm": "^1" + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "phpoffice/phpspreadsheet", + "version": "5.9.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339", + "reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-filter": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^8.2", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^2.0 || ^3.0", + "ext-intl": "*", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.5", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1 || ^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": "^10.5 || ^11.0", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" }, "suggest": { - "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" }, "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com", - "homepage": "https://paragonie.com" + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + }, + { + "name": "Owen Leibman" } ], - "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", "keywords": [ - "csprng", - "polyfill", - "pseudorandom", - "random" + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" ], "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/random_compat/issues", - "source": "https://github.com/paragonie/random_compat" + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0" }, - "time": "2020-10-15T08:29:30+00:00" + "time": "2026-07-12T19:17:39+00:00" }, { "name": "phpoption/phpoption", @@ -6660,6 +7332,86 @@ }, "time": "2025-04-25T07:40:09+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.4.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.33 || 2.2.2", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.4.6", + "rector/type-perfect": "1.0.0 || 2.1.3", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.5.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" + }, + "time": "2026-06-18T15:10:53+00:00" + }, { "name": "smartpings/php-sdk", "version": "dev-main", @@ -9556,6 +10308,149 @@ ], "time": "2026-03-24T13:12:05+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", @@ -9836,16 +10731,16 @@ }, { "name": "whilesmart/eloquent-agent-actions", - "version": "dev-dev", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/whilesmartphp/eloquent-agent-actions.git", - "reference": "b787b5fba6982aceb46abad40b11d93e51bcf06e" + "reference": "3cbffe23bf6fea3ff6f5fd6231fa5c3f4ee8c153" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/whilesmartphp/eloquent-agent-actions/zipball/b787b5fba6982aceb46abad40b11d93e51bcf06e", - "reference": "b787b5fba6982aceb46abad40b11d93e51bcf06e", + "url": "https://api.github.com/repos/whilesmartphp/eloquent-agent-actions/zipball/3cbffe23bf6fea3ff6f5fd6231fa5c3f4ee8c153", + "reference": "3cbffe23bf6fea3ff6f5fd6231fa5c3f4ee8c153", "shasum": "" }, "require": { @@ -9859,7 +10754,6 @@ "laravel/pint": "^1.22", "orchestra/testbench": "^9.0|^10.0" }, - "default-branch": true, "type": "library", "extra": { "laravel": { @@ -9886,9 +10780,9 @@ "description": "DB-tracked agent action ledger with a handler registry and scheduler for Laravel applications.", "support": { "issues": "https://github.com/whilesmartphp/eloquent-agent-actions/issues", - "source": "https://github.com/whilesmartphp/eloquent-agent-actions/tree/dev" + "source": "https://github.com/whilesmartphp/eloquent-agent-actions/tree/v1.0.0" }, - "time": "2026-07-13T16:50:13+00:00" + "time": "2026-07-15T17:58:58+00:00" }, { "name": "whilesmart/eloquent-agent-metrics", @@ -9946,16 +10840,16 @@ }, { "name": "whilesmart/eloquent-agents", - "version": "dev-main", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/whilesmartphp/eloquent-agents.git", - "reference": "8b22831d73002d995a7c65d670084c87d21862fe" + "reference": "90786e825e70c15b242e44b22950e9149ef9e585" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/whilesmartphp/eloquent-agents/zipball/8b22831d73002d995a7c65d670084c87d21862fe", - "reference": "8b22831d73002d995a7c65d670084c87d21862fe", + "url": "https://api.github.com/repos/whilesmartphp/eloquent-agents/zipball/90786e825e70c15b242e44b22950e9149ef9e585", + "reference": "90786e825e70c15b242e44b22950e9149ef9e585", "shasum": "" }, "require": { @@ -9969,7 +10863,6 @@ "nunomaduro/collision": "^8.0", "orchestra/testbench": "^9.0|^10.0" }, - "default-branch": true, "type": "library", "extra": { "laravel": { @@ -10012,10 +10905,10 @@ ], "description": "AI tool-calling foundation for Laravel: ready-made tools, an agent harness, and an extension API on top of Prism", "support": { - "source": "https://github.com/whilesmartphp/eloquent-agents/tree/main", + "source": "https://github.com/whilesmartphp/eloquent-agents/tree/v1.1.0", "issues": "https://github.com/whilesmartphp/eloquent-agents/issues" }, - "time": "2026-07-09T14:18:27+00:00" + "time": "2026-07-29T22:00:43+00:00" }, { "name": "whilesmart/eloquent-engagement", @@ -10074,18 +10967,72 @@ }, "time": "2026-06-27T15:47:35+00:00" }, + { + "name": "whilesmart/eloquent-entitlements", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/whilesmartphp/eloquent-entitlements.git", + "reference": "66be36b3f07a09861bb7ee41acc409496d5e6bed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/whilesmartphp/eloquent-entitlements/zipball/66be36b3f07a09861bb7ee41acc409496d5e6bed", + "reference": "66be36b3f07a09861bb7ee41acc409496d5e6bed", + "shasum": "" + }, + "require": { + "laravel/framework": "^11.0|^12.0", + "php": "^8.2", + "whilesmart/eloquent-owner-access": "^1.0" + }, + "require-dev": { + "fakerphp/faker": "^1.24", + "laravel/pint": "^1.22", + "orchestra/testbench": "^9.0|^10.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Whilesmart\\Entitlements\\EntitlementsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Whilesmart\\Entitlements\\": "src/", + "Whilesmart\\Entitlements\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Whilesmart Team" + } + ], + "description": "Provider-neutral feature gating, plan limits and metered usage for Laravel, scoped per polymorphic owner. Ships an allow-all default so self-hosting stays free; hosts rebind to enforce.", + "support": { + "issues": "https://github.com/whilesmartphp/eloquent-entitlements/issues", + "source": "https://github.com/whilesmartphp/eloquent-entitlements/tree/v2.0.0" + }, + "time": "2026-07-19T12:33:11+00:00" + }, { "name": "whilesmart/eloquent-holdings", - "version": "1.1.0", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/whilesmartphp/eloquent-holdings.git", - "reference": "9aa77fe5deb6da0d08d7c2234ffa1b03a888afd2" + "reference": "c921cf5d696bde6d71784f17a8b9d3e1fdd606d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/whilesmartphp/eloquent-holdings/zipball/9aa77fe5deb6da0d08d7c2234ffa1b03a888afd2", - "reference": "9aa77fe5deb6da0d08d7c2234ffa1b03a888afd2", + "url": "https://api.github.com/repos/whilesmartphp/eloquent-holdings/zipball/c921cf5d696bde6d71784f17a8b9d3e1fdd606d5", + "reference": "c921cf5d696bde6d71784f17a8b9d3e1fdd606d5", "shasum": "" }, "require": { @@ -10124,9 +11071,9 @@ "description": "Polymorphic holdings register (crypto, stocks, property, any asset) for Laravel: track quantity and value, with pluggable live pricing.", "support": { "issues": "https://github.com/whilesmartphp/eloquent-holdings/issues", - "source": "https://github.com/whilesmartphp/eloquent-holdings/tree/v1.1.0" + "source": "https://github.com/whilesmartphp/eloquent-holdings/tree/v1.2.0" }, - "time": "2026-07-04T21:10:17+00:00" + "time": "2026-07-22T12:38:29+00:00" }, { "name": "whilesmart/eloquent-model-configuration", @@ -10671,85 +11618,6 @@ } ], "packages-dev": [ - { - "name": "composer/pcre", - "version": "3.3.2", - "source": { - "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<1.11.10" - }, - "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" - }, - "type": "library", - "extra": { - "phpstan": { - "includes": [ - "extension.neon" - ] - }, - "branch-alias": { - "dev-main": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Pcre\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "PCRE wrapping library that offers type-safe preg_* replacements.", - "keywords": [ - "PCRE", - "preg", - "regex", - "regular expression" - ], - "support": { - "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-11-12T16:29:46+00:00" - }, { "name": "composer/xdebug-handler", "version": "3.0.5", @@ -13967,8 +14835,6 @@ "stability-flags": { "phpmd/phpmd": 0, "smartpings/php-sdk": 20, - "whilesmart/eloquent-agent-actions": 20, - "whilesmart/eloquent-agents": 20, "whilesmart/eloquent-engagement": 20, "whilesmart/eloquent-outreach": 20, "whilesmart/eloquent-roles": 20 diff --git a/config/agents.php b/config/agents.php index cfe9bd99..2469af30 100644 --- a/config/agents.php +++ b/config/agents.php @@ -65,6 +65,8 @@ App\Ai\Tools\Read\ListCategoriesTool::class, App\Ai\Tools\Read\ListPartiesTool::class, App\Ai\Tools\Read\ListHoldingsTool::class, + App\Ai\Tools\Read\GetUserDefaultsTool::class, + App\Ai\Tools\Read\ConvertCurrencyTool::class, App\Ai\Tools\Read\GetExchangeRateTool::class, App\Ai\Tools\Read\GetAssetPriceTool::class, App\Ai\Tools\Render\RenderKpiTool::class, @@ -82,12 +84,48 @@ App\Ai\Tools\Write\CreateWalletTool::class, App\Ai\Tools\Write\CreateCategoryTool::class, App\Ai\Tools\Write\CreatePartyTool::class, - App\Ai\Tools\Write\CategorizeTransactionTool::class, + App\Ai\Tools\Write\CategorizeTransactionsTool::class, + App\Ai\Tools\Write\AssignTransactionCategoriesTool::class, App\Ai\Tools\Write\AttachToTransactionTool::class, + App\Ai\Tools\Write\CreateBudgetTool::class, + App\Ai\Tools\Write\CreateRecurringRuleTool::class, + App\Ai\Tools\Write\RecordRefundTool::class, App\Ai\Tools\Documents\ImportDocumentTool::class, App\Ai\Tools\Documents\ExtractReceiptTool::class, ], + /* + |-------------------------------------------------------------------------- + | Model resources + |-------------------------------------------------------------------------- + | + | Models that describe themselves to the agent via agentResource(). Each one + | gains an owner-scoped list_ tool and a place in the semantic + | layer written by `php artisan agents:export-schema`. Models whose reads are + | already served by a hand-written tool declare readTool: false: they are + | listed here for the schema and for scoping their children, not for a + | duplicate tool. + | + */ + 'resources' => [ + 'models' => [ + App\Models\Transaction::class, + App\Models\Wallet::class, + App\Models\Category::class, + App\Models\Party::class, + App\Models\Transfer::class, + App\Models\Budget::class, + App\Models\BudgetPeriodState::class, + App\Models\RecurringTransactionRule::class, + App\Models\Refund::class, + App\Models\Reminder::class, + App\Models\Notification::class, + App\Models\Group::class, + App\Models\ExchangeRate::class, + ], + 'max_rows' => (int) env('AGENTS_RESOURCE_MAX_ROWS', 50), + ], + /* |-------------------------------------------------------------------------- | Auto-discovery diff --git a/config/app.php b/config/app.php index 511193d5..e838e727 100644 --- a/config/app.php +++ b/config/app.php @@ -164,6 +164,7 @@ * Application Service Providers... */ App\Providers\AppServiceProvider::class, + App\Providers\AiServiceProvider::class, App\Providers\OutreachServiceProvider::class, App\Providers\AuthServiceProvider::class, App\Providers\BroadcastServiceProvider::class, diff --git a/config/entitlements.php b/config/entitlements.php new file mode 100644 index 00000000..db49c3e7 --- /dev/null +++ b/config/entitlements.php @@ -0,0 +1,14 @@ + env('ENTITLEMENTS_REGISTER_ROUTES', false), + + 'route_prefix' => env('ENTITLEMENTS_ROUTE_PREFIX', 'api/v1'), +]; diff --git a/config/schema.php b/config/schema.php index 371d88f2..0bafe9ef 100644 --- a/config/schema.php +++ b/config/schema.php @@ -18,6 +18,11 @@ * timestamp, json, enum (with `values`) * * Index specs: ['columns' => [...], 'name' => 'optional', 'unique' => bool] + * + * Any migration that ALTERS an existing table must also declare what it adds + * here. A create migration either ran or the table is absent (which `verify` + * reports on its own), but an ALTER that never reached an environment leaves a + * table that looks fine and fails on write. SchemaConformanceTest enforces this. */ return [ @@ -29,6 +34,29 @@ 'enforce' => env('SCHEMA_CONFORMANCE_ENFORCE', true), 'tables' => [ + 'transactions' => [ + 'columns' => [ + 'intent' => ['type' => 'string', 'default' => 'regular'], + 'metadata' => ['type' => 'json', 'nullable' => true], + ], + 'indexes' => [ + ['columns' => ['intent']], + ['columns' => ['datetime']], + ], + ], + + 'files' => [ + 'columns' => [ + 'metadata' => ['type' => 'json', 'nullable' => true], + ], + ], + + 'chat_messages' => [ + 'columns' => [ + 'progress' => ['type' => 'json', 'nullable' => true], + ], + ], + 'reminders' => [ 'columns' => [ 'source' => ['type' => 'string', 'nullable' => true], diff --git a/database/migrations/2026_08_05_000001_add_remindable_columns_to_reminders_table.php b/database/migrations/2026_08_05_000001_add_remindable_columns_to_reminders_table.php new file mode 100644 index 00000000..88916e3f --- /dev/null +++ b/database/migrations/2026_08_05_000001_add_remindable_columns_to_reminders_table.php @@ -0,0 +1,45 @@ +string('source')->nullable()->after('type'); + } + + if (! Schema::hasColumn('reminders', 'remindable_type')) { + $table->string('remindable_type')->nullable()->after('source'); + } + + if (! Schema::hasColumn('reminders', 'remindable_id')) { + $table->unsignedBigInteger('remindable_id')->nullable()->after('remindable_type'); + } + }); + + if (! Schema::hasIndex('reminders', 'reminders_remindable_index')) { + Schema::table('reminders', function (Blueprint $table) { + $table->index(['remindable_type', 'remindable_id'], 'reminders_remindable_index'); + }); + } + } + + public function down(): void + { + if (Schema::hasIndex('reminders', 'reminders_remindable_index')) { + Schema::table('reminders', function (Blueprint $table) { + $table->dropIndex('reminders_remindable_index'); + }); + } + + Schema::table('reminders', function (Blueprint $table) { + $table->dropColumn(['source', 'remindable_type', 'remindable_id']); + }); + } +}; diff --git a/docker/hosted/Dockerfile b/docker/hosted/Dockerfile index 5033f922..592de970 100644 --- a/docker/hosted/Dockerfile +++ b/docker/hosted/Dockerfile @@ -1,9 +1,10 @@ -# Trakli hosted image: the public base plus bundled private plugins, -# enabled and cached so paid features are available out of the box. +# Trakli hosted image: the public base plus bundled plugins, enabled and cached +# so their features are available out of the box. # -# The build context is prepared by the hosted-image workflow on the runner -# (private plugins cloned into plugins/, their dependencies installed into -# vendor/, and the plugin cache regenerated) before this image is built. +# The build context is prepared on the runner by the workflow in the repository +# that owns those plugins (plugins cloned into plugins/, their dependencies +# installed into vendor/, and the plugin cache regenerated) before this image is +# built. ARG BASE_IMAGE=ghcr.io/trakli/webservice:latest FROM ${BASE_IMAGE} diff --git a/docs/PLUGIN_SYSTEM.md b/docs/PLUGIN_SYSTEM.md index f56959af..41503923 100644 --- a/docs/PLUGIN_SYSTEM.md +++ b/docs/PLUGIN_SYSTEM.md @@ -106,9 +106,9 @@ Beyond routes and migrations, the core exposes contracts a plugin can hook into. ### Feature gating (Entitlements) -`App\Contracts\Entitlements` decides whether an owner may use a feature, what limits apply, and how much of a metered allowance remains. It is keyed on the resource owner (a user today, a shared owner later), not the user directly. The core binds a permissive default that allows everything; a billing plugin may rebind it to enforce a plan. +`App\Contracts\Entitlements` decides whether an owner may use a feature, what limits apply, and how much of a metered allowance remains. It is keyed on the resource owner (a user today, a shared owner later), not the user directly. The core binds a permissive default that allows everything; a plugin may rebind it to enforce its own policy. -Gate a paid route or action: +Gate a route or action: ```php if (! app(\App\Contracts\Entitlements::class)->allows($owner, 'your-feature')) { @@ -116,7 +116,7 @@ if (! app(\App\Contracts\Entitlements::class)->allows($owner, 'your-feature')) { } ``` -Feature keys are plain strings; the billing plugin maps them to plans. With no override, the default allows everything, so the open core stays free and self-hostable. +Feature keys are plain strings; whatever rebinds the contract decides what they mean. With no override, the default allows everything. ### Integration registry @@ -138,9 +138,9 @@ app(\App\Services\DocumentProcessorManager::class)->register($yourProcessor, pri The highest-priority processor whose `supports($mimeType, $extension)` returns true wins; equal priority keeps registration order. -## Paid and Private Plugins +## Closed-Source Plugins -Private, paid plugins live in their own repositories and never ship in the open image. A reusable workflow stacks them onto the public base image to produce a private hosted image with the plugins enabled and cached. Gate their features through `Entitlements` so the same code path stays inert on the open core. +A plugin can live in its own repository and never ship in the open image. A build in the repository that owns it stacks it onto the public base image, using `docker/hosted/Dockerfile` here, to produce a hosted image with the plugin enabled and cached, and rebuilds whenever this repository publishes a new base. Gate its features through `Entitlements` so the same code path stays inert on the open core. ## Best Practices diff --git a/public/docs/api.json b/public/docs/api.json index b5612038..70356743 100644 --- a/public/docs/api.json +++ b/public/docs/api.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "info": { "title": "Trakli API", - "version": "2.0.0-beta.1" + "version": "2.0.0-beta.2" }, "servers": [ { @@ -607,6 +607,78 @@ } } }, + "/ai/chats/{chat}/actions/batches/{batch}/confirm": { + "post": { + "tags": [ + "AI" + ], + "summary": "Confirm every action proposed together as one batch", + "operationId": "d128ebef97eb4df88716c5a6e61480f4", + "parameters": [ + { + "name": "chat", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "batch", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Batch processed" + }, + "404": { + "description": "Batch not found" + } + } + } + }, + "/ai/chats/{chat}/actions/batches/{batch}/reject": { + "post": { + "tags": [ + "AI" + ], + "summary": "Dismiss every action proposed together as one batch", + "operationId": "02c43f82323f9a0a6ad21f1deb423415", + "parameters": [ + { + "name": "chat", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "batch", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Batch dismissed" + }, + "404": { + "description": "Batch not found" + } + } + } + }, "/ai/chats/{chat}/messages/{message}/files": { "post": { "tags": [ @@ -1720,6 +1792,203 @@ } } }, + "/transactions/export": { + "get": { + "tags": [ + "Exports" + ], + "summary": "Download the filtered transaction list as a file", + "description": "Accepts the same filters as the transaction list endpoint so the download matches what the client is showing.", + "operationId": "ce9e29ced3553313e76747c07d38288b", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "csv", + "enum": [ + "csv", + "xlsx", + "pdf" + ] + } + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "income", + "expense" + ] + } + }, + { + "name": "date_from", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "date_to", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "wallet_ids", + "in": "query", + "description": "Comma-separated wallet ids", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "category_ids", + "in": "query", + "description": "Comma-separated category ids", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "intent", + "in": "query", + "description": "Comma-separated transaction intents", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "exclude_transfers", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The export as a file download" + }, + "422": { + "description": "Unsupported format, invalid filter, or too many rows" + } + } + } + }, + "/reports/export": { + "get": { + "tags": [ + "Exports" + ], + "summary": "Download a financial statement as a file", + "description": "Renders the same analytics as the stats endpoint into a formatted statement.", + "operationId": "f46810a8f80612df8304c4baf47ea4d8", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "pdf", + "enum": [ + "csv", + "xlsx", + "pdf" + ] + } + }, + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "preset", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "all_time", + "current_week", + "current_month", + "last_3_months" + ] + } + }, + { + "name": "wallet_ids", + "in": "query", + "description": "Comma-separated wallet ids", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "period", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "month", + "enum": [ + "day", + "week", + "month", + "year" + ] + } + } + ], + "responses": { + "200": { + "description": "The statement as a file download" + }, + "422": { + "description": "Unsupported format or invalid wallet ids" + } + } + } + }, "/files/{id}": { "get": { "tags": [ @@ -6558,6 +6827,10 @@ "name": "Currency", "description": "Exchange rates and asset prices" }, + { + "name": "Exports", + "description": "Downloadable transaction lists and statements" + }, { "name": "Files", "description": "Endpoints for accessing files" diff --git a/resources/views/exports/document.blade.php b/resources/views/exports/document.blade.php new file mode 100644 index 00000000..45572015 --- /dev/null +++ b/resources/views/exports/document.blade.php @@ -0,0 +1,84 @@ + + + + + {{ $title }} + + + +

{{ $title }}

+ +@if (! empty($meta)) +
+ @foreach ($meta as $label => $value) + {{ $label }}: {{ $value }} + @endforeach +
+@endif + +@foreach ($notices as $notice) +
{{ $notice }}
+@endforeach + +@foreach ($sections as $section) +

{{ $section['heading'] }}

+ + @if ($section['note']) +
{{ $section['note'] }}
+ @endif + + @if (! empty($section['summary'])) + + @foreach ($section['summary'] as $label => $value) + + + + + @endforeach +
{{ $label }}{{ $value }}
+ @endif + + @if (! empty($section['columns'])) + + + + @foreach ($section['columns'] as $column) + + @endforeach + + + + @forelse ($section['rows'] as $row) + + @foreach ($row as $cell) + + @endforeach + + @empty + + + + @endforelse + +
{{ $column }}
{{ $cell }}
{{ __('Nothing to show for this selection.') }}
+ @endif +@endforeach + + diff --git a/routes/api.php b/routes/api.php index 7ee8339b..9b6cf460 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,7 @@ use App\Http\Controllers\API\v1\BudgetController; use App\Http\Controllers\API\v1\BudgetPeriodStateController; use App\Http\Controllers\API\v1\CategoryController; +use App\Http\Controllers\API\v1\ExportController; use App\Http\Controllers\API\v1\FileController; use App\Http\Controllers\API\v1\GroupController; use App\Http\Controllers\API\v1\ImportController; @@ -59,6 +60,9 @@ Route::get('stats', [StatsController::class, 'index']); }); Route::get('integrations', [IntegrationController::class, 'index']); + // Ahead of the resource route so "export" is not read as a transaction id. + Route::get('transactions/export', [ExportController::class, 'transactions']); + Route::get('reports/export', [ExportController::class, 'report']); Route::apiResource('transactions', TransactionController::class); Route::post('/transactions/{id}/files', [TransactionController::class, 'uploadFiles']); Route::delete('/transactions/{id}/files/{file_id}', [TransactionController::class, 'deleteFiles']); @@ -90,6 +94,9 @@ Route::post('ai/chats/{chat}/messages', [AiController::class, 'storeMessage']); Route::post('ai/chats/{chat}/messages/{message}/files', [AiController::class, 'uploadFiles']); Route::get('ai/chats/{chat}/messages/{message}/export', [AiController::class, 'exportCanvas']); + // Batch routes come first so "batches" is not captured as an {action} id. + Route::post('ai/chats/{chat}/actions/batches/{batch}/confirm', [AiController::class, 'confirmActionBatch']); + Route::post('ai/chats/{chat}/actions/batches/{batch}/reject', [AiController::class, 'rejectActionBatch']); Route::post('ai/chats/{chat}/actions/{action}/confirm', [AiController::class, 'confirmAction']); Route::post('ai/chats/{chat}/actions/{action}/reject', [AiController::class, 'rejectAction']); Route::get('ai/health', [AiController::class, 'health']); diff --git a/smartql.yml b/smartql.yml index 2cdef07f..df3d12de 100644 --- a/smartql.yml +++ b/smartql.yml @@ -1,5 +1,16 @@ -version: "1.0" +# Semantic layer for the natural-language query tool. +# +# The entities, relationships, allowed_tables and required_filters below are +# generated from the models that implement HasAgentResource: +# +# php artisan agents:export-schema --output=storage/app/exported.yml +# +# Change a model's agentResource(), regenerate, and merge the result here rather +# than editing those sections by hand. Everything else (connection details, LLM +# settings, business rules, prompt examples, and the two entities with no model +# of their own) is maintained in this file. +version: '1.0' database: type: mysql connection: @@ -8,7 +19,6 @@ database: database: ${DB_DATABASE} user: ${DB_USERNAME} password: ${DB_PASSWORD} - llm: provider: ${LLM_PROVIDER} gemini: @@ -16,225 +26,699 @@ llm: api_key: ${GEMINI_API_KEY} temperature: ${LLM_TEMPERATURE} retries: ${LLM_RETRIES} - semantic_layer: entities: transactions: table: transactions - description: "Financial transactions representing income or expenses" - aliases: [spending, expenses, income, payments, purchases] + description: Money in and out. An income or expense against one wallet. + aliases: + - spending + - expenses + - income + - payments + - purchases + label_column: description columns: id: type: integer primary: true amount: type: decimal - description: "Transaction amount in the wallet's currency" + description: Amount in the wallet's currency type: - type: string - description: "Transaction type: 'income' or 'expense'" + type: enum + description: Whether money came in or went out + values: + - income + - expense intent: type: string - description: "What the money movement is: regular, loan_received, loan_repayment, debt_owed, debt_settled, investment_buy, investment_return, gift" + description: 'What the movement is: regular, loan_received, loan_repayment, debt_owed, debt_settled, investment_buy, investment_return, gift' datetime: type: datetime - description: "When the transaction occurred" + description: When the transaction happened description: type: text - description: "Transaction description or notes" - user_id: - type: integer - description: "Owner of the transaction" + description: What it was for wallet_id: type: integer - description: "Wallet this transaction belongs to" + description: Wallet the money moved through + references: wallets.id party_id: type: integer - description: "The counterparty (vendor, merchant, payer)" - + description: Who it was with + references: parties.id + transfer_id: + type: integer + description: Set when this row is one leg of a wallet-to-wallet transfer rather than real income or spending + references: transfers.id + user_id: + type: integer + hidden: true wallets: table: wallets - description: "User's financial accounts (bank accounts, cash, credit cards)" - aliases: [accounts, bank accounts, cards] + description: 'The user''s accounts: bank accounts, cash, credit cards, mobile money.' + aliases: + - accounts + - bank accounts + - cards + label_column: name columns: id: type: integer primary: true name: type: string - description: "Wallet name" + description: Wallet name balance: type: decimal - description: "Current balance" + description: Current balance currency: type: string - description: "Currency code (USD, EUR, XAF, etc.)" + description: Currency code (USD, EUR, XAF, ...) type: - type: string - description: "Wallet type: bank, cash, credit_card, mobile" + type: enum + description: What kind of account it is + values: + - bank + - cash + - credit_card + - mobile + description: + type: text + description: Notes about the wallet user_id: type: integer - description: "Owner of the wallet" - + hidden: true categories: table: categories - description: "Transaction categories for classification" - aliases: [category, tags, labels] + description: Labels the user classifies transactions under. + aliases: + - category + - tags + - labels + label_column: name columns: id: type: integer primary: true name: type: string - description: "Category name" + description: Category name type: - type: string - description: "Category type: 'income' or 'expense'" + type: enum + description: What the category applies to + values: + - income + - expense + - invoice + description: + type: text + description: What the category covers user_id: type: integer - description: "Owner of the category" - + hidden: true parties: table: parties - description: "Counterparties in transactions (vendors, merchants, employers)" - aliases: [vendors, merchants, payees, payers, contacts] + description: The people and businesses on the other side of a transaction. + aliases: + - vendors + - merchants + - payees + - payers + - contacts + label_column: name columns: id: type: integer primary: true name: type: string - description: "Party name" + description: Party name type: type: string - description: "Party type classification" + description: How the party is classified + description: + type: text + description: Notes about the party user_id: type: integer - description: "Owner of this party record" - - categorizables: - table: categorizables - description: "Links transactions to categories (many-to-many)" + hidden: true + transfers: + table: transfers + description: Money moved between two of the user's own wallets. Not income or expense. + aliases: + - moves + - wallet transfers columns: - category_id: + id: type: integer - categorizable_id: + primary: true + amount: + type: decimal + description: Amount taken from the source wallet + exchange_rate: + type: decimal + description: Destination currency units per source unit + from_wallet_id: type: integer - description: "Transaction ID" - categorizable_type: + description: Wallet the money left + references: wallets.id + to_wallet_id: + type: integer + description: Wallet the money arrived in + references: wallets.id + datetime: + type: datetime + description: When the transfer happened + user_id: + type: integer + hidden: true + created_at: + type: datetime + description: When the transfer was recorded + budgets: + table: budgets + description: Spending limits the user sets for a period, optionally targeting categories, groups or wallets. + aliases: + - budget + - spending limits + - caps + label_column: name + columns: + id: + type: integer + primary: true + name: type: string - description: "Always 'App\\Models\\Transaction'" - + description: Budget name + description: + type: text + description: What the budget covers + amount: + type: decimal + description: The spending limit for one period + currency: + type: string + description: Currency of the limit + period_type: + type: enum + description: How often the budget resets + values: + - weekly + - monthly + - yearly + - custom + start_date: + type: date + description: First day the budget applies + end_date: + type: date + description: Last day the budget applies, if it ends + rollover_enabled: + type: boolean + description: Whether unspent money carries into the next period + threshold_percent: + type: integer + description: Percentage used at which the user is warned + is_active: + type: boolean + description: Whether the budget is currently in force + owner_id: + type: integer + hidden: true + owner_type: + type: string + hidden: true + created_at: + type: datetime + budget_period_states: + table: budget_period_states + description: What a budget actually spent in a period once that period closed. Written by the system, never by the user. + aliases: + - budget history + - budget periods + columns: + id: + type: integer + primary: true + budget_id: + type: integer + description: Budget this period belongs to + references: budgets.id + period_start: + type: date + description: First day of the period + period_end: + type: date + description: Last day of the period + net_spent: + type: decimal + description: Spending in the period after refunds + rollover_in: + type: decimal + description: Unspent money carried in from the previous period + rollover_out: + type: decimal + description: Unspent money carried out to the next period + closed_at: + type: datetime + description: When the period was closed + recurring_rules: + table: recurring_transaction_rules + description: Rules that repeat a transaction on a schedule (rent, salary, subscriptions). + aliases: + - recurring transactions + - repeats + - subscriptions + - standing orders + columns: + id: + type: integer + primary: true + transaction_id: + type: integer + description: The transaction being repeated + references: transactions.id + recurrence_period: + type: enum + description: Unit the rule repeats on + values: + - daily + - weekly + - monthly + - yearly + recurrence_interval: + type: integer + description: How many periods between occurrences + next_scheduled_at: + type: datetime + description: When the next occurrence is due + recurrence_ends_at: + type: datetime + description: When the rule stops repeating + refunds: + table: refunds + description: Money returned for an earlier expense, linking the incoming transaction to the one it reverses. + aliases: + - returns + - reimbursements + - money back + columns: + id: + type: integer + primary: true + refund_transaction_id: + type: integer + description: The income transaction carrying the refunded money + references: transactions.id + original_transaction_id: + type: integer + description: The expense being refunded + references: transactions.id + created_at: + type: datetime + description: When the refund was recorded + reminders: + table: reminders + description: Things the user asked to be reminded about, such as a bill due date. + aliases: + - alerts + - nudges + - to-dos + label_column: title + columns: + id: + type: integer + primary: true + title: + type: string + description: What the reminder is about + description: + type: text + description: Longer note attached to the reminder + type: + type: string + description: What kind of reminder it is + status: + type: string + description: Whether the reminder is active, done or dismissed + trigger_at: + type: datetime + description: When the reminder first fires + due_at: + type: datetime + description: When the thing being remembered is due + next_trigger_at: + type: datetime + description: When it fires next + priority: + type: integer + description: How important it is, higher is more urgent + user_id: + type: integer + hidden: true + created_at: + type: datetime + notifications: + table: notifications + description: Messages Trakli has sent the user, such as a budget warning. Generated by the system. + aliases: + - alerts + - messages + label_column: title + columns: + id: + type: integer + primary: true + type: + type: string + description: What kind of notification it is + title: + type: string + description: Notification headline + body: + type: text + description: Notification text + read_at: + type: datetime + description: When the user read it, empty if unread + user_id: + type: integer + hidden: true + created_at: + type: datetime + description: When it was sent + groups: + table: groups + description: Groupings the user files transactions and budgets under, such as a household or a project. + aliases: + - group + - projects + - households + label_column: name + columns: + id: + type: integer + primary: true + name: + type: string + description: Group name + slug: + type: string + description: URL-safe form of the name + description: + type: text + description: What the group covers + user_id: + type: integer + hidden: true + created_at: + type: datetime + exchange_rates: + table: exchange_rates + description: Reference conversion rates between currencies. The same for every user. + aliases: + - rates + - fx + - currency rates + columns: + id: + type: integer + primary: true + base_currency: + type: string + description: Currency being converted from + target_currency: + type: string + description: Currency being converted to + rate: + type: decimal + description: Target units per one base unit + fetched_at: + type: datetime + description: When the rate was last refreshed holdings: table: holdings - description: "Owned assets (crypto, stocks, property) tracked by quantity and unit price; value = quantity * unit_price" - aliases: [assets, investments, crypto, stocks, portfolio, net worth] + description: Owned assets (crypto, stocks, property) tracked by quantity and unit price; value = quantity * unit_price + aliases: + - assets + - investments + - crypto + - stocks + - portfolio + - net worth + label_column: name columns: id: type: integer primary: true name: type: string - description: "Asset name (e.g. Bitcoin, Apple, Rental flat)" + description: Asset name (e.g. Bitcoin, Apple, Rental flat) symbol: type: string - description: "Ticker or short symbol" + description: Ticker or short symbol quantity: type: decimal - description: "Units held" + description: Units held currency: type: string - description: "Currency the unit price is denominated in" + description: Currency the unit price is denominated in unit_price: type: decimal - description: "Current price per unit" + description: Current price per unit price_source: type: string - description: "'manual' or 'auto' (live-priced)" + description: '''manual'' or ''auto'' (live-priced)' + owner_id: + type: integer + hidden: true owner_type: type: string - description: "Always 'App\\Models\\User'" - owner_id: + hidden: true + categorizables: + table: categorizables + description: Links transactions to categories (many-to-many) + columns: + category_id: type: integer - description: "Owner of the holding" - + references: categories.id + categorizable_id: + type: integer + description: Transaction ID + categorizable_type: + type: string + description: Always 'App\Models\Transaction' relationships: - - name: transaction_wallet - from: transactions - to: wallets - foreign_key: wallet_id - description: "Each transaction belongs to a wallet" - - - name: transaction_party - from: transactions - to: parties - foreign_key: party_id - description: "Each transaction may have a counterparty" - - - name: transaction_category - from: categorizables - to: categories - foreign_key: category_id - description: "Categories assigned to transactions" - - - name: transaction_categorizable - from: categorizables - to: transactions - foreign_key: categorizable_id - description: "Transaction that has categories" - + - name: transaction_wallet + type: many_to_one + from: transactions + to: wallets + foreign_key: wallet_id + description: Each transaction belongs to a wallet + - name: transaction_party + type: many_to_one + from: transactions + to: parties + foreign_key: party_id + description: Each transaction may have a counterparty + - name: transaction_transfer + type: many_to_one + from: transactions + to: transfers + foreign_key: transfer_id + description: A transfer leg points at its transfer + - name: transfer_source_wallet + type: many_to_one + from: transfers + to: wallets + foreign_key: from_wallet_id + description: Wallet the money left + - name: transfer_destination_wallet + type: many_to_one + from: transfers + to: wallets + foreign_key: to_wallet_id + description: Wallet the money arrived in + - name: budget_period_states + type: one_to_many + from: budgets + to: budget_period_states + foreign_key: budget_id + description: Closed periods of a budget + - name: budget_targets + type: many_to_many + from: budgets + to: categories + pivot_table: budgetables + pivot_from: budget_id + pivot_to: budgetable_id + description: Categories, groups or wallets a budget applies to + - name: period_state_budget + type: many_to_one + from: budget_period_states + to: budgets + foreign_key: budget_id + - name: recurring_rule_transaction + type: many_to_one + from: recurring_rules + to: transactions + foreign_key: transaction_id + - name: refund_transaction + type: many_to_one + from: refunds + to: transactions + foreign_key: refund_transaction_id + - name: refunded_transaction + type: many_to_one + from: refunds + to: transactions + foreign_key: original_transaction_id + - name: transaction_category + type: many_to_one + from: categorizables + to: categories + foreign_key: category_id + description: Categories assigned to transactions + - name: transaction_categorizable + type: many_to_one + from: categorizables + to: transactions + foreign_key: categorizable_id + description: Transaction that has categories business_rules: - - name: income - applies_to: [transactions] - definition: "type = 'income'" - description: "Filter for income transactions only" - - - name: expense - applies_to: [transactions] - definition: "type = 'expense'" - description: "Filter for expense transactions only" - - - name: this_month - applies_to: [transactions] - definition: "MONTH(datetime) = MONTH(CURRENT_DATE()) AND YEAR(datetime) = YEAR(CURRENT_DATE())" - description: "Transactions from the current month" - - - name: last_month - applies_to: [transactions] - definition: "MONTH(datetime) = MONTH(DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH)) AND YEAR(datetime) = YEAR(DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH))" - description: "Transactions from last month" - - - name: this_year - applies_to: [transactions] - definition: "YEAR(datetime) = YEAR(CURRENT_DATE())" - description: "Transactions from the current year" - + - name: income + applies_to: + - transactions + definition: type = 'income' AND transfer_id IS NULL + description: Money genuinely coming in, excluding the incoming leg of a wallet-to-wallet transfer + - name: expense + applies_to: + - transactions + definition: type = 'expense' AND transfer_id IS NULL + description: Money genuinely going out, excluding the outgoing leg of a wallet-to-wallet transfer + - name: transfer_leg + applies_to: + - transactions + definition: transfer_id IS NOT NULL + description: One side of a wallet-to-wallet transfer; never real income or spending + - name: this_month + applies_to: + - transactions + definition: MONTH(datetime) = MONTH(CURRENT_DATE()) AND YEAR(datetime) = YEAR(CURRENT_DATE()) + description: Transactions from the current month + - name: last_month + applies_to: + - transactions + definition: MONTH(datetime) = MONTH(DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH)) AND YEAR(datetime) = YEAR(DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH)) + description: Transactions from last month + - name: this_year + applies_to: + - transactions + definition: YEAR(datetime) = YEAR(CURRENT_DATE()) + description: Transactions from the current year + - name: active_budget + applies_to: + - budgets + definition: is_active = 1 + description: Budgets currently in force + - name: unread + applies_to: + - notifications + definition: read_at IS NULL + description: Notifications the user has not opened security: mode: read_only max_rows: 100 max_join_depth: 4 permission_key: role - allowed_tables: [transactions, wallets, categories, parties, categorizables] + allowed_tables: + - budget_period_states + - budgets + - categories + - categorizables + - exchange_rates + - groups + - holdings + - notifications + - parties + - recurring_transaction_rules + - refunds + - reminders + - transactions + - transfers + - wallets required_filters: - transactions: { column: user_id, bypass_roles: [admin] } - wallets: { column: user_id, bypass_roles: [admin] } - categories: { column: user_id, bypass_roles: [admin] } - parties: { column: user_id, bypass_roles: [admin] } + transactions: + column: user_id + bypass_roles: + - admin + wallets: + column: user_id + bypass_roles: + - admin + categories: + column: user_id + bypass_roles: + - admin + parties: + column: user_id + bypass_roles: + - admin + transfers: + column: user_id + budgets: + column: owner_id + param: user_id + constants: + owner_type: App\Models\User + budget_period_states: + through: + column: budget_id + references: budgets.id + recurring_transaction_rules: + through: + column: transaction_id + references: transactions.id + refunds: + through: + column: refund_transaction_id + references: transactions.id + reminders: + column: user_id + notifications: + column: user_id + groups: + column: user_id + holdings: + column: owner_id + param: user_id + constants: + owner_type: App\Models\User + bypass_roles: + - admin + categorizables: + through: + column: categorizable_id + references: transactions.id blocked_columns: - - users.password - - users.remember_token - - users.email_verified_at - + - users.password + - users.remember_token + - users.email_verified_at prompts: examples: - - question: "How much did I spend last month?" - sql: "SELECT SUM(amount) as total FROM transactions WHERE type = 'expense' AND user_id = :user_id AND MONTH(datetime) = MONTH(DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH))" - - - question: "What are my top 5 expense categories?" - sql: "SELECT c.name, SUM(t.amount) as total FROM transactions t JOIN categorizables cz ON t.id = cz.categorizable_id JOIN categories c ON cz.category_id = c.id WHERE t.type = 'expense' AND t.user_id = :user_id GROUP BY c.id ORDER BY total DESC LIMIT 5" - - - question: "Show my income this year" - sql: "SELECT SUM(amount) as total FROM transactions WHERE type = 'income' AND user_id = :user_id AND YEAR(datetime) = YEAR(CURRENT_DATE())" - - - question: "What is my wallet balance?" - sql: "SELECT name, balance, currency FROM wallets WHERE user_id = :user_id" + - question: How much did I spend last month? + sql: SELECT SUM(amount) as total FROM transactions WHERE type = 'expense' AND transfer_id IS NULL AND user_id = :user_id AND MONTH(datetime) = MONTH(DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH)) + - question: What are my top 5 expense categories? + sql: SELECT c.name, SUM(t.amount) as total FROM transactions t JOIN categorizables cz ON t.id = cz.categorizable_id JOIN categories c ON cz.category_id = c.id WHERE t.type = 'expense' AND t.transfer_id IS NULL AND t.user_id = :user_id AND c.user_id = :user_id GROUP BY c.id ORDER BY total DESC LIMIT 5 + - question: Show my income this year + sql: SELECT SUM(amount) as total FROM transactions WHERE type = 'income' AND transfer_id IS NULL AND user_id = :user_id AND YEAR(datetime) = YEAR(CURRENT_DATE()) + - question: What is my wallet balance? + sql: SELECT name, balance, currency FROM wallets WHERE user_id = :user_id + - question: Show my recent transfers between wallets + sql: SELECT t.amount, t.datetime, t.from_wallet_id, t.to_wallet_id FROM transfers t WHERE t.user_id = :user_id ORDER BY t.datetime DESC LIMIT 20 + - question: How am I doing on my budgets? + sql: SELECT b.name, b.amount, b.currency, b.period_type FROM budgets b WHERE b.owner_id = :user_id AND b.owner_type = 'App\Models\User' AND b.is_active = 1 + - question: How much did I actually spend last month, not counting transfers? + sql: SELECT SUM(amount) as total FROM transactions WHERE type = 'expense' AND transfer_id IS NULL AND user_id = :user_id AND MONTH(datetime) = MONTH(DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH)) diff --git a/tests/Feature/AdvancedImportTest.php b/tests/Feature/AdvancedImportTest.php index 15f791a4..021c0e6c 100644 --- a/tests/Feature/AdvancedImportTest.php +++ b/tests/Feature/AdvancedImportTest.php @@ -824,6 +824,27 @@ public function test_confirm_requires_session_id(): void // Helpers // --------------------------------------------------------------------- + public function test_confirm_rejects_a_zero_amount_suggestion(): void + { + $wallet = $this->user->wallets()->create(['name' => 'Checking', 'currency' => 'USD']); + + $session = $this->makeReadySession([ + $this->sampleSuggestion(['amount' => 0, 'type' => 'expense']), + ]); + + $response = $this->actingAs($this->user)->postJson('/api/v1/import/confirm', [ + 'session_id' => $session->id, + 'accepted' => [['index' => 0, 'wallet_id' => $wallet->id]], + ]); + + $response->assertStatus(206); + + $data = $response->json('data'); + $this->assertEquals(0, $data['created_count']); + $this->assertNotEmpty($data['errors']); + $this->assertEquals(0, $this->user->transactions()->count()); + } + private function makeReadySession(array $suggestions): \App\Models\ImportSession { return $this->user->importSessions()->create([ diff --git a/tests/Feature/AgentBatchActionTest.php b/tests/Feature/AgentBatchActionTest.php new file mode 100644 index 00000000..1b77ec27 --- /dev/null +++ b/tests/Feature/AgentBatchActionTest.php @@ -0,0 +1,340 @@ +user = User::factory()->create(); + $this->wallet = Wallet::factory()->create(['user_id' => $this->user->id, 'currency' => 'XAF']); + + $this->session = new ChatSession(['title' => 'Batch']); + $this->session->owner()->associate($this->user); + $this->session->save(); + + $this->message = ChatMessage::create([ + 'chat_session_id' => $this->session->id, + 'user_id' => $this->user->id, + 'role' => 'assistant', + 'content' => 'ok', + ]); + + app()->instance(BlockCollector::class, new BlockCollector()); + } + + private function context(): ToolContext + { + return ToolContext::forUser($this->user, 'en', [ + 'chat_session_id' => $this->session->id, + 'chat_message_id' => $this->message->id, + ]); + } + + private function transaction(string $description): Transaction + { + return Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'wallet_id' => $this->wallet->id, + 'description' => $description, + ]); + } + + private function category(string $name): Category + { + return Category::factory()->create([ + 'user_id' => $this->user->id, + 'name' => $name, + 'type' => 'expense', + ]); + } + + private function block(): array + { + return app(BlockCollector::class)->all()[0]; + } + + public function test_categorizing_many_transactions_proposes_one_batch(): void + { + $a = $this->transaction('Coffee'); + $b = $this->transaction('Lunch'); + $groceries = $this->category('Groceries'); + + app(CategorizeTransactionsTool::class)->handle([ + 'transaction_ids' => [$a->id, $b->id], + 'category_name' => 'Groceries', + ], $this->context()); + + $block = $this->block(); + + $this->assertSame('proposed_action_batch', $block['type']); + $this->assertCount(2, $block['actions']); + $this->assertSame('Categorize 2 transactions as Groceries', $block['summary']); + + // One ledger row per transaction, all sharing one batch id. + $rows = AgentProposedAction::query()->forBatch($block['batch'])->get(); + $this->assertCount(2, $rows); + $this->assertEqualsCanonicalizing( + [$a->id, $b->id], + $rows->pluck('payload.transaction_id')->all() + ); + $this->assertSame([$groceries->id], $rows->first()->payload['categories']); + } + + public function test_a_single_transaction_stays_a_plain_proposal(): void + { + $a = $this->transaction('Coffee'); + $this->category('Groceries'); + + app(CategorizeTransactionsTool::class)->handle([ + 'transaction_ids' => [$a->id], + 'category_name' => 'Groceries', + ], $this->context()); + + $this->assertSame('proposed_action', $this->block()['type']); + } + + public function test_cards_name_the_transaction_and_category_rather_than_ids(): void + { + $a = $this->transaction('Flat white'); + $this->category('Coffee'); + + app(CategorizeTransactionsTool::class)->handle([ + 'transaction_ids' => [$a->id], + 'category_name' => 'Coffee', + ], $this->context()); + + $block = $this->block(); + + $this->assertStringContainsString('Flat white', $block['summary']); + $this->assertStringNotContainsString('#' . $a->id, $block['summary']); + + $fields = collect($block['fields'])->keyBy('key'); + $this->assertStringContainsString('Flat white', $fields['transaction_id']['display']); + $this->assertStringContainsString('XAF', $fields['transaction_id']['display']); + $this->assertSame('Coffee', $fields['categories']['display']); + } + + public function test_assigning_a_different_category_to_each_transaction(): void + { + $a = $this->transaction('Coffee'); + $b = $this->transaction('Bus fare'); + $coffee = $this->category('Coffee'); + $transport = $this->category('Transport'); + + app(AssignTransactionCategoriesTool::class)->handle([ + 'assignments' => [ + ['transaction_id' => $a->id, 'category_name' => 'Coffee'], + ['transaction_id' => $b->id, 'category_name' => 'Transport'], + ], + ], $this->context()); + + $block = $this->block(); + $rows = AgentProposedAction::query()->forBatch($block['batch'])->get()->keyBy('payload.transaction_id'); + + $this->assertSame([$coffee->id], $rows[$a->id]->payload['categories']); + $this->assertSame([$transport->id], $rows[$b->id]->payload['categories']); + $this->assertSame('Categorize 2 transactions across 2 categories', $block['summary']); + } + + public function test_assignment_rejects_unknown_categories_naming_all_of_them(): void + { + $a = $this->transaction('Coffee'); + + $result = app(AssignTransactionCategoriesTool::class)->handle([ + 'assignments' => [ + ['transaction_id' => $a->id, 'category_name' => 'Nope'], + ['transaction_id' => $a->id, 'category_name' => 'Also nope'], + ], + ], $this->context()); + + $this->assertStringContainsString('do not exist yet', $result['error']); + } + + public function test_a_transaction_from_another_user_is_refused(): void + { + $mine = $this->transaction('Coffee'); + $other = User::factory()->create(); + $theirs = Transaction::factory()->create([ + 'user_id' => $other->id, + 'wallet_id' => Wallet::factory()->create(['user_id' => $other->id])->id, + ]); + $this->category('Groceries'); + + $result = app(CategorizeTransactionsTool::class)->handle([ + 'transaction_ids' => [$mine->id, $theirs->id], + 'category_name' => 'Groceries', + ], $this->context()); + + $this->assertStringContainsString('not found among your records', $result['error']); + $this->assertSame(0, AgentProposedAction::query()->count()); + } + + public function test_confirming_a_batch_categorizes_every_member(): void + { + $a = $this->transaction('Coffee'); + $b = $this->transaction('Lunch'); + $groceries = $this->category('Groceries'); + + app(CategorizeTransactionsTool::class)->handle([ + 'transaction_ids' => [$a->id, $b->id], + 'category_name' => 'Groceries', + ], $this->context()); + + $batch = $this->block()['batch']; + + $this->actingAs($this->user) + ->postJson("/api/v1/ai/chats/{$this->session->id}/actions/batches/{$batch}/confirm") + ->assertOk() + ->assertJsonPath('data.executed', 2); + + $this->assertSame([$groceries->id], $a->fresh()->categories->pluck('id')->all()); + $this->assertSame([$groceries->id], $b->fresh()->categories->pluck('id')->all()); + $this->assertSame( + [ActionStatus::Executed->value, ActionStatus::Executed->value], + AgentProposedAction::query()->forBatch($batch)->pluck('status')->map->value->all() + ); + } + + public function test_confirming_a_batch_twice_does_not_duplicate_work(): void + { + $a = $this->transaction('Coffee'); + $this->transaction('Lunch'); + $this->category('Groceries'); + + app(CategorizeTransactionsTool::class)->handle([ + 'transaction_ids' => [$a->id, Transaction::query()->latest('id')->first()->id], + 'category_name' => 'Groceries', + ], $this->context()); + + $batch = $this->block()['batch']; + $url = "/api/v1/ai/chats/{$this->session->id}/actions/batches/{$batch}/confirm"; + + $this->actingAs($this->user)->postJson($url)->assertOk(); + $this->actingAs($this->user)->postJson($url)->assertOk()->assertJsonPath('data.executed', 2); + + $this->assertCount(1, $a->fresh()->categories); + } + + public function test_dismissing_a_batch_changes_nothing(): void + { + $a = $this->transaction('Coffee'); + $b = $this->transaction('Lunch'); + $this->category('Groceries'); + + app(CategorizeTransactionsTool::class)->handle([ + 'transaction_ids' => [$a->id, $b->id], + 'category_name' => 'Groceries', + ], $this->context()); + + $batch = $this->block()['batch']; + + $this->actingAs($this->user) + ->postJson("/api/v1/ai/chats/{$this->session->id}/actions/batches/{$batch}/reject") + ->assertOk(); + + $this->assertCount(0, $a->fresh()->categories); + $this->assertCount(0, $b->fresh()->categories); + } + + public function test_another_user_cannot_confirm_a_batch(): void + { + $a = $this->transaction('Coffee'); + $b = $this->transaction('Lunch'); + $this->category('Groceries'); + + app(CategorizeTransactionsTool::class)->handle([ + 'transaction_ids' => [$a->id, $b->id], + 'category_name' => 'Groceries', + ], $this->context()); + + $batch = $this->block()['batch']; + + $this->actingAs(User::factory()->create()) + ->postJson("/api/v1/ai/chats/{$this->session->id}/actions/batches/{$batch}/confirm") + ->assertNotFound(); + + $this->assertCount(0, $a->fresh()->categories); + } + + public function test_confirming_a_batch_marks_the_stored_card_done(): void + { + $a = $this->transaction('Coffee'); + $b = $this->transaction('Lunch'); + $this->category('Groceries'); + + app(CategorizeTransactionsTool::class)->handle([ + 'transaction_ids' => [$a->id, $b->id], + 'category_name' => 'Groceries', + ], $this->context()); + + $block = $this->block(); + $this->message->update(['result' => ['blocks' => [$block]]]); + + $this->actingAs($this->user) + ->postJson("/api/v1/ai/chats/{$this->session->id}/actions/batches/{$block['batch']}/confirm") + ->assertOk(); + + $stored = $this->message->fresh()->result['blocks'][0]; + + $this->assertSame(ActionStatus::Executed->value, $stored['status']); + $this->assertSame( + [ActionStatus::Executed->value, ActionStatus::Executed->value], + array_column($stored['actions'], 'status') + ); + } + + public function test_a_batch_stays_pending_until_every_member_is_settled(): void + { + $a = $this->transaction('Coffee'); + $b = $this->transaction('Lunch'); + $this->category('Groceries'); + + app(CategorizeTransactionsTool::class)->handle([ + 'transaction_ids' => [$a->id, $b->id], + 'category_name' => 'Groceries', + ], $this->context()); + + $block = $this->block(); + $this->message->update(['result' => ['blocks' => [$block]]]); + $first = $block['actions'][0]['id']; + + // Confirming one member on its own must not settle the whole card. + $this->actingAs($this->user) + ->postJson("/api/v1/ai/chats/{$this->session->id}/actions/{$first}/confirm") + ->assertOk(); + + $stored = $this->message->fresh()->result['blocks'][0]; + + $this->assertSame(ActionStatus::Proposed->value, $stored['status']); + $this->assertSame(ActionStatus::Executed->value, $stored['actions'][0]['status']); + $this->assertSame(ActionStatus::Proposed->value, $stored['actions'][1]['status']); + } +} diff --git a/tests/Feature/AgentCurrencyTest.php b/tests/Feature/AgentCurrencyTest.php new file mode 100644 index 00000000..3f4f06a6 --- /dev/null +++ b/tests/Feature/AgentCurrencyTest.php @@ -0,0 +1,164 @@ +systemPrompt( + $user ? $this->contextFor($user) : null + ); + } + + public function test_prompt_states_the_users_configured_currency(): void + { + $user = User::factory()->create(); + $user->setConfigValue('default-currency', 'XAF', ConfigValueType::String); + Wallet::factory()->create(['user_id' => $user->id, 'currency' => 'XAF']); + + $prompt = $this->promptFor($user); + + $this->assertStringContainsString('currency is XAF', $prompt); + $this->assertStringContainsString('Report every amount in XAF', $prompt); + } + + public function test_prompt_warns_when_wallets_hold_other_currencies(): void + { + $user = User::factory()->create(); + $user->setConfigValue('default-currency', 'XAF', ConfigValueType::String); + Wallet::factory()->create(['user_id' => $user->id, 'currency' => 'XAF']); + Wallet::factory()->create(['user_id' => $user->id, 'currency' => 'EUR']); + + $prompt = $this->promptFor($user); + + $this->assertStringContainsString('EUR', $prompt); + $this->assertStringContainsString('convert_currency', $prompt); + } + + public function test_prompt_infers_currency_from_a_lone_wallet_when_unset(): void + { + $user = User::factory()->create(); + Wallet::factory()->create(['user_id' => $user->id, 'currency' => 'GBP']); + + $this->assertStringContainsString('currency is GBP', $this->promptFor($user)); + } + + public function test_prompt_asks_rather_than_assuming_when_currency_is_unset_and_wallets_differ(): void + { + $user = User::factory()->create(); + Wallet::factory()->create(['user_id' => $user->id, 'currency' => 'GBP']); + Wallet::factory()->create(['user_id' => $user->id, 'currency' => 'NGN']); + + $prompt = $this->promptFor($user); + + $this->assertStringContainsString('has NOT set a default currency', $prompt); + $this->assertStringContainsString('Never assume dollars', $prompt); + } + + public function test_prompt_never_assumes_dollars_without_a_user(): void + { + $this->assertStringContainsString('Never assume dollars', $this->promptFor(null)); + } + + public function test_defaults_tool_reports_the_configured_currency_and_wallet(): void + { + $user = User::factory()->create(); + $user->setConfigValue('default-currency', 'XAF', ConfigValueType::String); + $wallet = Wallet::factory()->create(['user_id' => $user->id, 'currency' => 'XAF']); + $user->setConfigValue('default-wallet', (string) $wallet->id, ConfigValueType::String); + + $result = app(GetUserDefaultsTool::class)->handle([], $this->contextFor($user)); + + $this->assertSame('XAF', $result['currency']); + $this->assertTrue($result['currency_is_set']); + $this->assertSame($wallet->id, $result['default_wallet']['id']); + } + + public function test_defaults_tool_resolves_a_wallet_stored_as_a_client_id(): void + { + $user = User::factory()->create(); + $wallet = Wallet::factory()->create(['user_id' => $user->id, 'currency' => 'XAF']); + $wallet->setClientGeneratedId('device-token:abc-123', $user); + $user->setConfigValue('default-wallet', $wallet->fresh()->client_generated_id, ConfigValueType::String); + + $result = app(GetUserDefaultsTool::class)->handle([], $this->contextFor($user)); + + $this->assertSame($wallet->id, $result['default_wallet']['id']); + } + + public function test_defaults_tool_flags_an_unset_currency_instead_of_defaulting(): void + { + $user = User::factory()->create(); + Wallet::factory()->create(['user_id' => $user->id, 'currency' => 'GBP']); + Wallet::factory()->create(['user_id' => $user->id, 'currency' => 'NGN']); + + $result = app(GetUserDefaultsTool::class)->handle([], $this->contextFor($user)); + + $this->assertFalse($result['currency_is_set']); + $this->assertArrayNotHasKey('currency', $result); + $this->assertStringContainsString('Ask which they want', $result['currency_note']); + } + + public function test_convert_currency_uses_the_exchange_service(): void + { + $user = User::factory()->create(); + + $rates = Mockery::mock(ExchangeRateService::class); + $rates->shouldReceive('convert')->once() + ->with(100.0, 'USD', 'XAF', Mockery::any())->andReturn(60000.0); + $rates->shouldReceive('getRate')->once() + ->with('USD', 'XAF', Mockery::any())->andReturn(600.0); + + $result = (new ConvertCurrencyTool($rates))->handle( + ['amount' => 100, 'from' => 'usd', 'to' => 'xaf'], + $this->contextFor($user), + ); + + $this->assertSame(60000.0, $result['converted']); + $this->assertSame(600.0, $result['rate']); + $this->assertSame('XAF', $result['to']); + } + + public function test_convert_currency_refuses_to_estimate_a_missing_rate(): void + { + $user = User::factory()->create(); + + $rates = Mockery::mock(ExchangeRateService::class); + $rates->shouldReceive('convert')->andReturn(null); + + $result = (new ConvertCurrencyTool($rates))->handle( + ['amount' => 100, 'from' => 'USD', 'to' => 'ZZZ'], + $this->contextFor($user), + ); + + $this->assertStringContainsString('No exchange rate is available', $result['error']); + } + + public function test_currency_tools_are_reachable_by_the_agent(): void + { + $names = app(TrakliHarness::class)->toolNames(); + + $this->assertContains('get_user_defaults', $names); + $this->assertContains('convert_currency', $names); + } +} diff --git a/tests/Feature/AgentModelCoverageTest.php b/tests/Feature/AgentModelCoverageTest.php new file mode 100644 index 00000000..88658b20 --- /dev/null +++ b/tests/Feature/AgentModelCoverageTest.php @@ -0,0 +1,474 @@ +user = User::factory()->create(); + $this->wallet = Wallet::factory()->create([ + 'user_id' => $this->user->id, + 'name' => 'Cash', + 'currency' => 'USD', + ]); + $this->session = ChatSession::create([ + 'owner_type' => $this->user->getMorphClass(), + 'owner_id' => $this->user->id, + ]); + } + + private function tool(string $name) + { + return $this->app->make(ToolRegistry::class)->resolve($name); + } + + /** + * Run a write tool the way the agent does and return what it proposed. + */ + private function propose(string $toolName, array $arguments, ?User $user = null): AgentProposedAction + { + $this->assertNull($this->runTool($toolName, $arguments, $user), 'The tool reported an error instead of proposing.'); + + return AgentProposedAction::latest('id')->firstOrFail(); + } + + /** + * Run a write tool, returning the error message when it refuses. + */ + private function runTool(string $toolName, array $arguments, ?User $user = null): ?string + { + $this->app->instance(BlockCollector::class, new BlockCollector()); + + $context = ToolContext::forUser($user ?? $this->user, 'en', ['chat_session_id' => $this->session->id]); + $result = $this->tool($toolName)->handle($arguments, $context); + + return is_array($result) ? ($result['error'] ?? null) : null; + } + + private function confirm(AgentProposedAction $action, array $payload = [], ?User $user = null) + { + return $this->actingAs($user ?? $this->user) + ->postJson("/api/v1/ai/chats/{$this->session->id}/actions/{$action->id}/confirm", $payload); + } + + private function read(string $toolName, ?User $user = null): array + { + $result = $this->tool($toolName)->handle([], ToolContext::forUser($user ?? $this->user)); + + $this->assertIsArray($result, "Read tool {$toolName} refused: " . (is_string($result) ? $result : '')); + + return $result; + } + + private function transaction(array $attributes = []): Transaction + { + return Transaction::factory()->create(array_merge([ + 'user_id' => $this->user->id, + 'wallet_id' => $this->wallet->id, + 'amount' => 25, + 'type' => 'expense', + ], $attributes)); + } + + // Budgets + + public function test_budget_is_only_created_on_confirm(): void + { + $action = $this->propose('create_budget', [ + 'name' => 'Groceries', + 'amount' => 300, + 'currency' => 'USD', + 'period_type' => 'monthly', + ]); + + $this->assertSame(0, Budget::count(), 'Proposing must not create a budget.'); + + $this->confirm($action)->assertStatus(200); + + $this->assertSame(ActionStatus::Executed, $action->fresh()->status); + $this->assertDatabaseHas('budgets', [ + 'owner_id' => $this->user->id, + 'owner_type' => $this->user->getMorphClass(), + 'name' => 'Groceries', + 'currency' => 'USD', + 'period_type' => 'monthly', + ]); + } + + public function test_budget_targets_are_attached_on_confirm(): void + { + $category = Category::factory()->create(['user_id' => $this->user->id, 'name' => 'Food']); + + $action = $this->propose('create_budget', [ + 'name' => 'Food budget', + 'amount' => 200, + 'currency' => 'USD', + 'period_type' => 'monthly', + 'targets' => [['type' => 'category', 'name' => 'Food']], + ]); + + $this->confirm($action)->assertStatus(200); + + $budget = Budget::firstOrFail(); + $this->assertSame([$category->id], $budget->categories()->pluck('categories.id')->all()); + } + + public function test_budget_refuses_a_target_the_user_does_not_own(): void + { + $other = User::factory()->create(); + $theirs = Category::factory()->create(['user_id' => $other->id, 'name' => 'Theirs']); + + $error = $this->runTool('create_budget', [ + 'name' => 'Sneaky', + 'amount' => 100, + 'currency' => 'USD', + 'period_type' => 'monthly', + 'targets' => [['type' => 'category', 'id' => $theirs->id]], + ]); + + $this->assertNotNull($error); + $this->assertStringContainsString('does not belong to you', $error); + $this->assertSame(0, AgentProposedAction::count()); + } + + public function test_budget_confirm_rejects_an_edited_target_from_another_user(): void + { + Category::factory()->create(['user_id' => $this->user->id, 'name' => 'Food']); + $other = User::factory()->create(); + $theirs = Category::factory()->create(['user_id' => $other->id, 'name' => 'Theirs']); + + $action = $this->propose('create_budget', [ + 'name' => 'Food budget', + 'amount' => 200, + 'currency' => 'USD', + 'period_type' => 'monthly', + 'targets' => [['type' => 'category', 'name' => 'Food']], + ]); + + $this->confirm($action, ['overrides' => ['targets' => [['type' => 'category', 'id' => $theirs->id]]]]) + ->assertStatus(422); + + $this->assertSame(0, Budget::count()); + } + + public function test_budget_rejects_a_custom_period_without_an_end_date(): void + { + $error = $this->runTool('create_budget', [ + 'name' => 'Trip', + 'amount' => 500, + 'currency' => 'USD', + 'period_type' => 'custom', + ]); + + $this->assertStringContainsString('end date', (string) $error); + } + + public function test_budget_confirm_is_idempotent(): void + { + $action = $this->propose('create_budget', [ + 'name' => 'Groceries', + 'amount' => 300, + 'currency' => 'USD', + 'period_type' => 'monthly', + ]); + + $this->confirm($action)->assertStatus(200); + $this->confirm($action); + + $this->assertSame(1, Budget::count()); + } + + // Refunds + + public function test_refund_links_both_transactions_on_confirm(): void + { + $expense = $this->transaction(['type' => 'expense', 'description' => 'Jacket']); + $income = $this->transaction(['type' => 'income', 'description' => 'Jacket returned']); + + $action = $this->propose('record_refund', [ + 'refund_transaction_id' => $income->id, + 'original_transaction_id' => $expense->id, + ]); + + $this->assertDatabaseCount('refunds', 0); + + $this->confirm($action)->assertStatus(200); + + $this->assertDatabaseHas('refunds', [ + 'refund_transaction_id' => $income->id, + 'original_transaction_id' => $expense->id, + ]); + $this->assertTrue($income->fresh()->isRefund()); + } + + public function test_refund_must_be_an_income_transaction(): void + { + $expense = $this->transaction(['type' => 'expense']); + + $error = $this->runTool('record_refund', ['refund_transaction_id' => $expense->id]); + + $this->assertStringContainsString('income', (string) $error); + } + + public function test_refund_refuses_another_users_transaction(): void + { + $other = User::factory()->create(); + $theirWallet = Wallet::factory()->create(['user_id' => $other->id]); + $theirs = Transaction::factory()->create([ + 'user_id' => $other->id, + 'wallet_id' => $theirWallet->id, + 'type' => 'income', + ]); + + $error = $this->runTool('record_refund', ['refund_transaction_id' => $theirs->id]); + + $this->assertStringContainsString('not found', (string) $error); + $this->assertSame(0, AgentProposedAction::count()); + } + + public function test_refund_confirm_is_idempotent(): void + { + $income = $this->transaction(['type' => 'income']); + + $action = $this->propose('record_refund', ['refund_transaction_id' => $income->id]); + + $this->confirm($action)->assertStatus(200); + $this->confirm($action); + + $this->assertDatabaseCount('refunds', 1); + } + + // Recurring rules + + public function test_recurring_rule_attaches_to_its_transaction_on_confirm(): void + { + $transaction = $this->transaction(['description' => 'Rent', 'datetime' => now()]); + + $action = $this->propose('create_recurring_rule', [ + 'transaction_id' => $transaction->id, + 'recurrence_period' => 'monthly', + ]); + + $this->assertDatabaseCount('recurring_transaction_rules', 0); + + $this->confirm($action)->assertStatus(200); + + $this->assertDatabaseHas('recurring_transaction_rules', [ + 'transaction_id' => $transaction->id, + 'recurrence_period' => 'monthly', + 'recurrence_interval' => 1, + ]); + } + + public function test_recurring_rule_refuses_another_users_transaction(): void + { + $other = User::factory()->create(); + $theirWallet = Wallet::factory()->create(['user_id' => $other->id]); + $theirs = Transaction::factory()->create([ + 'user_id' => $other->id, + 'wallet_id' => $theirWallet->id, + ]); + + $error = $this->runTool('create_recurring_rule', [ + 'transaction_id' => $theirs->id, + 'recurrence_period' => 'monthly', + ]); + + $this->assertStringContainsString('not found', (string) $error); + } + + public function test_recurring_rule_refuses_a_transaction_that_already_repeats(): void + { + $transaction = $this->transaction(['datetime' => now()]); + + $action = $this->propose('create_recurring_rule', [ + 'transaction_id' => $transaction->id, + 'recurrence_period' => 'monthly', + ]); + $this->confirm($action)->assertStatus(200); + + $error = $this->runTool('create_recurring_rule', [ + 'transaction_id' => $transaction->id, + 'recurrence_period' => 'weekly', + ]); + + $this->assertStringContainsString('already repeats', (string) $error); + } + + // Generic create tool + + public function test_group_is_created_through_the_generic_write_tool(): void + { + $action = $this->propose('create_group', ['name' => 'Household']); + + $this->assertSame('group.create', $action->action_type); + $this->assertSame(0, Group::count()); + + $this->confirm($action)->assertStatus(200); + + $this->assertDatabaseHas('groups', ['user_id' => $this->user->id, 'name' => 'Household']); + } + + public function test_reminder_is_created_through_the_generic_write_tool(): void + { + $action = $this->propose('create_reminder', [ + 'title' => 'Pay rent', + 'trigger_at' => '2026-09-01T09:00:00Z', + ]); + + $this->confirm($action)->assertStatus(200); + + $this->assertDatabaseHas('reminders', ['user_id' => $this->user->id, 'title' => 'Pay rent']); + } + + /** + * A resource whose creation a purpose-built tool covers must keep that + * tool: a generic one registered under the same name would silently + * replace it with something that cannot express the same create. + */ + public function test_a_hand_written_write_tool_is_not_replaced_by_a_generic_one(): void + { + $bespoke = [ + 'create_budget' => \App\Ai\Tools\Write\CreateBudgetTool::class, + 'record_refund' => \App\Ai\Tools\Write\RecordRefundTool::class, + 'create_recurring_rule' => \App\Ai\Tools\Write\CreateRecurringRuleTool::class, + 'create_wallet' => \App\Ai\Tools\Write\CreateWalletTool::class, + 'record_transaction' => \App\Ai\Tools\Write\RecordTransactionTool::class, + ]; + + foreach ($bespoke as $name => $class) { + $this->assertInstanceOf($class, $this->tool($name)); + } + } + + public function test_generic_write_tool_enforces_the_resources_own_rules(): void + { + $error = $this->runTool('create_group', ['name' => str_repeat('x', 300)]); + + $this->assertNotNull($error); + $this->assertSame(0, AgentProposedAction::count()); + } + + public function test_generic_write_tool_ignores_fields_the_resource_does_not_expose(): void + { + $other = User::factory()->create(); + + $action = $this->propose('create_group', ['name' => 'Mine', 'user_id' => $other->id]); + + $this->assertArrayNotHasKey('user_id', $action->payload); + + $this->confirm($action)->assertStatus(200); + + $this->assertDatabaseHas('groups', ['name' => 'Mine', 'user_id' => $this->user->id]); + $this->assertDatabaseMissing('groups', ['name' => 'Mine', 'user_id' => $other->id]); + } + + // Generated read tools + + public function test_read_tools_never_show_another_users_records(): void + { + $other = User::factory()->create(); + $otherWallet = Wallet::factory()->create(['user_id' => $other->id]); + + Group::factory()->create(['user_id' => $other->id, 'name' => 'Theirs']); + Reminder::factory()->create(['user_id' => $other->id, 'title' => 'Theirs']); + Budget::factory()->create(['owner_id' => $other->id, 'owner_type' => $other->getMorphClass()]); + + $theirTransaction = Transaction::factory()->create([ + 'user_id' => $other->id, + 'wallet_id' => $otherWallet->id, + 'type' => 'income', + ]); + $theirTransaction->markAsRefund(); + + foreach (['list_groups', 'list_reminders', 'list_budgets', 'list_refunds'] as $tool) { + $this->assertSame(0, $this->read($tool)['count'], "{$tool} leaked another user's records."); + } + + $this->assertSame(1, $this->read('list_groups', $other)['count']); + $this->assertSame(1, $this->read('list_refunds', $other)['count']); + } + + public function test_read_tools_hide_internal_columns(): void + { + Group::factory()->create(['user_id' => $this->user->id, 'name' => 'Mine']); + + $row = $this->read('list_groups')['rows'][0]; + + $this->assertArrayNotHasKey('user_id', $row); + $this->assertSame('Mine', $row['name']); + } + + public function test_exchange_rates_are_readable_by_anyone(): void + { + \App\Models\ExchangeRate::create([ + 'base_currency' => 'USD', + 'target_currency' => 'EUR', + 'rate' => 0.9, + ]); + + $this->assertSame(1, $this->read('list_exchange_rates')['count']); + } + + public function test_a_guest_reads_nothing_from_an_owned_resource(): void + { + Group::factory()->create(['user_id' => $this->user->id]); + + $result = $this->tool('list_groups')->handle([], ToolContext::guest()); + + $this->assertIsString($result); + } + + public function test_transactions_keep_their_hand_written_read_tool(): void + { + $names = $this->app->make(ToolRegistry::class)->names(); + + $this->assertNotContains('list_transactions', $names); + $this->assertContains('list_wallets', $names); + $this->assertContains('list_transfers', $names); + } + + public function test_the_harness_offers_every_generated_read_tool(): void + { + $harness = $this->app->make(\App\Ai\Harnesses\TrakliHarness::class); + $names = $harness->toolNames(); + + foreach (['list_transfers', 'list_budgets', 'list_refunds', 'list_reminders', 'list_groups'] as $tool) { + $this->assertContains($tool, $names); + } + + $this->assertSame(array_unique($names), $names, 'The harness lists a tool twice.'); + } +} diff --git a/tests/Feature/EntitlementsTest.php b/tests/Feature/EntitlementsTest.php index ab37138c..8c35217f 100644 --- a/tests/Feature/EntitlementsTest.php +++ b/tests/Feature/EntitlementsTest.php @@ -2,7 +2,7 @@ namespace Tests\Feature; -use App\Contracts\Entitlements; +use Whilesmart\Entitlements\Contracts\Entitlements; use App\Jobs\ProcessChatMessageJob; use App\Models\ChatMessage; use App\Models\ChatSession; diff --git a/tests/Feature/ExportTest.php b/tests/Feature/ExportTest.php new file mode 100644 index 00000000..f79e04f6 --- /dev/null +++ b/tests/Feature/ExportTest.php @@ -0,0 +1,211 @@ +user = User::factory()->create(); + + $this->wallet = $this->user->wallets()->create([ + 'name' => 'Everyday', + 'balance' => 0, + 'currency' => 'USD', + ]); + + $this->category = $this->user->categories()->create([ + 'name' => 'Groceries', + 'type' => 'expense', + ]); + } + + private function createTransaction(array $overrides = []): array + { + $response = $this->actingAs($this->user)->postJson('/api/v1/transactions', array_merge([ + 'type' => 'expense', + 'amount' => 100, + 'description' => 'Weekly shop', + 'wallet_id' => $this->wallet->id, + 'categories' => [$this->category->id], + 'datetime' => '2026-03-15T10:00:00.000Z', + ], $overrides)); + + $response->assertStatus(201); + + return $response->json('data'); + } + + public function test_transactions_export_defaults_to_csv(): void + { + $this->createTransaction(); + + $response = $this->actingAs($this->user)->get('/api/v1/transactions/export'); + + $response->assertStatus(200); + $this->assertStringContainsString('text/csv', (string) $response->headers->get('content-type')); + $this->assertStringContainsString( + 'attachment; filename="transactions', + (string) $response->headers->get('content-disposition') + ); + + $content = $response->getContent(); + $this->assertStringContainsString('Weekly shop', $content); + $this->assertStringContainsString('Everyday', $content); + $this->assertStringContainsString('Groceries', $content); + } + + public function test_transactions_export_renders_xlsx_and_pdf(): void + { + $this->createTransaction(); + + $xlsx = $this->actingAs($this->user)->get('/api/v1/transactions/export?format=xlsx'); + $xlsx->assertStatus(200); + $this->assertStringContainsString('spreadsheetml', (string) $xlsx->headers->get('content-type')); + // An xlsx file is a zip archive; anything else means the writer failed. + $this->assertStringStartsWith('PK', $xlsx->getContent()); + + $pdf = $this->actingAs($this->user)->get('/api/v1/transactions/export?format=pdf'); + $pdf->assertStatus(200); + $this->assertStringContainsString('application/pdf', (string) $pdf->headers->get('content-type')); + $this->assertStringStartsWith('%PDF', $pdf->getContent()); + } + + public function test_transactions_export_honours_the_same_filters_as_the_list(): void + { + $this->createTransaction(['description' => 'In range', 'datetime' => '2026-03-15T10:00:00.000Z']); + $this->createTransaction(['description' => 'Out of range', 'datetime' => '2026-01-05T10:00:00.000Z']); + + $filters = 'date_from=2026-03-01&date_to=2026-03-31'; + + $list = $this->actingAs($this->user)->getJson("/api/v1/transactions?{$filters}"); + $list->assertStatus(200); + + $export = $this->actingAs($this->user)->get("/api/v1/transactions/export?{$filters}"); + $export->assertStatus(200); + + $content = $export->getContent(); + $this->assertStringContainsString('In range', $content); + $this->assertStringNotContainsString('Out of range', $content); + + $this->assertCount(1, $list->json('data.data')); + } + + public function test_transactions_export_only_covers_the_authenticated_user(): void + { + $this->createTransaction(['description' => 'Mine']); + + $other = User::factory()->create(); + $otherWallet = $other->wallets()->create(['name' => 'Theirs', 'balance' => 0, 'currency' => 'USD']); + $this->actingAs($other)->postJson('/api/v1/transactions', [ + 'type' => 'expense', + 'amount' => 50, + 'description' => 'Not mine', + 'wallet_id' => $otherWallet->id, + 'datetime' => '2026-03-15T10:00:00.000Z', + ])->assertStatus(201); + + $content = $this->actingAs($this->user)->get('/api/v1/transactions/export')->getContent(); + + $this->assertStringContainsString('Mine', $content); + $this->assertStringNotContainsString('Not mine', $content); + } + + public function test_export_requires_authentication(): void + { + $this->getJson('/api/v1/transactions/export')->assertStatus(401); + $this->getJson('/api/v1/reports/export')->assertStatus(401); + } + + public function test_unsupported_format_is_rejected(): void + { + $response = $this->actingAs($this->user)->getJson('/api/v1/transactions/export?format=docx'); + + $response->assertStatus(422); + $response->assertJsonPath('success', false); + $this->assertContains('csv', $response->json('errors.supported_formats')); + } + + /** + * dompdf lays the whole document out in memory, so it holds far less than + * the other formats. The limit has to be enforced per format, or a large + * selection exhausts the worker instead of returning an error. + */ + public function test_the_row_limit_is_enforced_per_format(): void + { + $pdfLimit = app(ExporterManager::class)->for('pdf')->maxRows(); + + // Bulk setup only; the export itself still goes over HTTP. + Transaction::factory()->count($pdfLimit + 1)->create([ + 'user_id' => $this->user->id, + 'wallet_id' => $this->wallet->id, + 'datetime' => now(), + ]); + + $pdf = $this->actingAs($this->user)->getJson('/api/v1/transactions/export?format=pdf'); + $pdf->assertStatus(422); + $pdf->assertJsonPath('errors.format', 'pdf'); + $pdf->assertJsonPath('errors.max_rows', $pdfLimit); + $this->assertGreaterThan( + $pdfLimit, + $pdf->json('errors.format_limits.csv'), + 'the error should point at a format that holds more' + ); + + $csv = $this->actingAs($this->user)->get('/api/v1/transactions/export?format=csv'); + $csv->assertStatus(200); + } + + public function test_report_export_renders_a_statement(): void + { + $this->createTransaction(['type' => 'income', 'amount' => 900, 'description' => 'Salary']); + $this->createTransaction(['type' => 'expense', 'amount' => 300, 'description' => 'Rent']); + + $response = $this->actingAs($this->user)->get('/api/v1/reports/export?format=csv&preset=all_time'); + + $response->assertStatus(200); + $content = $response->getContent(); + + $this->assertStringContainsString('Overview', $content); + $this->assertStringContainsString('Financial position', $content); + $this->assertStringContainsString('Total income', $content); + } + + public function test_report_export_rejects_wallets_the_user_does_not_own(): void + { + $other = User::factory()->create(); + $otherWallet = $other->wallets()->create(['name' => 'Theirs', 'balance' => 0, 'currency' => 'USD']); + + $response = $this->actingAs($this->user) + ->getJson("/api/v1/reports/export?wallet_ids={$otherWallet->id}"); + + $response->assertStatus(422); + $this->assertContains($otherWallet->id, $response->json('errors.invalid_wallet_ids')); + } + + public function test_export_route_does_not_shadow_a_transaction_lookup(): void + { + $transaction = $this->createTransaction(); + + $response = $this->actingAs($this->user)->getJson("/api/v1/transactions/{$transaction['id']}"); + + $response->assertStatus(200); + $this->assertEquals($transaction['id'], $response->json('data.id')); + } +} diff --git a/tests/Feature/HoldingsIntegrationTest.php b/tests/Feature/HoldingsIntegrationTest.php index f1965bc6..edc456cf 100644 --- a/tests/Feature/HoldingsIntegrationTest.php +++ b/tests/Feature/HoldingsIntegrationTest.php @@ -69,6 +69,24 @@ public function test_creating_for_another_owner_is_forbidden() ->assertStatus(403); } + public function test_creating_an_auto_holding_prices_it_from_coingecko_immediately() + { + Http::fake([ + 'api.coingecko.com/api/v3/simple/price*' => Http::response(['bitcoin' => ['usd' => 70000]], 200), + ]); + + $payload = $this->ownerPayload([ + 'price_source' => 'auto', 'provider' => 'coingecko', 'external_ref' => 'bitcoin', + ]); + unset($payload['unit_price']); + + $response = $this->actingAs($this->user)->postJson('/api/v1/holdings', $payload); + + $response->assertStatus(201); + $this->assertEquals(70000, $response->json('data.unit_price')); + $this->assertNotNull($response->json('data.last_priced_at')); + } + public function test_reprice_updates_auto_holdings_via_coingecko() { Http::fake([ diff --git a/tests/Feature/ImportTest.php b/tests/Feature/ImportTest.php index 1fae2b7c..0fba4b83 100644 --- a/tests/Feature/ImportTest.php +++ b/tests/Feature/ImportTest.php @@ -72,6 +72,34 @@ private function uploadCsv(string $content = '') return $data; } + public function test_api_csv_rows_with_non_positive_amounts_fail_instead_of_importing() + { + $content = "amount,currency,type,party,wallet,category,description,date\n". + "0,USD,expense,John Doe,Wallet1,Food,Zero row,2023-01-01\n". + 'abc,USD,expense,John Doe,Wallet1,Food,Junk row,2023-01-01'; + $this->uploadCsv($content); + + $response = $this->actingAs($this->user)->getJson('/api/v1/imports'); + $response->assertStatus(200); + $this->assertEquals(2, $response->json('data')[0]['failed_imports_count']); + $this->assertEquals(0, $this->user->transactions()->count()); + } + + public function test_api_csv_transfer_rows_import_as_a_transfer() + { + $content = "amount,currency,type,party,wallet,category,description,date\n". + "100,USD,+Transfer,,Wallet1,,Transfer out,2023-01-01\n". + '100,USD,-Transfer,,Wallet2,,Transfer in,2023-01-01'; + $this->uploadCsv($content); + + $response = $this->actingAs($this->user)->getJson('/api/v1/imports'); + $response->assertStatus(200); + $this->assertEquals(0, $response->json('data')[0]['failed_imports_count']); + + $this->assertEquals(1, $this->user->transfers()->count()); + $this->assertEquals(2, $this->user->transactions()->count()); + } + public function test_api_user_can_get_scheduled_imports() { $import = $this->uploadCsv(); diff --git a/tests/Feature/IntegrationsTest.php b/tests/Feature/IntegrationsTest.php index 44db177d..a3adacd0 100644 --- a/tests/Feature/IntegrationsTest.php +++ b/tests/Feature/IntegrationsTest.php @@ -2,7 +2,7 @@ namespace Tests\Feature; -use App\Contracts\Entitlements; +use Whilesmart\Entitlements\Contracts\Entitlements; use App\Contracts\Integration; use App\Contracts\IntegrationUi; use App\Models\User; diff --git a/tests/Feature/SchemaConformanceTest.php b/tests/Feature/SchemaConformanceTest.php new file mode 100644 index 00000000..e95186b9 --- /dev/null +++ b/tests/Feature/SchemaConformanceTest.php @@ -0,0 +1,164 @@ +verify(); + + $this->assertSame( + [], + $problems, + 'config/schema.php declares structures the database does not have: ' + . implode(', ', array_column($problems, 'detail')) + ); + } + + /** + * A migration that alters an existing table can reach one environment and + * not another, leaving a table that looks intact but rejects writes. The + * conformance spec is what turns that into a clear 503 instead, so every + * altered column has to be declared there. + */ + public function test_every_altered_column_is_declared_in_the_spec(): void + { + $declared = collect(config('schema.tables', [])) + ->map(fn (array $spec) => array_keys($spec['columns'] ?? [])); + + $undeclared = []; + + foreach ($this->alteredColumnsByTable() as $table => $columns) { + foreach ($columns as $column) { + if (! in_array($column, $declared->get($table, []), true)) { + $undeclared[] = "{$table}.{$column}"; + } + } + } + + $this->assertSame( + [], + $undeclared, + 'These columns are added by an ALTER migration but are not declared in ' + . 'config/schema.php, so schema:verify cannot detect them going missing: ' + . implode(', ', $undeclared) + ); + } + + /** + * Catches the reverse gap: an attribute the model writes on every insert + * that no migration ever creates. + */ + public function test_every_model_attribute_maps_to_a_real_column(): void + { + $missing = []; + + foreach ($this->eloquentModels() as $model) { + $table = $model->getTable(); + + if (! Schema::hasTable($table)) { + $missing[] = $table . ' (table missing for ' . $model::class . ')'; + + continue; + } + + $attributes = array_unique(array_merge( + $model->getFillable(), + array_keys($model->getAttributes()) + )); + + foreach ($attributes as $attribute) { + if (! Schema::hasColumn($table, $attribute)) { + $missing[] = "{$table}.{$attribute} (" . $model::class . ')'; + } + } + } + + $this->assertSame( + [], + $missing, + 'These model attributes have no matching column: ' . implode(', ', $missing) + ); + } + + /** + * Columns added by `Schema::table(...)` blocks across the app's own + * migrations, keyed by table. Migrations written in a shape this does not + * recognise are skipped rather than reported, so the check never fails on + * an unusual but correct migration. + * + * @return array> + */ + private function alteredColumnsByTable(): array + { + $byTable = []; + + foreach (glob(database_path('migrations/*.php')) as $path) { + $contents = (string) file_get_contents($path); + + // Everything after each `Schema::table('x'` up to the next one, so a + // file altering two tables attributes its columns to the right table. + $blocks = preg_split("/Schema::table\(\s*'/", $contents); + array_shift($blocks); + + foreach ($blocks as $block) { + if (! preg_match("/^(\w+)'/", $block, $tableMatch)) { + continue; + } + + $methods = implode('|', self::COLUMN_METHODS); + preg_match_all("/\\\$table->({$methods})\(\s*'(\w+)'/", $block, $columnMatches); + + foreach ($columnMatches[2] as $column) { + $byTable[$tableMatch[1]][] = $column; + } + } + } + + return array_map('array_unique', $byTable); + } + + /** + * @return array + */ + private function eloquentModels(): array + { + $models = []; + + foreach (glob(app_path('Models/*.php')) as $path) { + $class = 'App\\Models\\' . Str::before(basename($path), '.php'); + + if (! class_exists($class) || ! is_subclass_of($class, Model::class)) { + continue; + } + + if ((new ReflectionClass($class))->isAbstract()) { + continue; + } + + $models[] = new $class(); + } + + return $models; + } +} diff --git a/tests/Feature/SmartqlSchemaTest.php b/tests/Feature/SmartqlSchemaTest.php new file mode 100644 index 00000000..94d4b362 --- /dev/null +++ b/tests/Feature/SmartqlSchemaTest.php @@ -0,0 +1,295 @@ + */ + private array $schema; + + protected function setUp(): void + { + parent::setUp(); + + $this->schema = Yaml::parseFile(base_path('smartql.yml')); + } + + /** + * @return array + */ + private function entities(): array + { + return $this->schema['semantic_layer']['entities']; + } + + /** + * @return array + */ + private function allowedTables(): array + { + return $this->schema['security']['allowed_tables']; + } + + /** + * @return array + */ + private function requiredFilters(): array + { + return $this->schema['security']['required_filters']; + } + + public function test_the_file_parses(): void + { + $this->assertIsArray($this->schema); + $this->assertNotEmpty($this->entities()); + } + + public function test_every_entity_table_is_allowed(): void + { + foreach ($this->entities() as $name => $entity) { + $this->assertContains( + $entity['table'], + $this->allowedTables(), + "Entity '{$name}' describes a table the query tool may not read." + ); + } + } + + public function test_every_allowed_table_has_an_entity(): void + { + $tables = array_column($this->entities(), 'table'); + + foreach ($this->allowedTables() as $table) { + $this->assertContains($table, $tables, "Table '{$table}' is readable but undescribed."); + } + } + + public function test_every_filtered_table_is_allowed(): void + { + foreach (array_keys($this->requiredFilters()) as $table) { + $this->assertContains($table, $this->allowedTables(), "Table '{$table}' is filtered but unreadable."); + } + } + + /** + * The one that matters: a readable table with no filter returns every user's + * rows to whoever asks. + */ + public function test_every_readable_table_is_scoped_to_its_owner(): void + { + $global = ['exchange_rates']; + + foreach ($this->allowedTables() as $table) { + if (in_array($table, $global, true)) { + continue; + } + + $this->assertArrayHasKey( + $table, + $this->requiredFilters(), + "Table '{$table}' is readable by anyone: it has no required filter." + ); + } + } + + public function test_every_filter_actually_constrains_something(): void + { + foreach ($this->requiredFilters() as $table => $filter) { + $this->assertTrue( + isset($filter['column']) || isset($filter['through']), + "The filter on '{$table}' binds nothing." + ); + } + } + + public function test_through_filters_point_at_a_table_that_is_itself_scoped(): void + { + foreach ($this->requiredFilters() as $table => $filter) { + if (! isset($filter['through'])) { + continue; + } + + [$parent] = explode('.', $filter['through']['references']); + + $this->assertArrayHasKey( + $parent, + $this->requiredFilters(), + "Table '{$table}' borrows scoping from '{$parent}', which has none of its own." + ); + } + } + + public function test_polymorphic_owners_pin_their_type(): void + { + foreach ($this->requiredFilters() as $table => $filter) { + if (($filter['column'] ?? null) !== 'owner_id') { + continue; + } + + $this->assertArrayHasKey( + 'constants', + $filter, + "Table '{$table}' filters on owner_id without pinning owner_type, so another owner type's rows leak." + ); + } + } + + public function test_column_references_point_at_a_described_entity(): void + { + $entities = $this->entities(); + + foreach ($entities as $name => $entity) { + foreach ($entity['columns'] as $column => $definition) { + if (! isset($definition['references'])) { + continue; + } + + [$target] = explode('.', $definition['references']); + + $this->assertArrayHasKey( + $target, + $entities, + "{$name}.{$column} references '{$target}', which is not described." + ); + } + } + } + + public function test_owner_columns_are_hidden_from_results(): void + { + foreach ($this->entities() as $name => $entity) { + foreach (['user_id', 'owner_id', 'owner_type'] as $column) { + if (! isset($entity['columns'][$column])) { + continue; + } + + $this->assertTrue( + $entity['columns'][$column]['hidden'] ?? false, + "{$name}.{$column} is internal plumbing and should not reach an answer." + ); + } + } + } + + /** + * A transfer writes a paired income and expense leg for money that never + * left the user. Counting either as real income or spending double-counts + * every transfer. + */ + public function test_income_and_expense_rules_exclude_transfer_legs(): void + { + $rules = collect($this->schema['semantic_layer']['business_rules'])->keyBy('name'); + + $this->assertStringContainsString('transfer_id IS NULL', $rules['income']['definition']); + $this->assertStringContainsString('transfer_id IS NULL', $rules['expense']['definition']); + } + + public function test_the_transactions_entity_documents_transfer_id(): void + { + $this->assertArrayHasKey('transfer_id', $this->entities()['transactions']['columns']); + } + + public function test_the_schema_matches_the_registered_resources(): void + { + $entities = $this->entities(); + + foreach ($this->app->make(ModelResourceRegistry::class)->all() as $resource) { + $this->assertArrayHasKey( + $resource->name, + $entities, + "Resource '{$resource->name}' is missing from smartql.yml. Regenerate it with agents:export-schema." + ); + + $this->assertSame($resource->table, $entities[$resource->name]['table']); + $this->assertSame( + array_map(fn ($field) => $field->name, $resource->readable), + array_keys($entities[$resource->name]['columns']), + "The columns of '{$resource->name}' have drifted from its model. Regenerate smartql.yml." + ); + } + } + + public function test_registered_resources_that_are_scoped_are_filtered_in_the_schema(): void + { + $filters = $this->requiredFilters(); + + foreach ($this->app->make(ModelResourceRegistry::class)->all() as $resource) { + if ($resource->global) { + continue; + } + + $this->assertArrayHasKey($resource->table, $filters, "Resource '{$resource->name}' is unscoped in the schema."); + $this->assertSame($resource->ownerKey, $filters[$resource->table]['column'] ?? null); + } + } + + public function test_prompt_examples_are_scoped_to_the_caller(): void + { + foreach ($this->schema['prompts']['examples'] as $example) { + $this->assertStringContainsString( + ':user_id', + $example['sql'], + "The example \"{$example['question']}\" teaches an unscoped query." + ); + } + } + + public function test_no_global_resource_is_left_writable_by_accident(): void + { + foreach ($this->app->make(ModelResourceRegistry::class)->all() as $resource) { + if (! $resource->global) { + continue; + } + + $this->assertFalse($resource->writeEnabled, "Global resource '{$resource->name}' must not be writable by an agent."); + } + } + + public function test_every_resource_is_either_owned_or_deliberately_global(): void + { + foreach ($this->app->make(ModelResourceRegistry::class)->all() as $resource) { + $this->assertTrue( + $resource->isOwned() || $resource->global, + "Resource '{$resource->name}' is neither owned nor global, so its rows belong to nobody." + ); + } + } + + public function test_label_columns_exist_on_their_entity(): void + { + foreach ($this->entities() as $name => $entity) { + if (! isset($entity['label_column'])) { + continue; + } + + $this->assertArrayHasKey( + $entity['label_column'], + $entity['columns'], + "Entity '{$name}' names a label column it does not describe." + ); + } + } + + public function test_resource_label_columns_match_the_schema(): void + { + $entities = $this->entities(); + + foreach ($this->app->make(ModelResourceRegistry::class)->all() as $resource) { + $this->assertSame( + $resource->labelColumn, + $entities[$resource->name]['label_column'] ?? null, + "The label column of '{$resource->name}' has drifted from its model." + ); + } + } +} diff --git a/tests/Feature/SyncableModelsTest.php b/tests/Feature/SyncableModelsTest.php index 923c9156..f46c2a21 100644 --- a/tests/Feature/SyncableModelsTest.php +++ b/tests/Feature/SyncableModelsTest.php @@ -4,11 +4,13 @@ use App\Models\Category; use App\Models\Group; +use App\Models\ModelSyncState; use App\Models\Party; use App\Models\Transaction; use App\Models\Transfer; use App\Models\User; use App\Models\Wallet; +use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -104,4 +106,70 @@ public function user_can_not_update_a_transaction_if_the_updated_at_is_less_than $this->assertEquals('The updated at date is less than the created at date', $message); } + + /** @test */ + public function soft_deleting_a_transaction_over_the_api_updates_last_synced_at() + { + $user = User::factory()->create(); + $wallet = Wallet::factory()->create(['user_id' => $user->id]); + + /** @var Transaction */ + $transaction = Transaction::factory()->create([ + 'user_id' => $user->id, + 'wallet_id' => $wallet->id, + ]); + + $before = Carbon::parse($transaction->syncState->last_synced_at); + $this->travel(5)->minutes(); + + $this->actingAs($user) + ->deleteJson('/api/v1/transactions/' . $transaction->id) + ->assertStatus(200); + + $state = ModelSyncState::where('syncable_type', Transaction::class) + ->where('syncable_id', $transaction->id) + ->first(); + + $this->assertTrue(Carbon::parse($state->last_synced_at)->gt($before)); + } + + /** @test */ + public function soft_deleting_a_wallet_over_the_api_updates_last_synced_at() + { + $user = User::factory()->create(); + $wallet = Wallet::factory()->create(['user_id' => $user->id]); + + $before = Carbon::parse($wallet->syncState->last_synced_at); + $this->travel(5)->minutes(); + + $this->actingAs($user) + ->deleteJson('/api/v1/wallets/' . $wallet->id) + ->assertStatus(200); + + $state = ModelSyncState::where('syncable_type', Wallet::class) + ->where('syncable_id', $wallet->id) + ->first(); + + $this->assertTrue(Carbon::parse($state->last_synced_at)->gt($before)); + } + + /** @test */ + public function soft_deleting_a_category_over_the_api_updates_last_synced_at() + { + $user = User::factory()->create(); + $category = Category::factory()->create(['user_id' => $user->id]); + + $before = Carbon::parse($category->syncState->last_synced_at); + $this->travel(5)->minutes(); + + $this->actingAs($user) + ->deleteJson('/api/v1/categories/' . $category->id) + ->assertStatus(204); + + $state = ModelSyncState::where('syncable_type', Category::class) + ->where('syncable_id', $category->id) + ->first(); + + $this->assertTrue(Carbon::parse($state->last_synced_at)->gt($before)); + } } diff --git a/tests/Feature/TransactionsTest.php b/tests/Feature/TransactionsTest.php index ba462446..f9eba506 100644 --- a/tests/Feature/TransactionsTest.php +++ b/tests/Feature/TransactionsTest.php @@ -748,6 +748,58 @@ public function test_api_user_can_create_recurring_transactions_with_end_date() $this->assertNotNull($transaction['recurring_rules']['recurrence_ends_at']); } + public function test_api_user_can_create_a_recurring_transaction_from_a_form_request() + { + $response = $this->actingAs($this->user)->post('/api/v1/transactions', [ + 'type' => 'expense', + 'amount' => '100', + 'wallet_id' => (string) $this->wallet->id, + 'party_id' => (string) $this->party->id, + 'datetime' => '2025-04-30T15:17:54.120Z', + 'is_recurring' => 'true', + 'recurrence_period' => 'monthly', + 'recurrence_interval' => '2', + 'files' => [UploadedFile::fake()->image('receipt.png')], + ]); + + $response->assertStatus(201); + + $transaction = $response->json('data'); + $this->assertEquals('monthly', $transaction['recurring_rules']['recurrence_period']); + $this->assertEquals(2, $transaction['recurring_rules']['recurrence_interval']); + $this->assertCount(1, $transaction['files']); + } + + public function test_api_form_request_can_turn_a_recurring_transaction_off() + { + $response = $this->actingAs($this->user)->post('/api/v1/transactions', [ + 'type' => 'expense', + 'amount' => '100', + 'wallet_id' => (string) $this->wallet->id, + 'datetime' => '2025-04-30T15:17:54.120Z', + 'is_recurring' => 'false', + ]); + + $response->assertStatus(201); + $this->assertNull($response->json('data.recurring_rules')); + } + + public function test_api_rejects_an_is_recurring_flag_it_cannot_read() + { + $response = $this->actingAs($this->user)->post('/api/v1/transactions', [ + 'type' => 'expense', + 'amount' => '100', + 'wallet_id' => (string) $this->wallet->id, + 'datetime' => '2025-04-30T15:17:54.120Z', + 'is_recurring' => 'sometimes', + 'recurrence_period' => 'monthly', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('is_recurring'); + $this->assertDatabaseCount('transactions', 0); + } + public function test_api_validates_recurring_transaction_parameters() { // Test missing recurrence_period when is_recurring is true @@ -936,6 +988,47 @@ public function test_api_user_can_update_their_transactions_with_client_id() $this->assertEquals("$deviceToken:$clientId", $response->json('data.client_generated_id')); } + public function test_api_user_can_attach_a_client_id_with_a_client_id_only_update() + { + $expense = $this->createTransaction('expense'); + $clientId = '245cb3df-df3a-428b-a908-e5f74b8d58a5'; + $deviceToken = '245cb3df-df3a-428b-a908-e5f74b8d58a4'; + + $response = $this->actingAs($this->user)->putJson('/api/v1/transactions/' . $expense['id'], [ + 'client_id' => "$deviceToken:$clientId", + ]); + + $response->assertStatus(200); + + $transaction = Transaction::find($expense['id']); + $this->assertEquals($clientId, $transaction->syncState->client_generated_id); + $this->assertEquals(100, $transaction->amount); + } + + public function test_api_updating_with_a_new_client_id_does_not_overwrite_the_existing_one() + { + $deviceToken = '245cb3df-df3a-428b-a908-e5f74b8d58a4'; + $originalClientId = '245cb3df-df3a-428b-a908-e5f74b8d58a5'; + + $response = $this->actingAs($this->user)->postJson('/api/v1/transactions', [ + 'type' => 'expense', + 'amount' => 100, + 'wallet_id' => $this->wallet->id, + 'party_id' => $this->party->id, + 'datetime' => '2025-04-30T15:17:54.120Z', + 'client_id' => "$deviceToken:$originalClientId", + ]); + $response->assertStatus(201); + $transactionId = $response->json('data.id'); + + $this->actingAs($this->user)->putJson('/api/v1/transactions/' . $transactionId, [ + 'client_id' => "$deviceToken:245cb3df-df3a-428b-a908-e5f74b8d58a6", + ])->assertStatus(200); + + $transaction = Transaction::find($transactionId); + $this->assertEquals($originalClientId, $transaction->syncState->client_generated_id); + } + public function test_api_user_cannot_create_transaction_with_invalid_client_id_format() { // Test with client_id that has no colon diff --git a/tests/Feature/TransferTest.php b/tests/Feature/TransferTest.php index bec887f7..48117a72 100644 --- a/tests/Feature/TransferTest.php +++ b/tests/Feature/TransferTest.php @@ -185,6 +185,63 @@ public function test_api_user_cannot_transfer_zero_amount() $response->assertStatus(422); } + public function test_transfer_preserves_decimals_in_the_received_amount() + { + $user = User::factory()->create(); + $fromWallet = Wallet::factory()->create(['user_id' => $user->id, 'balance' => 1000]); + $toWallet = Wallet::factory()->create(['user_id' => $user->id, 'balance' => 0]); + + $this->actingAs($user)->postJson('/api/v1/transfers', [ + 'amount' => 100.75, + 'from_wallet_id' => $fromWallet->id, + 'to_wallet_id' => $toWallet->id, + ])->assertStatus(201); + + $toWallet->refresh(); + $this->assertEquals(100.75, $toWallet->balance); + + $incomeTransaction = Transaction::where('wallet_id', $toWallet->id)->first(); + $this->assertEquals(100.75, $incomeTransaction->amount); + } + + public function test_cross_currency_transfer_keeps_a_fractional_received_amount() + { + $user = User::factory()->create(); + $fromWallet = Wallet::factory()->create(['user_id' => $user->id, 'balance' => 1000, 'currency' => 'USD']); + $toWallet = Wallet::factory()->create(['user_id' => $user->id, 'balance' => 0, 'currency' => 'BTC']); + + $this->actingAs($user)->postJson('/api/v1/transfers', [ + 'amount' => 50, + 'from_wallet_id' => $fromWallet->id, + 'to_wallet_id' => $toWallet->id, + 'exchange_rate' => 0.0005, + ])->assertStatus(201); + + $toWallet->refresh(); + $this->assertEquals(0.025, $toWallet->balance); + + $incomeTransaction = Transaction::where('wallet_id', $toWallet->id)->first(); + $this->assertEquals(0.025, $incomeTransaction->amount); + } + + public function test_transfer_fails_when_the_received_amount_rounds_to_zero() + { + $user = User::factory()->create(); + $fromWallet = Wallet::factory()->create(['user_id' => $user->id, 'balance' => 1000, 'currency' => 'USD']); + $toWallet = Wallet::factory()->create(['user_id' => $user->id, 'balance' => 0, 'currency' => 'BTC']); + + $response = $this->actingAs($user)->postJson('/api/v1/transfers', [ + 'amount' => 0.01, + 'from_wallet_id' => $fromWallet->id, + 'to_wallet_id' => $toWallet->id, + 'exchange_rate' => 0.001, + ]); + + $response->assertStatus(422); + $this->assertEquals(0, Transfer::count()); + $this->assertEquals(1000, $fromWallet->refresh()->balance); + } + public function test_transfer_with_client_id_stores_sync_state() { $user = User::factory()->create();