Move transaction filtering from client-side to server-side (#126)
## Summary - **Server-side filtering/sorting/search**: With encryption removed, all transaction filtering, sorting, and full-text search (LIKE on description + notes) now happens via SQL queries instead of client-side IndexedDB with decryption - **Cursor pagination**: Transactions are returned with cursor-based pagination (default 50 per page, "Load More" pattern) instead of loading all into IndexedDB - **Removed IndexedDB dependency**: The main transactions page no longer depends on Dexie/IndexedDB for data or sync — other pages (categorize, accounts/show, budgets/show) are unaffected and will be migrated separately ## Changes | File | Change | |------|--------| | `app/Models/Transaction.php` | Added `scopeApplyFilters` query scope | | `app/Http/Requests/IndexTransactionRequest.php` | New form request for filter validation | | `app/Http/Controllers/TransactionController.php` | Server-filtered cursor-paginated index, refactored bulkUpdate to reuse scope | | `resources/js/pages/transactions/index.tsx` | Major rewrite — server-driven filtering, sorting, pagination via `router.visit()` | | `resources/js/components/transactions/transaction-filters.tsx` | Search always enabled (removed encryption key dependency) | | `resources/js/types/transaction.ts` | Added `ServerTransaction` type | | `resources/js/contexts/sync-context.tsx` | Simplified — removed transaction sync service | | `tests/Feature/TransactionFilterTest.php` | 20 new Pest tests for all filter scenarios | ## Test plan - [x] Run `php artisan test` — all 523+ tests pass - [x] Navigate to `/transactions` — paginated results load from server - [x] Apply each filter type (date, amount, category, account, label, search) and verify server round-trip - [x] Test "uncategorized" category filter - [x] Test combined filters - [x] Test sorting by date, amount, description (ascending/descending) - [x] Test "Load More" button for cursor pagination - [x] Test bulk operations (select some → update, select all filtered → update) - [x] Verify URL persistence (refresh page with filters in URL) - [x] Test search on both description and notes fields
This commit is contained in:
parent
6abec95d0e
commit
d1efc03fc5
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\BulkUpdateTransactionsRequest;
|
||||
use App\Http\Requests\IndexTransactionRequest;
|
||||
use App\Http\Requests\StoreTransactionRequest;
|
||||
use App\Http\Requests\UpdateTransactionRequest;
|
||||
use App\Models\Account;
|
||||
|
|
@ -21,9 +22,49 @@ class TransactionController extends Controller
|
|||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function index(Request $request): Response
|
||||
public function index(IndexTransactionRequest $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$validated = $request->validated();
|
||||
|
||||
$perPage = (int) ($validated['per_page'] ?? 50);
|
||||
$sortParam = $validated['sort'] ?? '-transaction_date';
|
||||
|
||||
$descending = str_starts_with($sortParam, '-');
|
||||
$sortColumn = ltrim($sortParam, '-');
|
||||
$sortDirection = $descending ? 'desc' : 'asc';
|
||||
|
||||
$filters = array_filter([
|
||||
'date_from' => $validated['date_from'] ?? null,
|
||||
'date_to' => $validated['date_to'] ?? null,
|
||||
'amount_min' => $validated['amount_min'] ?? null,
|
||||
'amount_max' => $validated['amount_max'] ?? null,
|
||||
'category_ids' => $validated['category_ids'] ?? null,
|
||||
'account_ids' => $validated['account_ids'] ?? null,
|
||||
'label_ids' => $validated['label_ids'] ?? null,
|
||||
'search' => $validated['search'] ?? null,
|
||||
], fn ($value) => $value !== null);
|
||||
|
||||
$transactions = Transaction::query()
|
||||
->where('user_id', $user->id)
|
||||
->with(['account.bank:id,name,logo', 'category:id,name,icon,color', 'labels:id,name,color'])
|
||||
->applyFilters($filters)
|
||||
->orderBy($sortColumn, $sortDirection)
|
||||
->orderBy('id', 'desc')
|
||||
->cursorPaginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
$appliedFilters = [
|
||||
'date_from' => $validated['date_from'] ?? null,
|
||||
'date_to' => $validated['date_to'] ?? null,
|
||||
'amount_min' => $validated['amount_min'] ?? null,
|
||||
'amount_max' => $validated['amount_max'] ?? null,
|
||||
'category_ids' => $validated['category_ids'] ?? [],
|
||||
'account_ids' => $validated['account_ids'] ?? [],
|
||||
'label_ids' => $validated['label_ids'] ?? [],
|
||||
'search' => $validated['search'] ?? '',
|
||||
'sort' => $sortParam,
|
||||
];
|
||||
|
||||
$categories = Category::query()
|
||||
->where('user_id', $user->id)
|
||||
|
|
@ -56,6 +97,8 @@ class TransactionController extends Controller
|
|||
->get();
|
||||
|
||||
return Inertia::render('transactions/index', [
|
||||
'transactions' => $transactions,
|
||||
'appliedFilters' => $appliedFilters,
|
||||
'categories' => $categories,
|
||||
'accounts' => $accounts,
|
||||
'banks' => $banks,
|
||||
|
|
@ -192,30 +235,7 @@ class TransactionController extends Controller
|
|||
], 403);
|
||||
}
|
||||
} elseif ($filters !== null) {
|
||||
if (isset($filters['date_from'])) {
|
||||
$query->whereDate('transaction_date', '>=', $filters['date_from']);
|
||||
}
|
||||
if (isset($filters['date_to'])) {
|
||||
$query->whereDate('transaction_date', '<=', $filters['date_to']);
|
||||
}
|
||||
if (isset($filters['amount_min'])) {
|
||||
$query->where('amount', '>=', $filters['amount_min'] * 100);
|
||||
}
|
||||
if (isset($filters['amount_max'])) {
|
||||
$query->where('amount', '<=', $filters['amount_max'] * 100);
|
||||
}
|
||||
if (! empty($filters['category_ids'])) {
|
||||
$query->whereIn('category_id', $filters['category_ids']);
|
||||
}
|
||||
if (! empty($filters['account_ids'])) {
|
||||
$query->whereIn('account_id', $filters['account_ids']);
|
||||
}
|
||||
if (! empty($filters['label_ids'])) {
|
||||
$query->whereHas('labels', function ($q) use ($filters) {
|
||||
$q->whereIn('labels.id', $filters['label_ids']);
|
||||
});
|
||||
}
|
||||
|
||||
$query->applyFilters($filters);
|
||||
$transactions = $query->get();
|
||||
} else {
|
||||
$transactions = $query->get();
|
||||
|
|
@ -242,40 +262,17 @@ class TransactionController extends Controller
|
|||
}
|
||||
|
||||
if (! empty($updateData)) {
|
||||
$query = Transaction::query()->where('user_id', $user->id);
|
||||
$updateQuery = Transaction::query()->where('user_id', $user->id);
|
||||
if ($transactionIds && count($transactionIds) > 0) {
|
||||
$query->whereIn('id', $transactionIds);
|
||||
$updateQuery->whereIn('id', $transactionIds);
|
||||
} elseif ($filters !== null) {
|
||||
if (isset($filters['date_from'])) {
|
||||
$query->whereDate('transaction_date', '>=', $filters['date_from']);
|
||||
}
|
||||
if (isset($filters['date_to'])) {
|
||||
$query->whereDate('transaction_date', '<=', $filters['date_to']);
|
||||
}
|
||||
if (isset($filters['amount_min'])) {
|
||||
$query->where('amount', '>=', $filters['amount_min'] * 100);
|
||||
}
|
||||
if (isset($filters['amount_max'])) {
|
||||
$query->where('amount', '<=', $filters['amount_max'] * 100);
|
||||
}
|
||||
if (! empty($filters['category_ids'])) {
|
||||
$query->whereIn('category_id', $filters['category_ids']);
|
||||
}
|
||||
if (! empty($filters['account_ids'])) {
|
||||
$query->whereIn('account_id', $filters['account_ids']);
|
||||
}
|
||||
if (! empty($filters['label_ids'])) {
|
||||
$query->whereHas('labels', function ($q) use ($filters) {
|
||||
$q->whereIn('labels.id', $filters['label_ids']);
|
||||
});
|
||||
}
|
||||
$updateQuery->applyFilters($filters);
|
||||
}
|
||||
$query->update($updateData);
|
||||
$updateQuery->update($updateData);
|
||||
}
|
||||
|
||||
if ($hasLabelUpdate) {
|
||||
foreach ($transactions as $transaction) {
|
||||
// Replace labels (consistent with single update behavior)
|
||||
$transaction->labels()->sync($labelIds ?? []);
|
||||
$transaction->save();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class IndexTransactionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$fields = ['category_ids', 'account_ids', 'label_ids'];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$value = $this->input($field);
|
||||
if (is_string($value)) {
|
||||
$this->merge([
|
||||
$field => array_filter(explode(',', $value)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'date_from' => ['nullable', 'date'],
|
||||
'date_to' => ['nullable', 'date'],
|
||||
'amount_min' => ['nullable', 'numeric'],
|
||||
'amount_max' => ['nullable', 'numeric'],
|
||||
'category_ids' => ['nullable', 'array'],
|
||||
'category_ids.*' => ['string'],
|
||||
'account_ids' => ['nullable', 'array'],
|
||||
'account_ids.*' => ['string', 'uuid'],
|
||||
'label_ids' => ['nullable', 'array'],
|
||||
'label_ids.*' => ['string', 'uuid'],
|
||||
'search' => ['nullable', 'string', 'max:200'],
|
||||
'sort' => ['nullable', 'string', 'in:transaction_date,-transaction_date,amount,-amount,description,-description'],
|
||||
'cursor' => ['nullable', 'string'],
|
||||
'per_page' => ['nullable', 'integer', 'min:10', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ use App\Enums\TransactionSource;
|
|||
use App\Events\TransactionCreated;
|
||||
use App\Events\TransactionDeleted;
|
||||
use App\Events\TransactionUpdated;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
|
@ -79,4 +80,58 @@ class Transaction extends Model
|
|||
{
|
||||
return $this->hasMany(BudgetTransaction::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Transaction> $query
|
||||
* @param array<string, mixed> $filters
|
||||
* @return Builder<Transaction>
|
||||
*/
|
||||
public function scopeApplyFilters(Builder $query, array $filters): Builder
|
||||
{
|
||||
if (isset($filters['date_from'])) {
|
||||
$query->whereDate('transaction_date', '>=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (isset($filters['date_to'])) {
|
||||
$query->whereDate('transaction_date', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
if (isset($filters['amount_min'])) {
|
||||
$query->where('amount', '>=', $filters['amount_min'] * 100);
|
||||
}
|
||||
|
||||
if (isset($filters['amount_max'])) {
|
||||
$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();
|
||||
|
||||
$query->where(function (Builder $q) use ($realIds, $hasUncategorized) {
|
||||
if (! empty($realIds)) {
|
||||
$q->whereIn('category_id', $realIds);
|
||||
}
|
||||
if ($hasUncategorized) {
|
||||
$q->orWhereNull('category_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($filters['account_ids'])) {
|
||||
$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['search'])) {
|
||||
$term = '%'.$filters['search'].'%';
|
||||
$query->where(fn (Builder $q) => $q->where('description', 'LIKE', $term)->orWhere('notes', 'LIKE', $term));
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { formatDate } from '@/utils/date';
|
|||
import { __ } from '@/utils/i18n';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { getYear, parseISO } from 'date-fns';
|
||||
import { ArrowDown, MoreHorizontal } from 'lucide-react';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
import { AccountName } from '@/components/accounts/account-name';
|
||||
import { BankLogo } from '@/components/bank-logo';
|
||||
|
|
@ -97,8 +97,13 @@ export function createTransactionColumns({
|
|||
}
|
||||
>
|
||||
{__('Date')}
|
||||
|
||||
<ArrowDown className="h-2 w-2 opacity-25" />
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 opacity-25" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ export function TransactionFilters({
|
|||
categories,
|
||||
labels,
|
||||
accounts,
|
||||
isKeySet,
|
||||
actions,
|
||||
hideAccountFilter = false,
|
||||
}: TransactionFiltersProps) {
|
||||
|
|
@ -80,7 +79,7 @@ export function TransactionFilters({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters.searchText]);
|
||||
|
||||
function handleCategoryToggle(categoryId: number) {
|
||||
function handleCategoryToggle(categoryId: string) {
|
||||
const newCategoryIds = filters.categoryIds.includes(categoryId)
|
||||
? filters.categoryIds.filter((id) => id !== categoryId)
|
||||
: [...filters.categoryIds, categoryId];
|
||||
|
|
@ -132,14 +131,9 @@ export function TransactionFilters({
|
|||
<div className="flex flex-col items-center gap-3 lg:flex-row">
|
||||
<div className="flex w-full flex-row items-center gap-2 lg:w-auto">
|
||||
<Input
|
||||
placeholder={
|
||||
isKeySet
|
||||
? __('Search description or notes...')
|
||||
: __('Search disabled (encryption key not set)')
|
||||
}
|
||||
placeholder={__('Search description or notes...')}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
disabled={!isKeySet}
|
||||
className="max-w-sm flex-1 md:max-w-full md:min-w-[350px]"
|
||||
/>
|
||||
|
||||
|
|
@ -567,4 +561,4 @@ export function TransactionFilters({
|
|||
);
|
||||
}
|
||||
|
||||
const UNCATEGORIZED_CATEGORY_ID = -1;
|
||||
const UNCATEGORIZED_CATEGORY_ID = 'uncategorized';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useOnlineStatus } from '@/hooks/use-online-status';
|
||||
import { identifyUser, resetPostHog } from '@/lib/posthog';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import type { User } from '@/types/index.d';
|
||||
import type { Page } from '@inertiajs/core';
|
||||
import { router } from '@inertiajs/react';
|
||||
|
|
@ -27,31 +26,6 @@ interface SyncContextType {
|
|||
|
||||
const SyncContext = createContext<SyncContextType | undefined>(undefined);
|
||||
|
||||
function formatErrorMessage(error: string): string {
|
||||
if (error.includes('status code 5')) {
|
||||
return 'Server is temporarily unavailable. Please try again later.';
|
||||
}
|
||||
if (
|
||||
error.includes('status code 401') ||
|
||||
error.includes('status code 403')
|
||||
) {
|
||||
return 'Your session has expired. Please refresh the page.';
|
||||
}
|
||||
if (error.includes('status code 4')) {
|
||||
return 'Something went wrong. Please try again.';
|
||||
}
|
||||
if (error.includes('Network Error') || error.includes('network')) {
|
||||
return 'Unable to connect. Check your internet connection.';
|
||||
}
|
||||
if (error.includes('timeout') || error.includes('Timeout')) {
|
||||
return 'The request took too long. Please try again.';
|
||||
}
|
||||
if (error === 'Sync already in progress') {
|
||||
return 'Sync is already running. Please wait.';
|
||||
}
|
||||
return 'Sync failed. Please try again.';
|
||||
}
|
||||
|
||||
interface SyncProviderProps {
|
||||
children: ReactNode;
|
||||
initialIsAuthenticated: boolean;
|
||||
|
|
@ -72,7 +46,6 @@ export function SyncProvider({
|
|||
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [wasOffline, setWasOffline] = useState(!isOnline);
|
||||
const syncInProgressRef = useRef(false);
|
||||
const lastUserIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -102,55 +75,16 @@ export function SyncProvider({
|
|||
}, [isAuthenticated]);
|
||||
|
||||
const sync = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
if (!isAuthenticated || !isOnline) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOnline) {
|
||||
setError(
|
||||
'Unable to sync while offline. Connect to the internet and try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSyncStatus('success');
|
||||
setLastSyncTime(new Date());
|
||||
|
||||
if (syncInProgressRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncInProgressRef.current = true;
|
||||
setSyncStatus('syncing');
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await transactionSyncService.sync();
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
const uniqueFormattedErrors = [
|
||||
...new Set(result.errors.map(formatErrorMessage)),
|
||||
];
|
||||
setError(uniqueFormattedErrors.join(' '));
|
||||
setSyncStatus('error');
|
||||
} else {
|
||||
setSyncStatus('success');
|
||||
setLastSyncTime(new Date());
|
||||
|
||||
setTimeout(() => {
|
||||
setSyncStatus('idle');
|
||||
}, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Sync failed:', err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Unknown sync error';
|
||||
setError(formatErrorMessage(errorMessage));
|
||||
setSyncStatus('error');
|
||||
|
||||
setTimeout(() => {
|
||||
setSyncStatus('idle');
|
||||
}, 5000);
|
||||
} finally {
|
||||
syncInProgressRef.current = false;
|
||||
}
|
||||
setTimeout(() => {
|
||||
setSyncStatus('idle');
|
||||
}, 3000);
|
||||
}, [isAuthenticated, isOnline]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -166,10 +100,6 @@ export function SyncProvider({
|
|||
return;
|
||||
}
|
||||
|
||||
// If user changed, clear transactions
|
||||
if (lastUserIdRef.current && lastUserIdRef.current !== currentUser.id) {
|
||||
transactionSyncService.clearAll();
|
||||
}
|
||||
lastUserIdRef.current = currentUser.id;
|
||||
|
||||
identifyUser(currentUser.id, {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -23,6 +23,12 @@ export interface Transaction {
|
|||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ServerTransaction extends Transaction {
|
||||
account?: Account;
|
||||
category?: Category | null;
|
||||
labels?: Label[];
|
||||
}
|
||||
|
||||
export interface DecryptedTransaction extends Transaction {
|
||||
decryptedDescription: string;
|
||||
decryptedNotes: string | null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,484 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Category;
|
||||
use App\Models\Label;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->onboarded()->create();
|
||||
$this->account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||
});
|
||||
|
||||
test('index returns paginated transactions as Inertia props', function () {
|
||||
Transaction::factory()->plaintext()->count(3)->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('transactions/index')
|
||||
->has('transactions.data', 3)
|
||||
->has('appliedFilters')
|
||||
->where('appliedFilters.sort', '-transaction_date')
|
||||
);
|
||||
});
|
||||
|
||||
test('transactions include eager-loaded relationships', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
$label = Label::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$transaction = Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
$transaction->labels()->attach($label->id);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data.0.account')
|
||||
->has('transactions.data.0.category')
|
||||
->has('transactions.data.0.labels', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('filter by date range', function () {
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'transaction_date' => '2025-06-15',
|
||||
]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'transaction_date' => '2025-01-01',
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'date_from' => '2025-06-01',
|
||||
'date_to' => '2025-06-30',
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('filter by amount range', function () {
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'amount' => -5000, // -$50.00
|
||||
]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'amount' => -20000, // -$200.00
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'amount_min' => -100,
|
||||
'amount_max' => -10,
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('filter by category', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'category_id' => $category->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,
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('filter by uncategorized', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'category_id' => $category->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' => 'uncategorized',
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 1)
|
||||
->where('transactions.data.0.category_id', null)
|
||||
);
|
||||
});
|
||||
|
||||
test('filter by account', function () {
|
||||
$otherAccount = Account::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $otherAccount->id,
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'account_ids' => $this->account->id,
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('filter by label', function () {
|
||||
$label = Label::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$txWithLabel = Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
$txWithLabel->labels()->attach($label->id);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'label_ids' => $label->id,
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('search matches description', function () {
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'description' => 'Grocery Store Purchase',
|
||||
]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'description' => 'Gas Station',
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'search' => 'Grocery',
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 1)
|
||||
->where('transactions.data.0.description', 'Grocery Store Purchase')
|
||||
);
|
||||
});
|
||||
|
||||
test('search matches notes', function () {
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'description' => 'Some purchase',
|
||||
'notes' => 'weekly groceries for the family',
|
||||
]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'description' => 'Another purchase',
|
||||
'notes' => null,
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'search' => 'groceries',
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('combined filters work', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
// Matches all filters
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'category_id' => $category->id,
|
||||
'transaction_date' => '2025-06-15',
|
||||
'amount' => -5000,
|
||||
'description' => 'Target purchase',
|
||||
]);
|
||||
|
||||
// Wrong date
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'category_id' => $category->id,
|
||||
'transaction_date' => '2025-01-01',
|
||||
'amount' => -5000,
|
||||
'description' => 'Target purchase',
|
||||
]);
|
||||
|
||||
// Wrong category
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'category_id' => null,
|
||||
'transaction_date' => '2025-06-15',
|
||||
'amount' => -5000,
|
||||
'description' => 'Target purchase',
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'date_from' => '2025-06-01',
|
||||
'date_to' => '2025-06-30',
|
||||
'category_ids' => $category->id,
|
||||
'search' => 'Target',
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('sort by date ascending', function () {
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'transaction_date' => '2025-06-15',
|
||||
]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'transaction_date' => '2025-01-01',
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'sort' => 'transaction_date',
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 2)
|
||||
->where('transactions.data.0.transaction_date', fn ($date) => str_contains($date, '2025-01-01'))
|
||||
);
|
||||
});
|
||||
|
||||
test('sort by date descending (default)', function () {
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'transaction_date' => '2025-06-15',
|
||||
]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'transaction_date' => '2025-01-01',
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index'));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 2)
|
||||
->where('transactions.data.0.transaction_date', fn ($date) => str_contains($date, '2025-06-15'))
|
||||
);
|
||||
});
|
||||
|
||||
test('sort by amount', function () {
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'amount' => -10000,
|
||||
]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'amount' => 5000,
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'sort' => 'amount',
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 2)
|
||||
->where('transactions.data.0.amount', -10000)
|
||||
);
|
||||
});
|
||||
|
||||
test('sort by description', function () {
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'description' => 'Zebra Store',
|
||||
]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'description' => 'Apple Store',
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'sort' => 'description',
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 2)
|
||||
->where('transactions.data.0.description', 'Apple Store')
|
||||
);
|
||||
});
|
||||
|
||||
test('cursor pagination returns correct results', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
Transaction::factory()->plaintext()->count(25)->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'per_page' => 10,
|
||||
]));
|
||||
|
||||
$nextCursor = null;
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 10)
|
||||
->where('transactions.next_cursor', function ($cursor) use (&$nextCursor) {
|
||||
$nextCursor = $cursor;
|
||||
|
||||
return $cursor !== null;
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch the next page using the cursor
|
||||
$response2 = actingAs($this->user)->get(route('transactions.index', [
|
||||
'per_page' => 10,
|
||||
'cursor' => $nextCursor,
|
||||
]));
|
||||
|
||||
$response2->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 10)
|
||||
);
|
||||
});
|
||||
|
||||
test('empty results when no matches', function () {
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'description' => 'Coffee Shop',
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'search' => 'nonexistent_query_string',
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 0)
|
||||
);
|
||||
});
|
||||
|
||||
test('user scoping - cannot see other users transactions', function () {
|
||||
$otherUser = User::factory()->create();
|
||||
$otherAccount = Account::factory()->create(['user_id' => $otherUser->id]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $otherUser->id,
|
||||
'account_id' => $otherAccount->id,
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index'));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('applied filters are returned in response', function () {
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'search' => 'test',
|
||||
'date_from' => '2025-01-01',
|
||||
'sort' => '-amount',
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->where('appliedFilters.search', 'test')
|
||||
->where('appliedFilters.date_from', '2025-01-01')
|
||||
->where('appliedFilters.sort', '-amount')
|
||||
);
|
||||
});
|
||||
|
||||
test('filter by multiple categories including uncategorized', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'category_id' => $category->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.',uncategorized',
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 2)
|
||||
);
|
||||
});
|
||||
|
|
@ -25,6 +25,9 @@ test('authenticated users can access transactions page', function () {
|
|||
$response->assertSuccessful();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('transactions/index')
|
||||
->has('transactions')
|
||||
->has('transactions.data')
|
||||
->has('appliedFilters')
|
||||
->has('categories')
|
||||
->has('accounts')
|
||||
->has('banks')
|
||||
|
|
|
|||
Loading…
Reference in New Issue