feat(transactions): save and reuse transaction filters (#496)

## What

Adds **saved filters** on the transactions page so users can name a set
of filters and reuse them later. The whole feature is gated behind a new
\`transaction-analysis\` Pennant feature flag (off by default).

## Why

Reusing the same filter combinations (e.g. "Japan trip", "Utilities") is
currently manual every time. This ports the saved-filters work from the
\`analysis-page\` branch, scoped down to **only** the transactions page
— the analysis screen and unrelated query changes from that branch are
intentionally left out.

## Changes

**Feature flag**
- \`App\Features\TransactionAnalysis\` — class-based flag, resolves
\`false\` by default.
- Shared to the frontend as \`features.transactionAnalysis\` and added
to the \`Features\` type.

**Backend (saved filters)**
- \`SavedFilter\` model (UUID, \`filters\` json cast, user belongsTo) +
migration + factory.
- \`Api\SavedFilterController\` — user-scoped index/store/update/destroy
under \`/api/saved-filters\`, ownership enforced with \`abort_unless\`.
- \`StoreSavedFilterRequest\` / \`UpdateSavedFilterRequest\` — name
unique per user, snake_case filter rules.

**Frontend**
- \`transaction-filter-serialization.ts\` —
serialize/deserialize/fingerprint between UI filter state and the
persisted snake_case shape.
- \`SavedFilters\` dropdown — load/save/update/delete with active +
dirty indicators.
- Integrated into the filter bar via an opt-in \`enableSavedFilters\`
prop, so it renders **only** on the transactions page (budgets/accounts
reuse the same component and stay unchanged). Render is also gated by
the feature flag.

**Filter bar UX**
- Split search + filters + saved searches onto their own row, separate
from the actions row (Categorize / Add transactions / columns), which
were getting cramped.
- On mobile, the text search moves into the filters popover.

## Reviewer notes
- Backend endpoints are **not** flag-gated; only the UI is, per the
request.
- Two-layer scoping: page opt-in prop **and** feature flag — flag off
means no UI anywhere.
- Translation keys fall back to the key (English); \`es.json\` entries
not added, matching the source branch.

## Testing
- \`tests/Feature/SavedFilterTest.php\` — 9 passing (auth, per-user
listing/uniqueness, ownership on update/delete).
- Pint, Prettier, ESLint clean. No new TypeScript errors introduced.
This commit is contained in:
Víctor Falcón 2026-06-05 18:00:14 +02:00 committed by GitHub
parent af87ac7560
commit 8df44c2ef4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 856 additions and 2 deletions

View File

@ -0,0 +1,19 @@
<?php
namespace App\Features;
use App\Models\User;
/**
* @api
*/
class TransactionAnalysis
{
/**
* Resolve the feature's initial value.
*/
public function resolve(?User $user): bool
{
return false;
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\StoreSavedFilterRequest;
use App\Http\Requests\Api\UpdateSavedFilterRequest;
use App\Models\SavedFilter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SavedFilterController extends Controller
{
public function index(Request $request): JsonResponse
{
$savedFilters = SavedFilter::query()
->where('user_id', $request->user()->id)
->orderBy('name')
->get(['id', 'name', 'filters']);
return response()->json(['data' => $savedFilters]);
}
public function store(StoreSavedFilterRequest $request): JsonResponse
{
$savedFilter = SavedFilter::query()->create([
'user_id' => $request->user()->id,
'name' => $request->validated('name'),
'filters' => $request->validated('filters'),
]);
return response()->json([
'data' => $savedFilter->only(['id', 'name', 'filters']),
], 201);
}
public function update(UpdateSavedFilterRequest $request, SavedFilter $savedFilter): JsonResponse
{
abort_unless($savedFilter->user_id === $request->user()->id, 403);
$savedFilter->update(['filters' => $request->validated('filters')]);
return response()->json([
'data' => $savedFilter->only(['id', 'name', 'filters']),
]);
}
public function destroy(Request $request, SavedFilter $savedFilter): JsonResponse
{
abort_unless($savedFilter->user_id === $request->user()->id, 403);
$savedFilter->delete();
return response()->json(['message' => 'Saved filter deleted']);
}
}

View File

@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use App\Enums\BankingConnectionStatus;
use App\Features\CalculateBalancesOnImport;
use App\Features\TransactionAnalysis;
use App\Models\BankingConnection;
use App\Services\CurrencyOptions;
use Illuminate\Foundation\Inspiring;
@ -168,6 +169,9 @@ class HandleInertiaRequests extends Middleware
'calculateBalancesOnImport' => $user
? Feature::for($user)->active(CalculateBalancesOnImport::class)
: false,
'transactionAnalysis' => $user
? Feature::for($user)->active(TransactionAnalysis::class)
: false,
];
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreSavedFilterRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
Rule::unique('saved_filters', 'name')->where('user_id', $this->user()->id),
],
'filters' => ['required', 'array'],
'filters.date_from' => ['nullable', 'date'],
'filters.date_to' => ['nullable', 'date'],
'filters.amount_min' => ['nullable', 'numeric'],
'filters.amount_max' => ['nullable', 'numeric'],
'filters.category_ids' => ['nullable', 'array'],
'filters.category_ids.*' => ['string'],
'filters.account_ids' => ['nullable', 'array'],
'filters.account_ids.*' => ['string'],
'filters.label_ids' => ['nullable', 'array'],
'filters.label_ids.*' => ['string'],
'filters.creditor_name' => ['nullable', 'string', 'max:255'],
'filters.debtor_name' => ['nullable', 'string', 'max:255'],
'filters.search' => ['nullable', 'string', 'max:200'],
];
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class UpdateSavedFilterRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'filters' => ['required', 'array'],
'filters.date_from' => ['nullable', 'date'],
'filters.date_to' => ['nullable', 'date'],
'filters.amount_min' => ['nullable', 'numeric'],
'filters.amount_max' => ['nullable', 'numeric'],
'filters.category_ids' => ['nullable', 'array'],
'filters.category_ids.*' => ['string'],
'filters.account_ids' => ['nullable', 'array'],
'filters.account_ids.*' => ['string'],
'filters.label_ids' => ['nullable', 'array'],
'filters.label_ids.*' => ['string'],
'filters.creditor_name' => ['nullable', 'string', 'max:255'],
'filters.debtor_name' => ['nullable', 'string', 'max:255'],
'filters.search' => ['nullable', 'string', 'max:200'],
];
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Database\Factories\SavedFilterFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SavedFilter extends Model
{
/** @use HasFactory<SavedFilterFactory> */
use HasFactory, HasUuids;
protected $fillable = [
'user_id',
'name',
'filters',
];
protected function casts(): array
{
return [
'filters' => 'array',
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace Database\Factories;
use App\Models\SavedFilter;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<SavedFilter>
*/
class SavedFilterFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'name' => fake()->unique()->words(2, true),
'filters' => [
'category_ids' => [],
'account_ids' => [],
'label_ids' => [],
'search' => fake()->word(),
],
];
}
}

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('saved_filters', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->json('filters');
$table->timestamps();
$table->unique(['user_id', 'name']);
});
}
public function down(): void
{
Schema::dropIfExists('saved_filters');
}
};

View File

@ -1,4 +1,20 @@
{
"A filter with that name already exists": "Ya existe un filtro con ese nombre",
"Delete saved filter": "Eliminar filtro guardado",
"Failed to delete the saved filter": "No se pudo eliminar el filtro guardado",
"Failed to save the filter": "No se pudo guardar el filtro",
"Failed to update the saved filter": "No se pudo actualizar el filtro guardado",
"Filter saved": "Filtro guardado",
"Filter updated": "Filtro actualizado",
"No saved filters yet": "Aún no hay filtros guardados",
"Save as new filter…": "Guardar como nuevo filtro…",
"Save filter": "Guardar filtro",
"Saved filters": "Filtros guardados",
"Saving…": "Guardando…",
"Unsaved changes": "Cambios sin guardar",
"Update “:name”": "Actualizar «:name»",
"e.g. Japan trip, Utilities": "p. ej. Viaje a Japón, Servicios",
"Give this set of filters a name so you can reuse it later.": "Dale un nombre a este conjunto de filtros para reutilizarlo más tarde.",
"+:count": "+:count",
":count selected": ":count seleccionadas",
"Categories": "Categorías",

View File

@ -0,0 +1,337 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import {
deserializeFilters,
filtersFingerprint,
hasActiveFilters,
type SerializedFilters,
serializeFilters,
} from '@/lib/transaction-filter-serialization';
import { cn } from '@/lib/utils';
import { type TransactionFilters } from '@/types/transaction';
import { type UUID } from '@/types/uuid';
import { __ } from '@/utils/i18n';
import axios from 'axios';
import {
Bookmark,
BookmarkCheck,
Check,
Plus,
Save,
Trash2,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
interface SavedFilter {
id: UUID;
name: string;
filters: SerializedFilters;
}
interface SavedFiltersProps {
filters: TransactionFilters;
onLoad: (filters: TransactionFilters) => void;
}
export function SavedFilters({ filters, onLoad }: SavedFiltersProps) {
const [savedFilters, setSavedFilters] = useState<SavedFilter[]>([]);
const [activeId, setActiveId] = useState<UUID | null>(null);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [name, setName] = useState('');
const [isSaving, setIsSaving] = useState(false);
const canSave = hasActiveFilters(filters);
const currentFingerprint = useMemo(
() => filtersFingerprint(serializeFilters(filters)),
[filters],
);
// The saved filter that exactly matches the current filters, if any.
const matchingSaved = useMemo(
() =>
savedFilters.find(
(savedFilter) =>
filtersFingerprint(savedFilter.filters) ===
currentFingerprint,
) ?? null,
[savedFilters, currentFingerprint],
);
// Keep track of the saved filter we're working from: it follows an exact
// match, and otherwise sticks around (so edits can be saved over it) until
// the filters are cleared.
useEffect(() => {
if (matchingSaved) {
setActiveId(matchingSaved.id);
} else if (!canSave) {
setActiveId(null);
}
}, [matchingSaved, canSave]);
const activeFilter =
savedFilters.find((savedFilter) => savedFilter.id === activeId) ?? null;
const isDirty = activeFilter !== null && matchingSaved === null;
useEffect(() => {
let active = true;
axios
.get<{ data: SavedFilter[] }>('/api/saved-filters')
.then((response) => {
if (active) {
setSavedFilters(response.data.data);
}
})
.catch((error) => {
console.error('Failed to load saved filters:', error);
});
return () => {
active = false;
};
}, []);
function handleLoad(savedFilter: SavedFilter) {
setActiveId(savedFilter.id);
onLoad(deserializeFilters(savedFilter.filters));
}
async function handleDelete(savedFilter: SavedFilter) {
const previous = savedFilters;
setSavedFilters((current) =>
current.filter((item) => item.id !== savedFilter.id),
);
if (activeId === savedFilter.id) {
setActiveId(null);
}
try {
await axios.delete(`/api/saved-filters/${savedFilter.id}`);
} catch (error) {
console.error('Failed to delete saved filter:', error);
setSavedFilters(previous);
toast.error(__('Failed to delete the saved filter'));
}
}
async function handleUpdate(savedFilter: SavedFilter) {
try {
const response = await axios.patch<{ data: SavedFilter }>(
`/api/saved-filters/${savedFilter.id}`,
{ filters: serializeFilters(filters) },
);
setSavedFilters((current) =>
current.map((item) =>
item.id === savedFilter.id ? response.data.data : item,
),
);
setActiveId(savedFilter.id);
toast.success(__('Filter updated'));
} catch (error) {
console.error('Failed to update saved filter:', error);
toast.error(__('Failed to update the saved filter'));
}
}
async function handleSave() {
const trimmedName = name.trim();
if (!trimmedName || isSaving) {
return;
}
setIsSaving(true);
try {
const response = await axios.post<{ data: SavedFilter }>(
'/api/saved-filters',
{
name: trimmedName,
filters: serializeFilters(filters),
},
);
setSavedFilters((current) =>
[...current, response.data.data].sort((a, b) =>
a.name.localeCompare(b.name),
),
);
setActiveId(response.data.data.id);
setSaveDialogOpen(false);
setName('');
toast.success(__('Filter saved'));
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 422) {
toast.error(__('A filter with that name already exists'));
} else {
console.error('Failed to save filter:', error);
toast.error(__('Failed to save the filter'));
}
} finally {
setIsSaving(false);
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{activeFilter ? (
<Button
variant="secondary"
className="max-w-[200px]"
aria-label={__('Saved filters')}
>
<BookmarkCheck className="mr-1 h-4 w-4 shrink-0" />
<span className="truncate">
{activeFilter.name}
</span>
{isDirty && (
<span
className="ml-1 text-muted-foreground"
title={__('Unsaved changes')}
>
</span>
)}
</Button>
) : (
<Button
variant="outline"
size="icon"
aria-label={__('Saved filters')}
>
<Bookmark className="h-4 w-4" />
</Button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>{__('Saved filters')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{savedFilters.length === 0 ? (
<div className="px-2 py-2 text-sm text-muted-foreground">
{__('No saved filters yet')}
</div>
) : (
savedFilters.map((savedFilter) => (
<DropdownMenuItem
key={savedFilter.id}
onSelect={() => handleLoad(savedFilter)}
className="group justify-between gap-2"
>
<span className="flex min-w-0 items-center gap-2">
<Check
className={cn(
'h-3.5 w-3.5 shrink-0',
matchingSaved?.id === savedFilter.id
? 'opacity-100'
: 'opacity-0',
)}
/>
<span className="truncate">
{savedFilter.name}
</span>
</span>
<button
type="button"
aria-label={__('Delete saved filter')}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:text-destructive"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleDelete(savedFilter);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</DropdownMenuItem>
))
)}
<DropdownMenuSeparator />
{isDirty && activeFilter && (
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
handleUpdate(activeFilter);
}}
>
<Save className="mr-1 h-4 w-4" />
{__('Update “:name”', { name: activeFilter.name })}
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={!canSave}
onSelect={(event) => {
event.preventDefault();
setSaveDialogOpen(true);
}}
>
<Plus className="mr-1 h-4 w-4" />
{__('Save as new filter…')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{__('Save filter')}</DialogTitle>
<DialogDescription>
{__(
'Give this set of filters a name so you can reuse it later.',
)}
</DialogDescription>
</DialogHeader>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={__('e.g. Japan trip, Utilities')}
autoFocus
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleSave();
}
}}
/>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSaveDialogOpen(false)}
disabled={isSaving}
>
{__('Cancel')}
</Button>
<Button
onClick={handleSave}
disabled={isSaving || !name.trim()}
>
{isSaving ? __('Saving…') : __('Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -1,10 +1,12 @@
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { format } from 'date-fns';
import * as Icons from 'lucide-react';
import { ChevronsUpDown, Tag, X } from 'lucide-react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { AccountName } from '@/components/accounts/account-name';
import { SavedFilters } from '@/components/transactions/saved-filters';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@ -29,6 +31,7 @@ import {
toggleCategorySelection,
} from '@/lib/category-tree';
import { cn } from '@/lib/utils';
import { type SharedData } from '@/types';
import { type Account } from '@/types/account';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { getLabelColorClasses, type Label } from '@/types/label';
@ -44,6 +47,7 @@ interface TransactionFiltersProps {
isKeySet: boolean;
actions?: ReactNode;
hideAccountFilter?: boolean;
enableSavedFilters?: boolean;
}
export function TransactionFilters({
@ -54,7 +58,9 @@ export function TransactionFilters({
accounts,
actions,
hideAccountFilter = false,
enableSavedFilters = false,
}: TransactionFiltersProps) {
const { features } = usePage<SharedData>().props;
const [isOpen, setIsOpen] = useState(false);
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
const [categorySearch, setCategorySearch] = useState('');
@ -231,13 +237,13 @@ export function TransactionFilters({
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex flex-col gap-3">
<div className="flex w-full flex-row items-center gap-2">
<Input
placeholder={__('Search description or notes...')}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="min-w-0 flex-1 md:min-w-[350px]"
className="hidden min-w-0 flex-1 md:block md:min-w-[350px]"
/>
<Popover open={isOpen} onOpenChange={setIsOpen}>
@ -266,6 +272,19 @@ export function TransactionFilters({
</h4>
</div>
<div className="space-y-2 md:hidden">
<FormLabel>{__('Search')}</FormLabel>
<Input
placeholder={__(
'Search description or notes...',
)}
value={searchText}
onChange={(e) =>
setSearchText(e.target.value)
}
/>
</div>
<div className="space-y-2">
<FormLabel>{__('Date')}</FormLabel>
<div className="grid grid-cols-2 gap-2 pt-2">
@ -685,6 +704,13 @@ export function TransactionFilters({
</PopoverContent>
</Popover>
{enableSavedFilters && features.transactionAnalysis && (
<SavedFilters
filters={filters}
onLoad={onFiltersChange}
/>
)}
{activeFilterCount > 0 && (
<Button
variant="ghost"

View File

@ -0,0 +1,98 @@
import { type TransactionFilters } from '@/types/transaction';
import { format } from 'date-fns';
/** Persisted, snake_case representation of a filter set (matches the backend query params). */
export interface SerializedFilters {
date_from?: string;
date_to?: string;
amount_min?: number;
amount_max?: number;
category_ids?: string[];
account_ids?: string[];
label_ids?: string[];
creditor_name?: string;
debtor_name?: string;
search?: string;
}
export function serializeFilters(
filters: TransactionFilters,
): SerializedFilters {
const result: SerializedFilters = {};
if (filters.dateFrom) {
result.date_from = format(filters.dateFrom, 'yyyy-MM-dd');
}
if (filters.dateTo) {
result.date_to = format(filters.dateTo, 'yyyy-MM-dd');
}
if (filters.amountMin !== null) {
result.amount_min = filters.amountMin;
}
if (filters.amountMax !== null) {
result.amount_max = filters.amountMax;
}
if (filters.categoryIds.length > 0) {
result.category_ids = filters.categoryIds;
}
if (filters.accountIds.length > 0) {
result.account_ids = filters.accountIds;
}
if (filters.labelIds.length > 0) {
result.label_ids = filters.labelIds;
}
if (filters.creditorName) {
result.creditor_name = filters.creditorName;
}
if (filters.debtorName) {
result.debtor_name = filters.debtorName;
}
if (filters.searchText) {
result.search = filters.searchText;
}
return result;
}
export function deserializeFilters(
data: SerializedFilters,
): TransactionFilters {
return {
dateFrom: data.date_from
? new Date(data.date_from + 'T00:00:00')
: null,
dateTo: data.date_to ? new Date(data.date_to + 'T00:00:00') : null,
amountMin: data.amount_min ?? null,
amountMax: data.amount_max ?? null,
categoryIds: data.category_ids ?? [],
accountIds: data.account_ids ?? [],
labelIds: data.label_ids ?? [],
creditorName: data.creditor_name ?? '',
debtorName: data.debtor_name ?? '',
searchText: data.search ?? '',
};
}
export function hasActiveFilters(filters: TransactionFilters): boolean {
return Object.keys(serializeFilters(filters)).length > 0;
}
/**
* Stable, order-insensitive fingerprint of a serialized filter set, so two
* filter sets that select the same things compare equal regardless of the
* order ids were added or which empty keys are present.
*/
export function filtersFingerprint(filters: SerializedFilters): string {
return JSON.stringify({
date_from: filters.date_from ?? null,
date_to: filters.date_to ?? null,
amount_min: filters.amount_min ?? null,
amount_max: filters.amount_max ?? null,
category_ids: [...(filters.category_ids ?? [])].sort(),
account_ids: [...(filters.account_ids ?? [])].sort(),
label_ids: [...(filters.label_ids ?? [])].sort(),
creditor_name: filters.creditor_name ?? '',
debtor_name: filters.debtor_name ?? '',
search: filters.search ?? '',
});
}

View File

@ -1165,6 +1165,7 @@ export default function Transactions({
labels={labels}
accounts={accounts}
isKeySet={true}
enableSavedFilters={true}
actions={
<div className="flex w-full flex-wrap justify-between gap-2 sm:justify-end">
<TransactionActionsMenu

View File

@ -42,6 +42,7 @@ export interface NavDivider {
export interface Features {
cashflow: boolean;
calculateBalancesOnImport: boolean;
transactionAnalysis: boolean;
}
export interface ExpiredBankingConnectionNotification {

View File

@ -5,6 +5,7 @@ use App\Http\Controllers\Api\AccountController;
use App\Http\Controllers\Api\CashflowAnalyticsController;
use App\Http\Controllers\Api\DashboardAnalyticsController;
use App\Http\Controllers\Api\ImportDataController;
use App\Http\Controllers\Api\SavedFilterController;
use App\Http\Controllers\Api\TransactionController;
use App\Http\Controllers\EncryptionController;
use App\Http\Controllers\Sync\TransactionSyncController;
@ -56,4 +57,10 @@ Route::middleware(['web', 'auth'])->group(function () {
Route::get('trend', [CashflowAnalyticsController::class, 'trend']);
Route::get('breakdown', [CashflowAnalyticsController::class, 'breakdown']);
});
// Saved transaction filters
Route::get('saved-filters', [SavedFilterController::class, 'index'])->name('api.saved-filters.index');
Route::post('saved-filters', [SavedFilterController::class, 'store'])->name('api.saved-filters.store');
Route::patch('saved-filters/{savedFilter}', [SavedFilterController::class, 'update'])->name('api.saved-filters.update');
Route::delete('saved-filters/{savedFilter}', [SavedFilterController::class, 'destroy'])->name('api.saved-filters.destroy');
});

View File

@ -43,6 +43,7 @@ test('shared feature flags do not include coinbase flag', function () {
expect($props['features'])->toBe([
'cashflow' => true,
'calculateBalancesOnImport' => false,
'transactionAnalysis' => false,
]);
});

View File

@ -0,0 +1,121 @@
<?php
use App\Models\SavedFilter;
use App\Models\User;
beforeEach(function () {
$this->user = User::factory()->create();
$this->actingAs($this->user);
});
test('saved filters endpoints require authentication', function () {
auth()->logout();
$this->getJson('/api/saved-filters')->assertUnauthorized();
});
test('lists only the current user saved filters ordered by name', function () {
SavedFilter::factory()->create(['user_id' => $this->user->id, 'name' => 'Zurich trip']);
SavedFilter::factory()->create(['user_id' => $this->user->id, 'name' => 'Apartment bills']);
SavedFilter::factory()->create(['name' => 'Someone else']);
$response = $this->getJson('/api/saved-filters')->assertOk();
expect($response->json('data'))->toHaveCount(2);
expect($response->json('data.0.name'))->toBe('Apartment bills');
expect($response->json('data.1.name'))->toBe('Zurich trip');
});
test('stores a saved filter for the current user', function () {
$payload = [
'name' => 'Trip to Japan',
'filters' => [
'label_ids' => ['11111111-1111-1111-1111-111111111111'],
'category_ids' => ['food'],
'date_from' => '2026-01-01',
],
];
$response = $this->postJson('/api/saved-filters', $payload)->assertCreated();
expect($response->json('data.name'))->toBe('Trip to Japan');
expect($response->json('data.filters.category_ids'))->toBe(['food']);
$this->assertDatabaseHas('saved_filters', [
'user_id' => $this->user->id,
'name' => 'Trip to Japan',
]);
});
test('name is required and unique per user', function () {
SavedFilter::factory()->create(['user_id' => $this->user->id, 'name' => 'Duplicate']);
$this->postJson('/api/saved-filters', ['filters' => []])
->assertStatus(422)
->assertJsonValidationErrors(['name', 'filters']);
$this->postJson('/api/saved-filters', ['name' => 'Duplicate', 'filters' => []])
->assertStatus(422)
->assertJsonValidationErrors(['name']);
});
test('the same name can be reused by a different user', function () {
SavedFilter::factory()->create(['user_id' => $this->user->id, 'name' => 'Shared name']);
$other = User::factory()->create();
$this->actingAs($other)
->postJson('/api/saved-filters', [
'name' => 'Shared name',
'filters' => ['search' => 'groceries'],
])
->assertCreated();
});
test('a user can update their own saved filter', function () {
$savedFilter = SavedFilter::factory()->create([
'user_id' => $this->user->id,
'filters' => ['search' => 'old'],
]);
$response = $this->patchJson("/api/saved-filters/{$savedFilter->id}", [
'filters' => ['search' => 'new', 'category_ids' => ['food']],
])->assertOk();
expect($response->json('data.filters.search'))->toBe('new');
$this->assertDatabaseHas('saved_filters', [
'id' => $savedFilter->id,
'name' => $savedFilter->name,
]);
expect($savedFilter->fresh()->filters)->toBe([
'search' => 'new',
'category_ids' => ['food'],
]);
});
test('a user cannot update another user saved filter', function () {
$savedFilter = SavedFilter::factory()->create(['filters' => ['search' => 'old']]);
$this->patchJson("/api/saved-filters/{$savedFilter->id}", [
'filters' => ['search' => 'hacked'],
])->assertForbidden();
expect($savedFilter->fresh()->filters)->toBe(['search' => 'old']);
});
test('a user can delete their own saved filter', function () {
$savedFilter = SavedFilter::factory()->create(['user_id' => $this->user->id]);
$this->deleteJson("/api/saved-filters/{$savedFilter->id}")->assertOk();
$this->assertDatabaseMissing('saved_filters', ['id' => $savedFilter->id]);
});
test('a user cannot delete another user saved filter', function () {
$savedFilter = SavedFilter::factory()->create();
$this->deleteJson("/api/saved-filters/{$savedFilter->id}")->assertForbidden();
$this->assertDatabaseHas('saved_filters', ['id' => $savedFilter->id]);
});