feat(rules): move automation rule evaluation to the backend (#168)

## 🚪 Why?

### Problem

Automation rule evaluation was happening entirely on the frontend,
relying on the client to fetch rules, loop over transactions, and apply
category/label/note changes. This meant evaluation was tied to
browser-side decryption logic, couldn't be triggered server-side, and
had no reliable progress reporting for bulk operations.

## 🔑 What?

### Changes

- **Single re-evaluation**: `POST
/transactions/{transaction}/re-evaluate-rules` applies all matching
rules immediately and returns the updated transaction; the frontend
updates local state from the response.
- **Bulk re-evaluation**: `POST /transactions/re-evaluate-rules`
dispatches a queued job and returns a `job_id`; the frontend polls `GET
/transactions/re-evaluate-rules/status/{jobId}` ~every second, updates a
progress toast, and refreshes the list when done.
- **Note support**: `AutomationRuleService` now applies plain
(unencrypted) `action_note` actions; encrypted notes are skipped since
the backend cannot decrypt them.
- **Dirty-only saves**: `AutomationRuleService::applyActions()` now
tracks whether any field changed and only calls `saveQuietly()` when
needed, avoiding unnecessary writes.
- **Encrypted transactions skipped**: Transactions with `description_iv`
are excluded from bulk queries and silently skipped on single
re-evaluation (existing behavior preserved).
- **Frontend cleanup**: Removed `evaluateRules`,
`appendNoteIfNotPresent`, and related crypto imports from
`transaction-list.tsx`, `index.tsx`,
`use-re-evaluate-all-transactions.tsx`, and
`transaction-actions-menu.tsx`.

##  Verification

### Tests

- New: `tests/Feature/ReEvaluateTransactionRulesTest.php` — 14 passing
tests covering:
  - Single re-evaluation applies category, label, and note
  - Single re-evaluation skips encrypted transactions
  - Bulk job dispatched and returns 202 with job_id
  - Bulk status polling returns `processing` and `done`
- Partial bulk (specific transaction_ids) only processes selected
transactions
  - Encrypted transactions excluded from bulk processing
  - Unauthenticated requests are rejected (401)

### Manual Verification

- Row dropdown "Re-evaluate rules" calls backend and updates the row in
place
- Bulk "Re-evaluate All Expenses" dispatches job, shows progress toast,
refreshes list on completion
- Encrypted transactions are silently skipped in both flows
This commit is contained in:
Víctor Falcón 2026-03-01 09:37:12 +00:00 committed by GitHub
parent dc812cc820
commit eda72d4304
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 778 additions and 511 deletions

View File

@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\BulkReEvaluateRulesRequest;
use App\Jobs\ReEvaluateTransactionRulesJob;
use App\Models\Transaction;
use App\Services\AutomationRuleService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class ReEvaluateTransactionRulesController extends Controller
{
use AuthorizesRequests;
/**
* Re-evaluate automation rules for a single transaction.
*/
public function single(Request $request, Transaction $transaction, AutomationRuleService $service): JsonResponse
{
$this->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);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class BulkReEvaluateRulesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'transaction_ids' => ['nullable', 'array'],
'transaction_ids.*' => ['required', 'string', 'uuid'],
];
}
public function messages(): array
{
return [
'transaction_ids.*.uuid' => 'Invalid transaction ID format.',
];
}
}

View File

@ -0,0 +1,105 @@
<?php
namespace App\Jobs;
use App\Models\Transaction;
use App\Models\User;
use App\Services\AutomationRuleService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
class ReEvaluateTransactionRulesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 600;
public function __construct(
public User $user,
public string $jobId,
public ?array $transactionIds = null,
) {}
public function handle(AutomationRuleService $service): void
{
$query = Transaction::query()
->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());
}
}

View File

@ -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)) {

View File

@ -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({
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleReEvaluateAll}
disabled={isReEvaluating || !transactions.length}
disabled={isReEvaluating}
>
<WandSparkles className="mr-2 h-4 w-4" />
{__('Re-evaluate All Expenses')}

View File

@ -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<void>((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(() => (
<div>
{`Re-evaluation complete!`}
<br />
{`${updated} transaction(s) updated.`}
</div>
));
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 ===');
}
}

View File

@ -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<void>((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(() => (
<div>
{`Re-evaluation complete!`}
<br />
{`${updated} transaction(s) updated.`}
</div>
));
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(() => (
<div>
{`Re-evaluation complete!`}
<br />
{`${successCount} transaction(s) updated.`}
</div>
));
} 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 };
}

View File

@ -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<void>((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(() => (
<div>
{`Re-evaluation complete!`}
<br />
{`${updated} transaction(s) updated.`}
</div>
));
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);
}

View File

@ -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 () {

View File

@ -0,0 +1,356 @@
<?php
use App\Jobs\ReEvaluateTransactionRulesJob;
use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
$this->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);
});