diff --git a/app/Http/Controllers/ReEvaluateTransactionRulesController.php b/app/Http/Controllers/ReEvaluateTransactionRulesController.php new file mode 100644 index 00000000..aa636615 --- /dev/null +++ b/app/Http/Controllers/ReEvaluateTransactionRulesController.php @@ -0,0 +1,75 @@ +authorize('update', $transaction); + + $service->applyRules($transaction); + + $transaction->refresh()->load('labels:id,name,color', 'category:id,name,icon,color'); + + return response()->json([ + 'data' => $transaction, + ]); + } + + /** + * Dispatch a background job to re-evaluate all (or selected) transactions. + * + * Returns a job ID the client can use to poll the status endpoint. + */ + public function bulk(BulkReEvaluateRulesRequest $request): JsonResponse + { + $user = $request->user(); + $transactionIds = $request->input('transaction_ids'); + + $jobId = (string) Str::uuid(); + + // Set initial pending state so the first poll returns something meaningful + Cache::put( + ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId), + ['status' => 'pending', 'processed' => 0, 'total' => 0, 'updated' => 0], + now()->addHour(), + ); + + ReEvaluateTransactionRulesJob::dispatch($user, $jobId, $transactionIds); + + return response()->json([ + 'job_id' => $jobId, + ], 202); + } + + /** + * Return current progress for a bulk re-evaluation job. + */ + public function status(Request $request, string $jobId): JsonResponse + { + $cacheKey = ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId); + $progress = Cache::get($cacheKey); + + if ($progress === null) { + return response()->json(['message' => 'Job not found.'], 404); + } + + return response()->json($progress); + } +} diff --git a/app/Http/Requests/BulkReEvaluateRulesRequest.php b/app/Http/Requests/BulkReEvaluateRulesRequest.php new file mode 100644 index 00000000..60486981 --- /dev/null +++ b/app/Http/Requests/BulkReEvaluateRulesRequest.php @@ -0,0 +1,28 @@ + ['nullable', 'array'], + 'transaction_ids.*' => ['required', 'string', 'uuid'], + ]; + } + + public function messages(): array + { + return [ + 'transaction_ids.*.uuid' => 'Invalid transaction ID format.', + ]; + } +} diff --git a/app/Jobs/ReEvaluateTransactionRulesJob.php b/app/Jobs/ReEvaluateTransactionRulesJob.php new file mode 100644 index 00000000..e3cf7c2e --- /dev/null +++ b/app/Jobs/ReEvaluateTransactionRulesJob.php @@ -0,0 +1,105 @@ +where('user_id', $this->user->id) + ->whereNull('description_iv'); + + if ($this->transactionIds !== null) { + $query->whereIn('id', $this->transactionIds); + } + + $total = $query->count(); + + $this->updateProgress(status: 'processing', processed: 0, total: $total, updated: 0); + + $processed = 0; + $updated = 0; + + $query->with(['account.bank', 'category', 'labels'])->chunkById(100, function ($transactions) use ($service, $total, &$processed, &$updated) { + foreach ($transactions as $transaction) { + $categoryBefore = $transaction->category_id; + $labelsBefore = $transaction->labels->pluck('id')->sort()->values()->all(); + $notesBefore = $transaction->notes; + + $service->applyRules($transaction); + + $transaction->refresh(); + $categoryAfter = $transaction->category_id; + $labelsAfter = $transaction->labels->pluck('id')->sort()->values()->all(); + $notesAfter = $transaction->notes; + + if ($categoryBefore !== $categoryAfter || $labelsBefore !== $labelsAfter || $notesBefore !== $notesAfter) { + $updated++; + } + + $processed++; + $this->updateProgress(status: 'processing', processed: $processed, total: $total, updated: $updated); + } + }); + + $this->updateProgress(status: 'done', processed: $processed, total: $total, updated: $updated); + } + + public function failed(\Throwable $exception): void + { + $cached = Cache::get($this->cacheKey()); + + $this->updateProgress( + status: 'failed', + processed: $cached['processed'] ?? 0, + total: $cached['total'] ?? 0, + updated: $cached['updated'] ?? 0, + ); + } + + public static function cacheKeyForJobId(string $jobId): string + { + return "re_evaluate_rules_job_{$jobId}"; + } + + private function cacheKey(): string + { + return self::cacheKeyForJobId($this->jobId); + } + + /** + * @param 'pending'|'processing'|'done'|'failed' $status + */ + private function updateProgress(string $status, int $processed, int $total, int $updated): void + { + Cache::put($this->cacheKey(), [ + 'status' => $status, + 'processed' => $processed, + 'total' => $total, + 'updated' => $updated, + ], now()->addHour()); + } +} diff --git a/app/Services/AutomationRuleService.php b/app/Services/AutomationRuleService.php index 75217730..79958f4f 100644 --- a/app/Services/AutomationRuleService.php +++ b/app/Services/AutomationRuleService.php @@ -85,8 +85,27 @@ class AutomationRuleService private function applyActions(Transaction $transaction, AutomationRule $rule): void { + $dirty = false; + if ($rule->action_category_id) { $transaction->category_id = $rule->action_category_id; + $dirty = true; + } + + // Only apply plain (unencrypted) notes — encrypted notes require the user's key + if ($rule->action_note && $rule->action_note_iv === null) { + $existingNotes = $transaction->notes ?? ''; + $ruleNote = $rule->action_note; + + if (! $this->noteAlreadyPresent($existingNotes, $ruleNote)) { + $transaction->notes = $existingNotes + ? $existingNotes."\n".$ruleNote + : $ruleNote; + $dirty = true; + } + } + + if ($dirty) { $transaction->saveQuietly(); } @@ -96,6 +115,11 @@ class AutomationRuleService } } + private function noteAlreadyPresent(string $existingNotes, string $note): bool + { + return mb_strpos($existingNotes, $note) !== false; + } + private function normalizeRuleJson(mixed $rulesJson): mixed { if (is_string($rulesJson)) { diff --git a/resources/js/components/transactions/transaction-actions-menu.tsx b/resources/js/components/transactions/transaction-actions-menu.tsx index a44f3233..a52ab096 100644 --- a/resources/js/components/transactions/transaction-actions-menu.tsx +++ b/resources/js/components/transactions/transaction-actions-menu.tsx @@ -74,20 +74,9 @@ export function TransactionActionsMenu({ }; const handleReEvaluateAll = async () => { - if (!transactions.length) { - toast.error('No transactions to re-evaluate'); - return; - } - setIsReEvaluating(true); try { - await reEvaluateAll( - transactions, - categories, - accounts, - banks, - automationRules, - ); + await reEvaluateAll(); onReEvaluateComplete?.(); } finally { setIsReEvaluating(false); @@ -193,7 +182,7 @@ export function TransactionActionsMenu({ {__('Re-evaluate All Expenses')} diff --git a/resources/js/components/transactions/transaction-list.tsx b/resources/js/components/transactions/transaction-list.tsx index 745fffaf..d30445d5 100644 --- a/resources/js/components/transactions/transaction-list.tsx +++ b/resources/js/components/transactions/transaction-list.tsx @@ -24,6 +24,7 @@ import { } from 'react'; import { toast } from 'sonner'; +import { single as reEvaluateSingle } from '@/actions/App/Http/Controllers/ReEvaluateTransactionRulesController'; import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar'; import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog'; import { TransactionActionsMenu } from '@/components/transactions/transaction-actions-menu'; @@ -58,7 +59,6 @@ import { decrypt, importKey } from '@/lib/crypto'; import { consoleDebug } from '@/lib/debug'; import { db } from '@/lib/dexie-db'; import { getStoredKey } from '@/lib/key-storage'; -import { evaluateRules } from '@/lib/rule-engine'; import { transactionSyncService } from '@/services/transaction-sync'; import { type Account, type Bank } from '@/types/account'; import { type AutomationRule } from '@/types/automation-rule'; @@ -836,80 +836,24 @@ export function TransactionList({ const handleReEvaluateRules = useCallback( async (transaction: DecryptedTransaction) => { consoleDebug('=== Re-evaluating rules for single transaction ==='); - consoleDebug('Transaction:', { - id: transaction.id, - description: transaction.decryptedDescription, - amount: transaction.amount, - currentCategory: transaction.category?.name || 'None', - }); setIsReEvaluating(true); try { - consoleDebug( - `Found ${automationRules.length} automation rules`, - ); + const response = await axios.post<{ + data: DecryptedTransaction; + }>(reEvaluateSingle({ transaction: transaction.id }).url); - if (automationRules.length === 0) { - consoleDebug('❌ No rules to evaluate'); - return; - } + const updated = response.data.data; - consoleDebug('Evaluating rules against transaction...'); - const result = await evaluateRules( - transaction, - automationRules, - categories, - accounts, - banks, - null, - ); - - consoleDebug('Rule evaluation result:', result); - - if (result) { - consoleDebug('✓ Rule matched! Applying changes...'); - let finalNotes = transaction.notes; - let finalNotesIv = transaction.notes_iv; - - const updateData = { - category_id: result.categoryId, - notes: finalNotes, - notes_iv: finalNotesIv, - }; - consoleDebug('Updating transaction with:', updateData); - - await transactionSyncService.update( - transaction.id, - updateData, - ); - consoleDebug('✓ Transaction updated in IndexedDB'); - - const selectedCategory = result.categoryId - ? categories.find((c) => c.id === result.categoryId) || - null - : null; - - const decryptedNotes = transaction.decryptedNotes; - - const updatedTransaction = { - ...transaction, - category_id: result.categoryId, - category: selectedCategory, - notes: finalNotes, - notes_iv: finalNotesIv, - decryptedNotes, - }; - consoleDebug('Updating UI state with:', { - id: updatedTransaction.id, - newCategory: selectedCategory?.name || 'None', - hasNotes: !!decryptedNotes, - }); - - updateTransaction(updatedTransaction); - consoleDebug('✓ UI state updated successfully'); - } else { - consoleDebug('❌ No rules matched this transaction'); - } + updateTransaction({ + ...transaction, + category_id: updated.category_id, + category: updated.category, + labels: updated.labels, + notes: updated.notes, + notes_iv: updated.notes_iv, + }); + consoleDebug('✓ UI state updated successfully'); } catch (error) { consoleDebug('❌ Error during re-evaluation:', error); console.error('Failed to re-evaluate rules:', error); @@ -932,132 +876,67 @@ export function TransactionList({ } setIsReEvaluating(true); + const toastId = toast.loading(`Re-evaluating 0 of ... transactions...`); + try { - consoleDebug(`Found ${automationRules.length} automation rules`); - - if (automationRules.length === 0) { - consoleDebug('❌ No rules to evaluate'); - return; - } - - const selectedTransactions = transactions.filter((t) => - selectedIds.includes(t.id.toString()), - ); - consoleDebug( - 'Processing transactions:', - selectedTransactions.map((t) => ({ - id: t.id, - description: t.decryptedDescription, - currentCategory: t.category?.name || 'None', - })), + const bulkResponse = await axios.post<{ job_id: string }>( + reEvaluateBulk().url, + { transaction_ids: selectedIds }, ); - const updates: Array<{ - transaction: DecryptedTransaction; - categoryId: number | null; - category: Category | null; - notes: string | null; - notesIv: string | null; - decryptedNotes: string | null; - }> = []; + const jobId = bulkResponse.data.job_id; - for (const transaction of selectedTransactions) { - consoleDebug(`\nEvaluating transaction ${transaction.id}...`); - const result = await evaluateRules( - transaction, - automationRules, - categories, - accounts, - banks, - null, - ); + await new Promise((resolve, reject) => { + const poll = async () => { + try { + const statusResponse = await axios.get<{ + status: string; + processed: number; + total: number; + updated: number; + }>(reEvaluateStatus({ jobId }).url); - consoleDebug('Rule evaluation result:', result); + const { status, processed, total, updated } = + statusResponse.data; - if (result) { - consoleDebug('✓ Rule matched! Applying changes...'); - const finalNotes = transaction.notes; - const finalNotesIv = transaction.notes_iv; - - const updateData = { - category_id: result.categoryId, - notes: finalNotes, - notes_iv: finalNotesIv, - }; - consoleDebug('Updating transaction with:', updateData); - - await transactionSyncService.update( - transaction.id, - updateData, - ); - consoleDebug('✓ Transaction updated in IndexedDB'); - - const selectedCategory = result.categoryId - ? categories.find((c) => c.id === result.categoryId) || - null - : null; - - const decryptedNotes = transaction.decryptedNotes; - - updates.push({ - transaction, - categoryId: result.categoryId, - category: selectedCategory, - notes: finalNotes, - notesIv: finalNotesIv, - decryptedNotes, - }); - consoleDebug( - `✓ Queued update for transaction ${transaction.id}`, - ); - } else { - consoleDebug( - `❌ No rules matched transaction ${transaction.id}`, - ); - } - } - - consoleDebug(`\nApplying ${updates.length} updates to UI state...`); - if (updates.length > 0) { - setTransactions((previous) => - previous.map((transaction) => { - const update = updates.find( - (u) => u.transaction.id === transaction.id, + toast.loading( + `Re-evaluating ${processed} of ${total} transactions...`, + { id: toastId }, ); - if (update) { - consoleDebug( - `Updating UI for transaction ${transaction.id}:`, - { - newCategory: - update.category?.name || 'None', - hasNotes: !!update.decryptedNotes, - }, - ); - return { - ...transaction, - category_id: update.categoryId, - category: update.category, - notes: update.notes, - notes_iv: update.notesIv, - decryptedNotes: update.decryptedNotes, - }; - } - return transaction; - }), - ); - consoleDebug('✓ UI state updated successfully'); - } else { - consoleDebug('❌ No updates to apply'); - } - consoleDebug('Clearing selection...'); + if (status === 'done') { + toast.dismiss(toastId); + toast.success(() => ( +
+ {`Re-evaluation complete!`} +
+ {`${updated} transaction(s) updated.`} +
+ )); + resolve(); + } else if (status === 'failed') { + reject(new Error('Job failed')); + } else { + setTimeout(poll, 1000); + } + } catch (error) { + reject(error); + } + }; + + poll(); + }); + setRowSelection({}); + consoleDebug('=== Bulk re-evaluation complete ==='); } catch (error) { consoleDebug('❌ Error during bulk re-evaluation:', error); console.error('Failed to re-evaluate rules:', error); + toast.error('Failed to re-evaluate rules. Please try again.', { + id: toastId, + }); } finally { setIsReEvaluating(false); - consoleDebug('=== Bulk re-evaluation complete ==='); } } diff --git a/resources/js/hooks/use-re-evaluate-all-transactions.tsx b/resources/js/hooks/use-re-evaluate-all-transactions.tsx index f9be5cb9..b0bae253 100644 --- a/resources/js/hooks/use-re-evaluate-all-transactions.tsx +++ b/resources/js/hooks/use-re-evaluate-all-transactions.tsx @@ -1,103 +1,71 @@ -import { evaluateRules } from '@/lib/rule-engine'; -import { transactionSyncService } from '@/services/transaction-sync'; -import type { Account, Bank } from '@/types/account'; -import type { AutomationRule } from '@/types/automation-rule'; -import type { Category } from '@/types/category'; -import type { DecryptedTransaction } from '@/types/transaction'; +import { + bulk as reEvaluateBulk, + status as reEvaluateStatus, +} from '@/actions/App/Http/Controllers/ReEvaluateTransactionRulesController'; +import axios from 'axios'; import { useCallback } from 'react'; import { toast } from 'sonner'; -interface ReEvaluateAllOptions { - onProgress?: (progress: { - current: number; - total: number; - transactionId: string; - description: string; - }) => void; -} - export function useReEvaluateAllTransactions() { - const reEvaluateAll = useCallback( - async ( - transactions: DecryptedTransaction[], - categories: Category[], - accounts: Account[], - banks: Bank[], - automationRules: AutomationRule[], - options?: ReEvaluateAllOptions, - ) => { - if (!transactions.length) { - toast.error('No transactions to re-evaluate'); - return; - } + const reEvaluateAll = useCallback(async () => { + const toastId = toast.loading(`Re-evaluating 0 of ... transactions...`); - if (!automationRules.length) { - toast.error('No automation rules found'); - return; - } - - const toastId = toast.loading( - `Re-evaluating 0 of ${transactions.length} transactions...`, + try { + const bulkResponse = await axios.post<{ job_id: string }>( + reEvaluateBulk().url, ); - let successCount = 0; + const jobId = bulkResponse.data.job_id; - try { - for (let i = 0; i < transactions.length; i++) { - const transaction = transactions[i]; - const progress = i + 1; + await new Promise((resolve, reject) => { + const poll = async () => { + try { + const statusResponse = await axios.get<{ + status: string; + processed: number; + total: number; + updated: number; + }>(reEvaluateStatus({ jobId }).url); - options?.onProgress?.({ - current: progress, - total: transactions.length, - transactionId: transaction.id, - description: transaction.decryptedDescription, - }); + const { status, processed, total, updated } = + statusResponse.data; - const result = await evaluateRules( - transaction, - automationRules, - categories, - accounts, - banks, - null, - ); + toast.loading( + `Re-evaluating ${processed} of ${total} transactions...`, + { id: toastId }, + ); - if (result) { - await transactionSyncService.update(transaction.id, { - category_id: result.categoryId, - notes: transaction.notes, - notes_iv: transaction.notes_iv, - }); - - successCount++; + if (status === 'done') { + toast.dismiss(toastId); + toast.success(() => ( +
+ {`Re-evaluation complete!`} +
+ {`${updated} transaction(s) updated.`} +
+ )); + resolve(); + } else if (status === 'failed') { + reject(new Error('Job failed')); + } else { + setTimeout(poll, 1000); + } + } catch (error) { + reject(error); } + }; - toast.loading( - `Re-evaluating ${progress} of ${transactions.length} transactions...`, - { id: toastId }, - ); - } - - toast.dismiss(toastId); - toast.success(() => ( -
- {`Re-evaluation complete!`} -
- {`${successCount} transaction(s) updated.`} -
- )); - } catch (error) { - console.error('Failed to re-evaluate transactions:', error); - toast.error( - 'Failed to re-evaluate transactions. Please try again.', - { id: toastId }, - ); - throw error; - } - }, - [], - ); + poll(); + }); + } catch (error) { + console.error('Failed to re-evaluate transactions:', error); + toast.error( + 'Failed to re-evaluate transactions. Please try again.', + { id: toastId }, + ); + throw error; + } + }, []); return { reEvaluateAll }; } diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 1dca1c92..3fd4f0fa 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -11,10 +11,16 @@ import { useReactTable, } from '@tanstack/react-table'; import { VirtualItem, Virtualizer } from '@tanstack/react-virtual'; +import axios from 'axios'; import { format, getYear, parseISO } from 'date-fns'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; +import { + bulk as reEvaluateBulk, + single as reEvaluateSingle, + status as reEvaluateStatus, +} from '@/actions/App/Http/Controllers/ReEvaluateTransactionRulesController'; import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController'; import HeadingSmall from '@/components/heading-small'; import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar'; @@ -56,11 +62,8 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Spinner } from '@/components/ui/spinner'; import { TableCell, TableRow } from '@/components/ui/table'; import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; -import { decrypt, importKey } from '@/lib/crypto'; import { consoleDebug } from '@/lib/debug'; -import { getStoredKey } from '@/lib/key-storage'; -import { evaluateRules } from '@/lib/rule-engine'; -import { appendNoteIfNotPresent, cn } from '@/lib/utils'; +import { cn } from '@/lib/utils'; import { transactionSyncService } from '@/services/transaction-sync'; import { type BreadcrumbItem } from '@/types'; import { type Account, type Bank } from '@/types/account'; @@ -592,95 +595,30 @@ export default function Transactions({ setIsReEvaluating(true); try { - const keyString = getStoredKey(); - if (!keyString) { - toast.error( - 'Please unlock your encryption key to re-evaluate rules', - ); - return; - } + const response = await axios.post<{ + data: DecryptedTransaction; + }>(reEvaluateSingle({ transaction: transaction.id }).url); - const key = await importKey(keyString); - const rules = automationRules; + const updated = response.data.data; - if (rules.length === 0) { - return; - } - - const result = await evaluateRules( - transaction, - rules, - categories, - accounts, - banks, - key, - ); - - if (result) { - let finalNotes = transaction.notes; - let finalNotesIv = transaction.notes_iv; - let decryptedNotes = transaction.decryptedNotes; - - if (result.note && result.noteIv) { - const decryptedRuleNote = await decrypt( - result.note, - key, - result.noteIv, - ); - const combinedNote = appendNoteIfNotPresent( - transaction.decryptedNotes, - decryptedRuleNote, - ); - - if (combinedNote !== transaction.decryptedNotes) { - finalNotes = combinedNote; - finalNotesIv = null; - decryptedNotes = combinedNote; - } - } else if (result.note && !result.noteIv) { - const combinedNote = appendNoteIfNotPresent( - transaction.decryptedNotes, - result.note, - ); - - if (combinedNote !== transaction.decryptedNotes) { - finalNotes = combinedNote; - finalNotesIv = null; - decryptedNotes = combinedNote; - } - } - - await transactionSyncService.update(transaction.id, { - category_id: result.categoryId, - notes: finalNotes, - notes_iv: finalNotesIv, - }); - - const selectedCategory = result.categoryId - ? categories.find((c) => c.id === result.categoryId) || - null - : null; - - updateTransaction({ - ...transaction, - category_id: result.categoryId, - category: selectedCategory, - notes: finalNotes, - notes_iv: finalNotesIv, - decryptedNotes, - }); - } + updateTransaction({ + ...transaction, + category_id: updated.category_id, + category: updated.category, + labels: updated.labels, + notes: updated.notes, + notes_iv: updated.notes_iv, + }); } catch (error) { console.error('Failed to re-evaluate rules:', error); } finally { setIsReEvaluating(false); } }, - [categories, accounts, banks, updateTransaction, automationRules], + [updateTransaction], ); async function handleBulkReEvaluateRules() { - const BATCH_SIZE = 25; consoleDebug('=== Re-evaluating rules for bulk transactions ==='); if (selectedIds.length === 0) { @@ -688,163 +626,64 @@ export default function Transactions({ } setIsReEvaluating(true); + const toastId = toast.loading(`Re-evaluating 0 of ... transactions...`); + try { - const keyString = getStoredKey(); - if (!keyString) { - toast.error( - 'Please unlock your encryption key to re-evaluate rules', - ); - return; - } - - const key = await importKey(keyString); - const rules = automationRules; - - if (rules.length === 0) { - return; - } - - const selectedTransactions = allTransactions.filter((t) => - selectedIds.includes(t.id.toString()), + const bulkResponse = await axios.post<{ job_id: string }>( + reEvaluateBulk().url, + { transaction_ids: selectedIds }, ); - const allUpdates: Array<{ - transaction: DecryptedTransaction; - categoryId: string | null; - category: Category | null; - notes: string | null; - notesIv: string | null; - decryptedNotes: string | null; - }> = []; + const jobId = bulkResponse.data.job_id; - const dbUpdates: Array<{ - id: string; - data: { - category_id: string | null; - notes: string | null; - notes_iv: string | null; + await new Promise((resolve, reject) => { + const poll = async () => { + try { + const statusResponse = await axios.get<{ + status: string; + processed: number; + total: number; + updated: number; + }>(reEvaluateStatus({ jobId }).url); + + const { status, processed, total, updated } = + statusResponse.data; + + toast.loading( + `Re-evaluating ${processed} of ${total} transactions...`, + { id: toastId }, + ); + + if (status === 'done') { + toast.dismiss(toastId); + toast.success(() => ( +
+ {`Re-evaluation complete!`} +
+ {`${updated} transaction(s) updated.`} +
+ )); + resolve(); + } else if (status === 'failed') { + reject(new Error('Job failed')); + } else { + setTimeout(poll, 1000); + } + } catch (error) { + reject(error); + } }; - }> = []; - for (const transaction of selectedTransactions) { - const result = await evaluateRules( - transaction, - rules, - categories, - accounts, - banks, - key, - ); - - if (result) { - let finalNotes = transaction.notes; - let finalNotesIv = transaction.notes_iv; - - if (result.note && result.noteIv) { - const decryptedRuleNote = await decrypt( - result.note, - key, - result.noteIv, - ); - const combinedNote = appendNoteIfNotPresent( - transaction.decryptedNotes, - decryptedRuleNote, - ); - - if (combinedNote !== transaction.decryptedNotes) { - finalNotes = combinedNote; - finalNotesIv = null; - } - } else if (result.note && !result.noteIv) { - const combinedNote = appendNoteIfNotPresent( - transaction.decryptedNotes, - result.note, - ); - - if (combinedNote !== transaction.decryptedNotes) { - finalNotes = combinedNote; - finalNotesIv = null; - } - } - - dbUpdates.push({ - id: transaction.id, - data: { - category_id: result.categoryId, - notes: finalNotes, - notes_iv: finalNotesIv, - }, - }); - - const selectedCategory = result.categoryId - ? categories.find((c) => c.id === result.categoryId) || - null - : null; - - let decryptedNotes = transaction.decryptedNotes; - if (finalNotes && !finalNotesIv) { - decryptedNotes = finalNotes; - } else if (finalNotes && finalNotesIv) { - decryptedNotes = await decrypt( - finalNotes, - key, - finalNotesIv, - ); - } - - allUpdates.push({ - transaction, - categoryId: result.categoryId, - category: selectedCategory, - notes: finalNotes, - notesIv: finalNotesIv, - decryptedNotes, - }); - } - } - - if (dbUpdates.length > 0) { - await transactionSyncService.updateManyIndividual(dbUpdates); - } - - if (allUpdates.length > 0) { - for (let i = 0; i < allUpdates.length; i += BATCH_SIZE) { - const batch = allUpdates.slice(i, i + BATCH_SIZE); - const batchIds = new Set( - batch.map((u) => u.transaction.id), - ); - - setAllTransactions((previous) => - previous.map((transaction) => { - if (!batchIds.has(transaction.id)) { - return transaction; - } - const update = batch.find( - (u) => u.transaction.id === transaction.id, - ); - if (update) { - return { - ...transaction, - category_id: update.categoryId, - category: update.category, - notes: update.notes, - notes_iv: update.notesIv, - decryptedNotes: update.decryptedNotes, - }; - } - return transaction; - }), - ); - - if (i + BATCH_SIZE < allUpdates.length) { - await new Promise((resolve) => setTimeout(resolve, 50)); - } - } - } + poll(); + }); setRowSelection({}); + refreshTransactions(); } catch (error) { console.error('Failed to re-evaluate rules:', error); + toast.error('Failed to re-evaluate rules. Please try again.', { + id: toastId, + }); } finally { setIsReEvaluating(false); } diff --git a/routes/web.php b/routes/web.php index da1dcecd..14bbc09e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Http\Controllers\OpenBanking\BinanceController; use App\Http\Controllers\OpenBanking\BitpandaController; use App\Http\Controllers\OpenBanking\IndexaCapitalController; use App\Http\Controllers\OpenBanking\InstitutionController; +use App\Http\Controllers\ReEvaluateTransactionRulesController; use App\Http\Controllers\RobotsController; use App\Http\Controllers\SitemapController; use App\Http\Controllers\SubscriptionController; @@ -65,8 +66,11 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi Route::get('transactions', [TransactionController::class, 'index'])->name('transactions.index'); Route::get('transactions/categorize', [TransactionController::class, 'categorize'])->name('transactions.categorize'); Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('transactions.bulk-update'); + Route::post('transactions/re-evaluate-rules', [ReEvaluateTransactionRulesController::class, 'bulk'])->name('transactions.re-evaluate-rules.bulk'); + Route::get('transactions/re-evaluate-rules/status/{jobId}', [ReEvaluateTransactionRulesController::class, 'status'])->name('transactions.re-evaluate-rules.status'); Route::patch('transactions/{transaction}', [TransactionController::class, 'update'])->name('transactions.update'); Route::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy'); + Route::post('transactions/{transaction}/re-evaluate-rules', [ReEvaluateTransactionRulesController::class, 'single'])->name('transactions.re-evaluate-rules.single'); }); Route::middleware(['auth', 'verified', 'onboarded', 'subscribed', 'open-banking'])->prefix('open-banking')->group(function () { diff --git a/tests/Feature/ReEvaluateTransactionRulesTest.php b/tests/Feature/ReEvaluateTransactionRulesTest.php new file mode 100644 index 00000000..58c921aa --- /dev/null +++ b/tests/Feature/ReEvaluateTransactionRulesTest.php @@ -0,0 +1,356 @@ +user = User::factory()->onboarded()->create(); + $this->bank = Bank::factory()->create(['name' => 'Test Bank', 'user_id' => $this->user->id]); + $this->account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + 'name' => 'Checking Account', + 'encrypted' => false, + ]); + $this->category = Category::factory()->create(['user_id' => $this->user->id]); +}); + +// ────────────────────────────────────────────── +// Single re-evaluate endpoint +// ────────────────────────────────────────────── + +test('single endpoint applies matching rule and returns updated transaction', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['grocery', ['var' => 'description']]], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Grocery Store Purchase', + 'amount' => -5000, + 'category_id' => null, + ]); + + $response = $this->actingAs($this->user) + ->postJson(route('transactions.re-evaluate-rules.single', $transaction)); + + $response->assertOk() + ->assertJsonPath('data.category_id', $this->category->id); + + expect($transaction->fresh()->category_id)->toBe($this->category->id); +}); + +test('single endpoint assigns labels from matching rule', function () { + $label = Label::factory()->create(['user_id' => $this->user->id]); + + AutomationRule::factory() + ->hasAttached($label, [], 'labels') + ->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['netflix', ['var' => 'description']]], + 'action_category_id' => null, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Netflix subscription', + 'amount' => -1500, + ]); + + $this->actingAs($this->user) + ->postJson(route('transactions.re-evaluate-rules.single', $transaction)) + ->assertOk(); + + expect($transaction->fresh()->labels->pluck('id'))->toContain($label->id); +}); + +test('single endpoint applies plain action_note from matching rule', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['spotify', ['var' => 'description']]], + 'action_category_id' => null, + 'action_note' => 'Streaming service', + 'action_note_iv' => null, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Spotify Premium', + 'amount' => -999, + 'notes' => null, + ]); + + $this->actingAs($this->user) + ->postJson(route('transactions.re-evaluate-rules.single', $transaction)) + ->assertOk(); + + expect($transaction->fresh()->notes)->toBe('Streaming service'); +}); + +test('single endpoint does not duplicate a note already present', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['spotify', ['var' => 'description']]], + 'action_category_id' => null, + 'action_note' => 'Streaming service', + 'action_note_iv' => null, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Spotify Premium', + 'amount' => -999, + 'notes' => 'Streaming service', + ]); + + $this->actingAs($this->user) + ->postJson(route('transactions.re-evaluate-rules.single', $transaction)) + ->assertOk(); + + expect($transaction->fresh()->notes)->toBe('Streaming service'); +}); + +test('single endpoint skips encrypted transactions silently', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['grocery', ['var' => 'description']]], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'encrypted-blob', + 'description_iv' => 'a1b2c3d4e5f60001', + 'amount' => -5000, + 'category_id' => null, + ]); + + $this->actingAs($this->user) + ->postJson(route('transactions.re-evaluate-rules.single', $transaction)) + ->assertOk(); + + // Category should remain null because the backend skips encrypted transactions + expect($transaction->fresh()->category_id)->toBeNull(); +}); + +test('single endpoint returns 403 for another user\'s transaction', function () { + $otherUser = User::factory()->onboarded()->create(); + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $otherUser->id, + 'account_id' => Account::factory()->create(['user_id' => $otherUser->id])->id, + 'description' => 'Some purchase', + 'amount' => -1000, + ]); + + $this->actingAs($this->user) + ->postJson(route('transactions.re-evaluate-rules.single', $transaction)) + ->assertForbidden(); +}); + +// ────────────────────────────────────────────── +// Bulk re-evaluate endpoint +// ────────────────────────────────────────────── + +test('bulk endpoint dispatches job and returns job_id', function () { + Queue::fake(); + + $response = $this->actingAs($this->user) + ->postJson(route('transactions.re-evaluate-rules.bulk')); + + $response->assertStatus(202) + ->assertJsonStructure(['job_id']); + + Queue::assertPushed(ReEvaluateTransactionRulesJob::class); +}); + +test('bulk endpoint dispatches job with provided transaction_ids', function () { + Queue::fake(); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Purchase', + 'amount' => -1000, + ]); + + $response = $this->actingAs($this->user) + ->postJson(route('transactions.re-evaluate-rules.bulk'), [ + 'transaction_ids' => [$transaction->id], + ]); + + $response->assertStatus(202); + + Queue::assertPushed(ReEvaluateTransactionRulesJob::class, function ($job) use ($transaction) { + return $job->transactionIds === [$transaction->id]; + }); +}); + +test('bulk endpoint sets initial pending status in cache', function () { + Queue::fake(); + + $response = $this->actingAs($this->user) + ->postJson(route('transactions.re-evaluate-rules.bulk')); + + $jobId = $response->json('job_id'); + $cacheKey = ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId); + + expect(Cache::get($cacheKey))->toMatchArray(['status' => 'pending']); +}); + +// ────────────────────────────────────────────── +// Status endpoint +// ────────────────────────────────────────────── + +test('status endpoint returns progress from cache', function () { + $jobId = 'test-job-id'; + $cacheKey = ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId); + + Cache::put($cacheKey, [ + 'status' => 'processing', + 'processed' => 10, + 'total' => 50, + 'updated' => 3, + ], now()->addHour()); + + $this->actingAs($this->user) + ->getJson(route('transactions.re-evaluate-rules.status', $jobId)) + ->assertOk() + ->assertJson([ + 'status' => 'processing', + 'processed' => 10, + 'total' => 50, + 'updated' => 3, + ]); +}); + +test('status endpoint returns 404 for unknown job', function () { + $this->actingAs($this->user) + ->getJson(route('transactions.re-evaluate-rules.status', 'non-existent-job-id')) + ->assertNotFound(); +}); + +// ────────────────────────────────────────────── +// Job execution +// ────────────────────────────────────────────── + +test('job applies rules to non-encrypted transactions and tracks progress', function () { + // Create transactions BEFORE the rule so the creation listener has no rules to apply + $matchingTransaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Grocery Store', + 'amount' => -5000, + 'category_id' => null, + ]); + + $nonMatchingTransaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Coffee Shop', + 'amount' => -500, + 'category_id' => null, + ]); + + // Add the rule after creation so re-evaluation is meaningful + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['grocery', ['var' => 'description']]], + 'action_category_id' => $this->category->id, + ]); + + $jobId = 'test-job-'.uniqid(); + $job = new ReEvaluateTransactionRulesJob($this->user, $jobId); + $job->handle(app(\App\Services\AutomationRuleService::class)); + + expect($matchingTransaction->fresh()->category_id)->toBe($this->category->id); + expect($nonMatchingTransaction->fresh()->category_id)->toBeNull(); + + $progress = Cache::get(ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId)); + expect($progress['status'])->toBe('done'); + expect($progress['processed'])->toBe(2); + expect($progress['updated'])->toBe(1); +}); + +test('job skips encrypted transactions', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['grocery', ['var' => 'description']]], + 'action_category_id' => $this->category->id, + ]); + + Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'encrypted-blob', + 'description_iv' => 'a1b2c3d4e5f60001', + 'amount' => -5000, + 'category_id' => null, + ]); + + $jobId = 'test-job-'.uniqid(); + $job = new ReEvaluateTransactionRulesJob($this->user, $jobId); + $job->handle(app(\App\Services\AutomationRuleService::class)); + + $progress = Cache::get(ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId)); + // 0 processed because the encrypted transaction is excluded from the query + expect($progress['processed'])->toBe(0); + expect($progress['updated'])->toBe(0); +}); + +test('job only processes provided transaction_ids', function () { + // Create transactions BEFORE the rule so the creation listener has no rules to apply + $t1 = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Grocery Store', + 'amount' => -5000, + 'category_id' => null, + ]); + + $t2 = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Grocery Supermarket', + 'amount' => -3000, + 'category_id' => null, + ]); + + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['grocery', ['var' => 'description']]], + 'action_category_id' => $this->category->id, + ]); + + $jobId = 'test-job-'.uniqid(); + $job = new ReEvaluateTransactionRulesJob($this->user, $jobId, [$t1->id]); + $job->handle(app(\App\Services\AutomationRuleService::class)); + + expect($t1->fresh()->category_id)->toBe($this->category->id); + expect($t2->fresh()->category_id)->toBeNull(); + + $progress = Cache::get(ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId)); + expect($progress['processed'])->toBe(1); +});