## Problem
Since #781 a catch-all budget ("Not budgeted") yields to any budget that
tracks a
transaction by category **or** label in a period covering its date.
Reassignment is
driven by `TransactionCreated` / `TransactionUpdated`, whose listener is
`AssignTransactionToBudget`.
Pivot writes and query-builder mass updates fire no model event. So
every path that
attaches a label without a real `save()` left the transaction assigned
to whatever it
was before — typically stuck in the catch-all and missing from the
budget that tracks
its label.
In production, 44 expenses carrying one label never entered their label
budget:
`label_transaction.created_at` is ~10 minutes later than
`transactions.updated_at` on
each of them, i.e. the listener ran before the label existed and nothing
ran after.
## The paths that were broken
| Path | Write | Event |
|---|---|---|
| `AutomationRuleService::applyActions` | `saveQuietly()` +
`syncWithoutDetaching` | none |
| `AutomationRuleService::applyRuleActionsToTransactions` |
`LabelTransaction::insertOrIgnore` + mass category `update()` | none |
| `Mcp\Tools\LabelTransaction` | `syncWithoutDetaching` / `detach` |
none |
| `TransactionController::bulkUpdate` | mass category `update()` +
`labels()->sync()` | none |
The last one was found during review and is the surface users hit most —
the
bulk-actions bar in the transactions table. Its `syncLabels()` helper
does
`sync()` then `save()`, but those models are loaded before the mass
update and
nothing dirties them, so `Model::save()` skips `performUpdate()` and
fires nothing.
The docblock claiming the `save()` bumped `updated_at` was wrong and is
corrected.
## The fix
A dedicated `ReassignTransactionsToBudgets` job, dispatched from each
path, rather
than re-broadcasting `TransactionUpdated` — which would also re-run the
automation
rules that dispatched it. The job takes ids, not models, so a retry
never works from
a stale payload.
Batching, so no path queues one job per row:
- the bulk rule apply dispatches per batch of 500 ids (the AI suggestion
path is the
one caller that can hand it an unbounded list)
- `ReEvaluateTransactionRulesJob` loops `applyRules()` over the user's
whole history,
so `applyRules()` takes `reassignBudgets` and that job batches one job
per chunk
- `TransactionController::bulkUpdate` dispatches once for the whole
selection
Notifications are suppressed (`notify: false`) everywhere the change is
an
administrative edit rather than new spending: bulk rule applies,
re-evaluating rules
over history, the bulk-actions bar, and MCP relabelling. Otherwise
removing a label
would announce a months-old expense as new in the catch-all budget. The
single
transaction matched by a rule at creation time keeps notifications on.
Suppressing
them leaves `close_to_limit_notified` / `over_limit_notified` unclaimed,
so the next
genuine transaction into that budget still alerts.
A note never changes which budget counts a transaction, so a note-only
rule no longer
earns a reassignment on either path.
`applyRuleActionsToTransactions` was doing category, note and label work
inline; the
three branches are extracted so the added dispatch keeps the method
under the
repo's complexity-10 gate.
## Deploy step
This stops the state from going stale again, it does not repair what is
already
stale. After deploy:
```
php artisan budgets:reassign-labeled [--user=<email>] [--dry-run]
```
## Tests
`tests/Feature/LabelBudgetReassignmentTest.php` covers all four paths
plus the
re-evaluate batching, each asserting the transaction actually leaves the
catch-all
and lands in the label budget. All seven fail on `main` and pass here.
`AutomationRuleApplicationTest` now pins that the bulk apply queues
exactly one
reassignment job and that it is silent.
## QA
Verified in the browser against the running app with a throwaway
account: a
catch-all budget and a label budget over the same period, four expenses
starting in
the catch-all.
- Selecting all four in the transactions table and applying the label
moves
**Not budgeted $540.00 → $0.00** and **Miami 26 $0.00 → $540.00**,
confirmed in
`budget_transactions`.
- "Remove all labels" hands them back to the catch-all.
## Demo
<!-- PLACEHOLDER: drag the QA video here -->
https://github.com/user-attachments/assets/5a135257-42cf-42a7-a66a-3f76bf773e0c
## Follow-ups (not in this PR)
- Soft-deleting a label leaves its budget still counting the
transactions; the
catch-all never takes them back.
- `CategoryTree::deleteSubtree` mass-nulls `category_id` with no event,
so cascade
category deletion leaves transactions in their old category budget.
- `BudgetService::create` only adds rows for a new budget's historical
transactions;
it never removes their catch-all rows, so creating a label budget next
to an
existing catch-all double-counts until something else reassigns them.
## Why
The ChatGPT app directory rejects the submission with:
> Every MCP tool must set readOnlyHint, openWorldHint, destructiveHint
to true or false.
We only ever declared one hint per tool — `#[IsReadOnly]` on the reads,
`#[IsDestructive]` on the writes — so the other two were absent from
`tools/list` and the portal's scan flagged all 23 tools.
## What
- `McpTool::annotations()` now defaults all three hints, so every tool
reports `readOnlyHint`, `destructiveHint` and `openWorldHint`
explicitly. The attributes still override: `#[IsReadOnly]` on the eight
read tools, `#[IsDestructive]` on the four deletes.
- `openWorldHint` is always `false`: every tool reads or writes the
user's own account, never the open web.
- `destructiveHint` drops to `false` on the eleven
create/update/categorize/label tools. Marking them destructive was wrong
— the directory reserves it for irreversible operations — and it made
ChatGPT ask for confirmation on every write, including recategorizing a
transaction.
- Tool descriptions trimmed to the portal's 200-character cap (nine were
longer, `create_automation_rule` ran to 524). The cuts are facts the
server instructions already state — amounts in minor units,
whole-account scope. The JsonLogic variable list and example move to the
`rules_json` schema field, which the model still reads and the form does
not cap.
- `chatgpt-app-submission.json` is the submission-import file the portal
accepts, carrying the listing metadata, the per-tool hints with their
required justifications, and the positive/negative test cases.
## Testing
`tests/Unit/Mcp/ToolAnnotationsTest.php` pins both contracts: every tool
declares all three hints with `readOnlyHint`/`destructiveHint` matching
the expected tool lists, and no description exceeds 200 characters.
`tests/Feature/Mcp` still passes.
## MCP Phase 2 — write tools
> **Stacked on #689** (`mcp-functionality`). Base this PR on
`mcp-functionality`, not `main`, and merge it **after** #689.
Phase 1 shipped a read-only MCP server for Pro accounts. This adds the
**write** surface and re-enables the read/read-write token scope the UI
dropped in PR1.
### Write tools
A new `WriteTool` base extends `McpTool`: on top of the Pro-plan gate it
requires the calling token to carry `mcp:write`, returning a clear error
for read-only tokens. Each concrete tool is annotated `#[IsDestructive]`
(PHP attributes aren't inherited, so the annotation lives on each tool,
not the base — a docblock on `WriteTool` notes this).
- `create_transaction` — manual (non-connected) accounts only; forces
`source = manually_created`.
- `update_transaction` / `delete_transaction` — manually-created
transactions only; bank/imported ones stay locked.
- `categorize_transaction` — sets/clears the category on **any**
transaction (imported included), marking it `category_source = manual`.
- `label_transaction` — add/remove labels on **any** transaction.
- `create_balance` — balance snapshot on manual accounts only.
- `create_category` / `update_category` / `delete_category` — mirrors
the settings controller (parent/depth/cycle rules, cashflow derivation,
child strategies).
- `create_label` / `update_label` / `delete_label`.
- `create_automation_rule` / `update_automation_rule` /
`delete_automation_rule` — JsonLogic conditions + category/label
actions, at least one action required.
- `list_labels` — a small **read** tool added so label ids are
discoverable (label/automation tools are unusable without it).
### Guardrails
Write tools never touch bank-sourced data: the existing
`TransactionSource` enum and `Account::isConnected()` are the barriers,
reused not reinvented. There is no server-side write confirmation
(client-controlled, accepted decision) — hence `#[IsDestructive]`.
### Token scope
`StoreMcpTokenRequest` re-adds `scope` (`read` | `read_write`); the
controller grants `['mcp:read']` or `['mcp:read', 'mcp:write']`. The
settings page gets its scope selector back with honest copy (new strings
added to `lang/es.json`). The `/mcp` route stays gated on
`abilities:mcp:read` — any MCP token can connect and read; the per-tool
`mcp:write` check is what blocks writes.
### Tests
Happy path + guardrail failures for every write tool, the
read-only-token rejection (via a real read-only PAT so the `tokenCan`
gate runs exactly as over HTTP), cross-user isolation, the inherited Pro
gate, and read/read_write scope validation.
### Notes
- `AutomationRule::labels()` gained a generic return annotation (needed
for larastan level 5 on the new label mapping).
### Verification
- `vendor/bin/pint --test` ✅
- `vendor/bin/phpstan analyse` (larastan level 5) — 0 errors ✅
- `php artisan test tests/Feature/Mcp
tests/Feature/Settings/McpTokenTest.php
tests/Feature/LocalizationTest.php` ✅
- `prettier --check` / `eslint` on `settings/mcp.tsx` ✅