fix(transactions): combine category and label filters with OR (#495)

## Problem

On the transactions page, the **Category** and **Labels** filters were
combined with **AND**. Selecting both a category and a label only showed
transactions that matched the category *and* carried that label.

## Fix

In `Transaction::scopeApplyFilters`, both filters are now wrapped in a
single `where` group with the label condition using `orWhereHas`, so
they combine as **OR**: a transaction matches if it's in a selected
category **or** has a selected label.

- Multi-category OR, category-tree expansion, and the *uncategorized*
option are preserved.
- Other filters (date, amount, account, search) remain AND.

## Tests

Added `category and label filters combine with OR` to
`TransactionFilterTest`. All 34 filter + bulk-update tests pass; pint
clean.
This commit is contained in:
Víctor Falcón 2026-06-05 17:11:02 +02:00 committed by GitHub
parent 744d874464
commit af87ac7560
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 66 additions and 17 deletions

View File

@ -132,25 +132,43 @@ class Transaction extends Model
$query->where('amount', '<=', $filters['amount_max'] * 100);
}
if (! empty($filters['category_ids'])) {
$ids = collect($filters['category_ids']);
$hasUncategorized = $ids->contains('uncategorized');
$realIds = $ids->reject(fn ($id) => $id === 'uncategorized')->values()->all();
$hasCategoryFilter = ! empty($filters['category_ids']);
$hasLabelFilter = ! empty($filters['label_ids']);
if ($realIds !== []) {
$userId = $filters['user_id'] ?? Category::query()->whereIn('id', $realIds)->value('user_id');
if ($hasCategoryFilter || $hasLabelFilter) {
$realIds = [];
$hasUncategorized = false;
if ($userId !== null) {
$realIds = app(CategoryTree::class)->expand($userId, $realIds);
if ($hasCategoryFilter) {
$ids = collect($filters['category_ids']);
$hasUncategorized = $ids->contains('uncategorized');
$realIds = $ids->reject(fn ($id) => $id === 'uncategorized')->values()->all();
if ($realIds !== []) {
$userId = $filters['user_id'] ?? Category::query()->whereIn('id', $realIds)->value('user_id');
if ($userId !== null) {
$realIds = app(CategoryTree::class)->expand($userId, $realIds);
}
}
}
$query->where(function (Builder $q) use ($realIds, $hasUncategorized) {
if (! empty($realIds)) {
$q->whereIn('category_id', $realIds);
$labelIds = $filters['label_ids'] ?? [];
$query->where(function (Builder $outer) use ($hasCategoryFilter, $realIds, $hasUncategorized, $hasLabelFilter, $labelIds) {
if ($hasCategoryFilter) {
$outer->where(function (Builder $q) use ($realIds, $hasUncategorized) {
if (! empty($realIds)) {
$q->whereIn('category_id', $realIds);
}
if ($hasUncategorized) {
$q->orWhereNull('category_id');
}
});
}
if ($hasUncategorized) {
$q->orWhereNull('category_id');
if ($hasLabelFilter) {
$outer->orWhereHas('labels', fn (Builder $q) => $q->whereIn('labels.id', $labelIds));
}
});
}
@ -159,10 +177,6 @@ class Transaction extends Model
$query->whereIn('account_id', $filters['account_ids']);
}
if (! empty($filters['label_ids'])) {
$query->whereHas('labels', fn (Builder $q) => $q->whereIn('labels.id', $filters['label_ids']));
}
if (! empty($filters['creditor_name'])) {
$term = '%'.$filters['creditor_name'].'%';
$query->where('creditor_name', 'LIKE', $term);

View File

@ -215,6 +215,41 @@ test('filter by label', function () {
);
});
test('category and label filters combine with OR', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$label = Label::factory()->create(['user_id' => $this->user->id]);
$txWithCategory = Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $category->id,
]);
$txWithLabel = Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
]);
$txWithLabel->labels()->attach($label->id);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'category_ids' => $category->id,
'label_ids' => $label->id,
]));
$ids = collect($response->viewData('page')['props']['transactions']['data'])->pluck('id');
expect($ids)->toHaveCount(2)
->and($ids)->toContain($txWithCategory->id)
->and($ids)->toContain($txWithLabel->id);
});
test('search matches description', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,