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:
parent
dc812cc820
commit
eda72d4304
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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 ===');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue