whisper-money/app/Http/Controllers
Víctor Falcón 2f00a56272
fix(budgets): re-derive budget membership when labels are attached without a model event (#787)
## 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.
2026-08-12 12:48:47 +02:00
..
Ai fix(security): scope job-status endpoints to owner + feature-area fixes (#627) 2026-07-03 14:49:32 +02:00
Api feat(budgets): count shared accounts at the owner's percentage (#786) 2026-08-12 12:47:43 +02:00
Auth fix: verify email via signed link without requiring login (#490) 2026-06-05 10:01:32 +02:00
OpenBanking refactor(open-banking): bring the authorization callback back under the complexity threshold (#784) 2026-08-12 09:47:22 +00:00
Settings refactor(accounts): split store/update in the account controller (#776) 2026-08-11 13:58:47 +00:00
Sync refactor(api): standardize serialization via model $hidden (#492) 2026-06-05 13:57:34 +02:00
AccountBalanceController.php feat: investment benefits — show gains/losses on investment accounts (#140) 2026-02-23 13:59:10 +01:00
AccountController.php perf(accounts): defer the account ledger prop on show (#632) 2026-07-03 17:40:08 +02:00
BudgetController.php feat(mcp): budget tools — read, create, edit and delete (#779) 2026-08-11 14:28:53 +00:00
CashflowController.php refactor(api): standardize serialization via model $hidden (#492) 2026-06-05 13:57:34 +02:00
Controller.php Set up a fresh Laravel app 2025-11-07 12:01:36 +00:00
DashboardController.php feat(accounts): count shared accounts at the owner's percentage (#750) 2026-08-11 13:26:54 +00:00
EncryptionController.php refactor(encryption): strip client-side transaction encryption (#514) 2026-06-20 16:13:26 +00:00
IntegrationRequestController.php feat(integration-requests): add done status and fix review command crash on orphaned author (#601) 2026-06-27 14:42:09 +00:00
LoanDetailController.php feat(loans): backfill historical balances on loan creation (#322) 2026-04-24 13:09:34 +01:00
OnboardingController.php fix(onboarding): don't trap users on the syncing step when a bank sync fails (#745) 2026-08-09 18:40:49 +02:00
ReEvaluateTransactionRulesController.php fix(security): scope job-status endpoints to owner + feature-area fixes (#627) 2026-07-03 14:49:32 +02:00
RealEstateDetailController.php feat(accounts): add real estate asset tracking (#241) 2026-03-24 10:21:32 +00:00
RoadmapController.php feat(roadmap): add a public roadmap page mirrored from UserJot (#778) 2026-08-11 14:14:59 +00:00
RobotsController.php User Onboarding Flow (#23) 2025-12-12 13:06:08 +01:00
SitemapController.php feat(roadmap): add a public roadmap page mirrored from UserJot (#778) 2026-08-11 14:14:59 +00:00
SubscriptionController.php feat(subscriptions): end the trial experiment and make the trial length per plan (#762) 2026-08-12 10:59:55 +02:00
TransactionController.php fix(budgets): re-derive budget membership when labels are attached without a model event (#787) 2026-08-12 12:48:47 +02:00