diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 06194926..7ba2ba6b 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -135,11 +135,20 @@ class TransactionController extends Controller ->orderBy('name') ->get(['id', 'name', 'color']); + $transactions = Transaction::query() + ->where('user_id', $user->id) + ->whereNull('category_id') + ->with(['account.bank:id,name,logo', 'labels:id,name,color']) + ->orderBy('transaction_date', 'desc') + ->orderBy('id', 'desc') + ->get(['id', 'account_id', 'category_id', 'description', 'description_iv', 'transaction_date', 'amount', 'currency_code', 'notes', 'notes_iv']); + return Inertia::render('transactions/categorize', [ 'categories' => $categories, 'accounts' => $accounts, 'banks' => $banks, 'labels' => $labels, + 'transactions' => $transactions, ]); } diff --git a/resources/js/pages/transactions/categorize.tsx b/resources/js/pages/transactions/categorize.tsx index 82701dea..eec41d14 100644 --- a/resources/js/pages/transactions/categorize.tsx +++ b/resources/js/pages/transactions/categorize.tsx @@ -17,7 +17,6 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { useLocale } from '@/hooks/use-locale'; import { decrypt, importKey } from '@/lib/crypto'; -import { db } from '@/lib/dexie-db'; import { getStoredKey } from '@/lib/key-storage'; import { evaluateRules } from '@/lib/rule-engine'; import { cn } from '@/lib/utils'; @@ -25,12 +24,14 @@ import { transactionSyncService } from '@/services/transaction-sync'; import { type Account, type Bank } from '@/types/account'; import { type AutomationRule } from '@/types/automation-rule'; import { type Category, getCategoryColorClasses } from '@/types/category'; -import { type DecryptedTransaction } from '@/types/transaction'; +import { + type DecryptedTransaction, + type Transaction, +} from '@/types/transaction'; import { formatDateLong } from '@/utils/date'; import { __ } from '@/utils/i18n'; import { Head, Link, router, usePage } from '@inertiajs/react'; import { parseISO } from 'date-fns'; -import { useLiveQuery } from 'dexie-react-hooks'; import { ArrowDown, ArrowLeft, @@ -49,6 +50,7 @@ interface Props { categories: Category[]; accounts: Account[]; banks: Bank[]; + transactions: Transaction[]; } type AnimationState = 'idle' | 'exiting' | 'entering' | 'success'; @@ -98,6 +100,7 @@ export default function CategorizeTransactions({ categories, accounts, banks, + transactions: initialTransactions, }: Props) { const { isKeySet } = useEncryptionKey(); const { automationRules: sharedAutomationRules } = usePage<{ @@ -105,18 +108,6 @@ export default function CategorizeTransactions({ }>().props; const locale = useLocale(); - const transactionIds = useLiveQuery( - async () => { - const txs = await db.transactions.toArray(); - return txs - .map((t) => t.id) - .sort() - .join(','); - }, - [], - '', - ); - const [uncategorizedTransactions, setUncategorizedTransactions] = useState< DecryptedTransaction[] >([]); @@ -154,21 +145,11 @@ export default function CategorizeTransactions({ } }, [isLoading, animationState, currentIndex]); + // Decrypt transactions received from the backend and build the initial list useEffect(() => { - async function loadUncategorizedTransactions() { - if (transactionIds === undefined) { - setIsLoading(true); - return; - } - + async function decryptTransactions() { setIsLoading(true); try { - const rawTransactions = await db.transactions.toArray(); - - const uncategorized = rawTransactions.filter( - (t) => !t.category_id, - ); - const accountsMap = new Map( accounts.map((account) => [account.id, account]), ); @@ -190,7 +171,7 @@ export default function CategorizeTransactions({ } const decrypted = await Promise.all( - uncategorized.map(async (transaction) => { + initialTransactions.map(async (transaction) => { try { let decryptedDescription = ''; let decryptedNotes: string | null = null; @@ -273,8 +254,8 @@ export default function CategorizeTransactions({ } } - loadUncategorizedTransactions(); - }, [transactionIds, accounts, banks, isKeySet]); + decryptTransactions(); + }, [initialTransactions, accounts, banks, isKeySet]); const currentTransaction = uncategorizedTransactions[currentIndex]; const remainingCount = uncategorizedTransactions.length - currentIndex; @@ -723,10 +704,6 @@ export default function CategorizeTransactions({ account={ currentTransaction.account } - length={{ - min: 5, - max: 20, - }} className="text-sm text-zinc-600 dark:text-zinc-400" /> diff --git a/tests/Feature/TransactionTest.php b/tests/Feature/TransactionTest.php index 46fe60e7..39b4c598 100644 --- a/tests/Feature/TransactionTest.php +++ b/tests/Feature/TransactionTest.php @@ -671,3 +671,98 @@ test('when budget with label exists, updating transaction with that label assign expect($budgetTransaction->transaction_id)->toBe($transaction->id); expect($budgetTransaction->amount)->toBe(5000); // Stored as absolute value }); + +test('categorize page is accessible to authenticated users', function () { + $user = User::factory()->onboarded()->create(); + + $response = actingAs($user)->get(route('transactions.categorize')); + + $response->assertSuccessful(); + $response->assertInertia(fn ($page) => $page + ->component('transactions/categorize') + ->has('categories') + ->has('accounts') + ->has('banks') + ->has('labels') + ->has('transactions') + ); +}); + +test('categorize page only returns uncategorized transactions', function () { + $user = User::factory()->onboarded()->create(); + $account = Account::factory()->create(['user_id' => $user->id]); + $category = Category::factory()->create(['user_id' => $user->id]); + + $uncategorized = Transaction::factory()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'category_id' => null, + ]); + + Transaction::factory()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'category_id' => $category->id, + ]); + + $response = actingAs($user)->get(route('transactions.categorize')); + + $response->assertSuccessful(); + $response->assertInertia(fn ($page) => $page + ->component('transactions/categorize') + ->has('transactions', 1) + ->where('transactions.0.id', $uncategorized->id) + ); +}); + +test('categorize page does not return transactions from deleted accounts', function () { + $user = User::factory()->onboarded()->create(); + + $activeAccount = Account::factory()->create(['user_id' => $user->id]); + $deletedAccount = Account::factory()->create(['user_id' => $user->id]); + + $visibleTransaction = Transaction::factory()->create([ + 'user_id' => $user->id, + 'account_id' => $activeAccount->id, + 'category_id' => null, + ]); + + // Delete the account and its transactions (as the controller does) + $deletedAccount->transactions()->delete(); + $deletedAccount->delete(); + + $response = actingAs($user)->get(route('transactions.categorize')); + + $response->assertSuccessful(); + $response->assertInertia(fn ($page) => $page + ->component('transactions/categorize') + ->has('transactions', 1) + ->where('transactions.0.id', $visibleTransaction->id) + ); +}); + +test('categorize page does not return transactions from other users', function () { + $user = User::factory()->onboarded()->create(); + $otherUser = User::factory()->onboarded()->create(); + + $otherAccount = Account::factory()->create(['user_id' => $otherUser->id]); + Transaction::factory()->create([ + 'user_id' => $otherUser->id, + 'account_id' => $otherAccount->id, + 'category_id' => null, + ]); + + $response = actingAs($user)->get(route('transactions.categorize')); + + $response->assertSuccessful(); + $response->assertInertia(fn ($page) => $page + ->component('transactions/categorize') + ->has('transactions', 0) + ); +}); + +test('guests are redirected from categorize page', function () { + $response = $this->get(route('transactions.categorize')); + + $response->assertRedirect(route('login')); +});