fix(categorizer): fetch uncategorized transactions from backend instead of IndexedDB (#165)

## Problem

The categorizer was reading transactions exclusively from the browser's
IndexedDB (Dexie), which is only populated via an on-demand sync
triggered from the import drawer. When an account was deleted
(soft-deleting all its transactions), those transactions remained in the
local IndexedDB and kept appearing in the categorizer on subsequent
visits.

## Solution

- **`TransactionController::categorize()`** now queries uncategorized
transactions directly from the database (`WHERE category_id IS NULL`)
with their account and bank eager-loaded, and passes them as an Inertia
prop alongside the existing `categories`, `accounts`, `banks`, and
`labels`.
- **`categorize.tsx`** drops all Dexie/`useLiveQuery` dependencies. The
transaction list now comes from the Inertia prop; client-side decryption
runs once on mount (the encryption key remains client-side only — the
server never sees it). Category assignment
(`transactionSyncService.update()`) is unchanged.

## Tests

Added 5 new feature tests covering:
- Categorize page is accessible to authenticated users and includes the
`transactions` prop
- Only uncategorized transactions are returned
- Transactions from deleted accounts are excluded
- Transactions from other users are not visible
- Guests are redirected to login
This commit is contained in:
Víctor Falcón 2026-02-28 16:30:54 +00:00 committed by GitHub
parent e01d62ffd4
commit 9bb835e79b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 115 additions and 34 deletions

View File

@ -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,
]);
}

View File

@ -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"
/>
</div>

View File

@ -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'));
});