feat(automation): Add re-evaluate all transactions functionality

This commit is contained in:
Víctor Falcón 2025-11-14 14:18:43 +01:00
parent 7f243bbc1e
commit e937a8647d
20 changed files with 248 additions and 68 deletions

View File

@ -25,7 +25,7 @@ class CreateDefaultCategories
*/
public static function getDefaultCategories(): array
{
return
return [
['name' => 'Administrative violations', 'icon' => 'AlertTriangle', 'color' => 'red'],
['name' => 'Alimony and child support', 'icon' => 'Users', 'color' => 'teal'],
['name' => 'Association membership fees', 'icon' => 'Users2', 'color' => 'cyan'],

View File

@ -26,5 +26,3 @@ class AccountSyncController extends Controller
]);
}
}

View File

@ -27,5 +27,3 @@ class BankSyncController extends Controller
]);
}
}

View File

@ -24,5 +24,3 @@ class CategorySyncController extends Controller
]);
}
}

View File

@ -83,12 +83,12 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
variant="outline"
size="sm"
onClick={toggleGroupOperator}
className='py-4 px-1.5'
className="px-1.5 py-4"
>
Groups joined by:{' '}
<Badge variant="secondary" className="ml-2">
{structure.groupOperator.toUpperCase()}
<ChevronDown className="inline-block h-3 w-3 -mr-1" />
<ChevronDown className="-mr-1 inline-block h-3 w-3" />
</Badge>
</Button>
)}
@ -97,32 +97,39 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
<div className="space-y-4">
{structure.groups.map((group, groupIndex) => (
<div key={group.id}>
<Card className="p-4 gap-2">
<Card className="gap-2 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{group.conditions.length > 1 && (<>
<Button
type="button"
variant="outline"
className='py-4 px-1.5'
size="sm"
onClick={() => {
updateGroup(group.id, {
...group,
operator:
group.operator === 'and'
? 'or'
: 'and',
});
}}
>
<span className='text-sm'>Conditions joined by:{' '}</span>
<Badge variant="secondary" className="ml-2">
{group.operator.toUpperCase()}
<ChevronDown className="inline-block h-3 w-3 -mr-1" />
</Badge>
</Button>
</>
{group.conditions.length > 1 && (
<>
<Button
type="button"
variant="outline"
className="px-1.5 py-4"
size="sm"
onClick={() => {
updateGroup(group.id, {
...group,
operator:
group.operator ===
'and'
? 'or'
: 'and',
});
}}
>
<span className="text-sm">
Conditions joined by:{' '}
</span>
<Badge
variant="secondary"
className="ml-2"
>
{group.operator.toUpperCase()}
<ChevronDown className="-mr-1 inline-block h-3 w-3" />
</Badge>
</Button>
</>
)}
</div>
{structure.groups.length > 1 && (
@ -143,14 +150,16 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
<div key={condition.id}>
<ConditionRow
condition={condition}
onChange={(updatedCondition) => {
onChange={(
updatedCondition,
) => {
updateGroup(group.id, {
...group,
conditions:
group.conditions.map(
(c) =>
c.id ===
condition.id
condition.id
? updatedCondition
: c,
),
@ -186,7 +195,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
type="button"
variant="outline"
size="sm"
className="w-full mt-0"
className="mt-0 w-full"
onClick={() => {
updateGroup(group.id, {
...group,
@ -267,8 +276,8 @@ function ConditionRow({
fieldConfig?.type === 'number'
? 'number'
: fieldConfig?.type === 'date'
? 'date'
: 'text';
? 'date'
: 'text';
return (
<div className="flex items-center gap-2">
@ -328,4 +337,3 @@ function ConditionRow({
</div>
);
}

View File

@ -74,5 +74,3 @@ export function SyncStatusButton() {
</TooltipProvider>
);
}

View File

@ -104,5 +104,3 @@ export function CategorySelect({
</Select>
);
}

View File

@ -315,18 +315,22 @@ export function EditTransactionDialog({
/>
) : (
<div className="text-sm">
{transaction && (() => {
const date = parseISO(
transaction.transaction_date,
);
const currentYear = getYear(new Date());
const transactionYear = getYear(date);
const formatString =
transactionYear === currentYear
? 'MMMM d'
: 'MMMM d, yyyy';
return format(date, formatString);
})()}
{transaction &&
(() => {
const date = parseISO(
transaction.transaction_date,
);
const currentYear = getYear(
new Date(),
);
const transactionYear =
getYear(date);
const formatString =
transactionYear === currentYear
? 'MMMM d'
: 'MMMM d, yyyy';
return format(date, formatString);
})()}
</div>
)}
</div>
@ -399,7 +403,9 @@ export function EditTransactionDialog({
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: transaction.currency_code,
}).format(parseFloat(transaction.amount))}
}).format(
parseFloat(transaction.amount),
)}
</div>
)}
</div>

View File

@ -53,7 +53,9 @@ export function ImportTransactionsButton({
</Button>
</TooltipTrigger>
<TooltipContent>
{!isKeySet ? `Unlock encryption to import transactions` : `Import transactions from CSV/Excel`}
{!isKeySet
? `Unlock encryption to import transactions`
: `Import transactions from CSV/Excel`}
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@ -29,7 +29,7 @@ import {
type ColumnMapping,
type ImportState,
} from '@/types/import';
import { Check, Loader2 } from 'lucide-react';
import { Check } from 'lucide-react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { ImportStepAccount } from './import-step-account';

View File

@ -13,9 +13,11 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useReEvaluateAllTransactions } from '@/hooks/use-re-evaluate-all-transactions';
import { type Account, type Bank } from '@/types/account';
import { type Category } from '@/types/category';
import { ChevronDown, Plus, Upload } from 'lucide-react';
import { type DecryptedTransaction } from '@/types/transaction';
import { ChevronDown, Plus, Upload, Zap } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { ImportTransactionsDrawer } from './import-transactions-drawer';
@ -25,6 +27,8 @@ interface TransactionActionsMenuProps {
accounts: Account[];
banks: Bank[];
onAddTransaction: () => void;
transactions: DecryptedTransaction[];
onReEvaluateComplete?: () => void;
}
export function TransactionActionsMenu({
@ -32,9 +36,13 @@ export function TransactionActionsMenu({
accounts,
banks,
onAddTransaction,
transactions,
onReEvaluateComplete,
}: TransactionActionsMenuProps) {
const { isKeySet } = useEncryptionKey();
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
const [isReEvaluating, setIsReEvaluating] = useState(false);
const { reEvaluateAll } = useReEvaluateAllTransactions();
const handleAddTransaction = () => {
if (!isKeySet) {
@ -56,6 +64,28 @@ export function TransactionActionsMenu({
setImportDrawerOpen(true);
};
const handleReEvaluateAll = async () => {
if (!isKeySet) {
toast.error(
'Please unlock your encryption key to re-evaluate transactions',
);
return;
}
if (!transactions.length) {
toast.error('No transactions to re-evaluate');
return;
}
setIsReEvaluating(true);
try {
await reEvaluateAll(transactions, categories, accounts, banks);
onReEvaluateComplete?.();
} finally {
setIsReEvaluating(false);
}
};
return (
<>
<ButtonGroup>
@ -64,8 +94,12 @@ export function TransactionActionsMenu({
<TooltipTrigger asChild>
<Button
variant="outline"
size={"sm"}
className={!isKeySet ? 'cursor-not-allowed opacity-50' : ''}
size={'sm'}
className={
!isKeySet
? 'cursor-not-allowed opacity-50'
: ''
}
onClick={handleAddTransaction}
aria-label="Add transaction"
>
@ -106,6 +140,17 @@ export function TransactionActionsMenu({
<Upload className="mr-2 h-4 w-4" />
Import Transactions
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleReEvaluateAll}
disabled={
!isKeySet ||
isReEvaluating ||
!transactions.length
}
>
<Zap className="mr-2 h-4 w-4" />
Re-evaluate All Expenses
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
@ -120,5 +165,3 @@ export function TransactionActionsMenu({
</>
);
}

View File

@ -23,3 +23,4 @@ export function useOnlineStatus() {
}

View File

@ -0,0 +1,124 @@
import { decrypt, encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { evaluateRules } from '@/lib/rule-engine';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { transactionSyncService } from '@/services/transaction-sync';
import type { Category } from '@/types/category';
import type { Account, Bank } from '@/types/account';
import type { DecryptedTransaction } from '@/types/transaction';
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[],
options?: ReEvaluateAllOptions,
) => {
if (!transactions.length) {
toast.error('No transactions to re-evaluate');
return;
}
const keyString = getStoredKey();
if (!keyString) {
toast.error('Please unlock your encryption key');
return;
}
const key = await importKey(keyString);
const rules = await automationRuleSyncService.getAll();
if (!rules.length) {
toast.error('No automation rules found');
return;
}
const toastId = toast.loading(
`Re-evaluating 0 of ${transactions.length} transactions...`,
);
let successCount = 0;
try {
for (let i = 0; i < transactions.length; i++) {
const transaction = transactions[i];
const progress = i + 1;
options?.onProgress?.({
current: progress,
total: transactions.length,
transactionId: transaction.id,
description: transaction.decryptedDescription,
});
const result = evaluateRules(
transaction,
rules,
categories,
accounts,
banks,
);
if (result) {
let finalNotes = transaction.notes;
let finalNotesIv = transaction.notes_iv;
if (result.note && result.noteIv) {
if (transaction.decryptedNotes) {
const combinedNote = `${transaction.decryptedNotes}\n${await decrypt(result.note, key, result.noteIv)}`;
const encrypted = await encrypt(combinedNote, key);
finalNotes = encrypted.encrypted;
finalNotesIv = encrypted.iv;
} else {
finalNotes = result.note;
finalNotesIv = result.noteIv;
}
}
await transactionSyncService.update(transaction.id, {
category_id: result.categoryId,
notes: finalNotes,
notes_iv: finalNotesIv,
});
successCount++;
}
toast.loading(
`Re-evaluating ${progress} of ${transactions.length} transactions...`,
{ id: toastId },
);
}
toast.success(
`Re-evaluation complete! ${successCount} transaction(s) updated.`,
{ id: toastId },
);
} 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

@ -52,7 +52,6 @@ import {
type DecryptedTransaction,
type TransactionFilters as Filters,
} from '@/types/transaction';
import { ChevronDown } from 'lucide-react';
const breadcrumbs: BreadcrumbItem[] = [
{
@ -966,6 +965,13 @@ export default function Transactions({ categories, accounts, banks }: Props) {
onAddTransaction={() =>
setCreateDialogOpen(true)
}
transactions={transactions}
onReEvaluateComplete={() => {
setRowSelection({});
setTimeout(() => {
window.location.reload();
}, 500);
}}
/>
<DataTableViewOptions table={table} />
</>

View File

@ -47,3 +47,4 @@ class AccountSyncService {
export const accountSyncService = new AccountSyncService();

View File

@ -43,3 +43,4 @@ class BankSyncService {
export const bankSyncService = new BankSyncService();

View File

@ -47,3 +47,4 @@ class CategorySyncService {
export const categorySyncService = new CategorySyncService();

View File

@ -62,3 +62,4 @@ export function formatRuleActions(rule: AutomationRule): string {
}

View File

@ -78,5 +78,3 @@ it('requires authentication', function () {
$response->assertUnauthorized();
});

View File

@ -52,5 +52,3 @@ it('requires authentication', function () {
$response->assertUnauthorized();
});