## Why
`TransactionController::bulkUpdate` sat at cyclomatic complexity **18**,
and the cause was a duplicated query. "Which transactions does this
apply to?" — explicit ids, else the active filters, else everything —
was written twice: once to load the rows, once to run the mass update.
```php
// to load them
if ($transactionIds && count($transactionIds) > 0) { $query->whereIn('id', $transactionIds); … }
elseif ($filters !== null) { $query->applyFilters($filters); … }
else { … }
// …and again, 40 lines later, to update them
$updateQuery = Transaction::query()->where('user_id', $user->id);
if ($transactionIds && count($transactionIds) > 0) { $updateQuery->whereIn('id', $transactionIds); }
elseif ($filters !== null) { $updateQuery->applyFilters($filters); }
```
Two copies of the selection rule is exactly the kind of thing that
drifts: change one and the update touches a different set of rows than
the check validated.
## What changed
| new method | job |
|---|---|
| `bulkSelection()` | the selection rule, in one place, called for both
the read and the update |
| `bulkUpdateAttributes()` | the columns to write; records the category
override first, while the rows still hold the old value |
| `syncLabels()` | replaces the label relations row by row |
`bulkUpdate` itself is now the flow: select, authorize, collect, update,
respond.
## Behaviour
No change. One thing the refactor surfaced: the old code guarded with
`$transactionIds && count($transactionIds) > 0`, treating an empty array
as "no selection". That branch is unreachable —
`BulkUpdateTransactionsRequest` validates `transaction_ids` as
`['array', 'min:1']`, so an empty array is a 422 before the controller
runs. The guard is now a plain `!== null` and a comment names the rule
it leans on.
## Metrics
| | before | after |
|---|---|---|
| `bulkUpdate` | 18 | 8 |
| `bulkSelection` / `bulkUpdateAttributes` / `syncLabels` (new) | — | 3
/ 6 / 2 |
## Testing
`BulkUpdateTransactionsTest`, `TransactionTest`, `TransactionFilterTest`
— 92 tests green, plus one new. The existing suite covers every
selection path (by ids, by date/amount/category filters, everything when
neither is given) and the 403 for ids the caller does not own, which is
the check most at risk from touching the selection.
The new test pins the empty-array case: the request rules reject it with
422 rather than letting it through as an empty selection, which is what
makes the simplified guard safe. PHPStan clean.