diff --git a/app/Actions/CreateDefaultCategories.php b/app/Actions/CreateDefaultCategories.php
index 22240b9a..274e8ed4 100644
--- a/app/Actions/CreateDefaultCategories.php
+++ b/app/Actions/CreateDefaultCategories.php
@@ -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'],
diff --git a/app/Http/Controllers/Sync/AccountSyncController.php b/app/Http/Controllers/Sync/AccountSyncController.php
index fdcfe20f..07aa6b0c 100644
--- a/app/Http/Controllers/Sync/AccountSyncController.php
+++ b/app/Http/Controllers/Sync/AccountSyncController.php
@@ -26,5 +26,3 @@ class AccountSyncController extends Controller
]);
}
}
-
-
diff --git a/app/Http/Controllers/Sync/BankSyncController.php b/app/Http/Controllers/Sync/BankSyncController.php
index 36c68f9f..2980df14 100644
--- a/app/Http/Controllers/Sync/BankSyncController.php
+++ b/app/Http/Controllers/Sync/BankSyncController.php
@@ -27,5 +27,3 @@ class BankSyncController extends Controller
]);
}
}
-
-
diff --git a/app/Http/Controllers/Sync/CategorySyncController.php b/app/Http/Controllers/Sync/CategorySyncController.php
index ae7b7582..7dd2f824 100644
--- a/app/Http/Controllers/Sync/CategorySyncController.php
+++ b/app/Http/Controllers/Sync/CategorySyncController.php
@@ -24,5 +24,3 @@ class CategorySyncController extends Controller
]);
}
}
-
-
diff --git a/resources/js/components/automation-rules/rule-builder.tsx b/resources/js/components/automation-rules/rule-builder.tsx
index 4d9c7db9..cdd0d205 100644
--- a/resources/js/components/automation-rules/rule-builder.tsx
+++ b/resources/js/components/automation-rules/rule-builder.tsx
@@ -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:{' '}
{structure.groupOperator.toUpperCase()}
-
+
)}
@@ -97,32 +97,39 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
{structure.groups.map((group, groupIndex) => (
-
+
- {group.conditions.length > 1 && (<>
-
- >
+ {group.conditions.length > 1 && (
+ <>
+
+ >
)}
{structure.groups.length > 1 && (
@@ -143,14 +150,16 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
{
+ 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 (
@@ -328,4 +337,3 @@ function ConditionRow({
);
}
-
diff --git a/resources/js/components/sync-status-button.tsx b/resources/js/components/sync-status-button.tsx
index 9d879e9e..2b000973 100644
--- a/resources/js/components/sync-status-button.tsx
+++ b/resources/js/components/sync-status-button.tsx
@@ -74,5 +74,3 @@ export function SyncStatusButton() {
);
}
-
-
diff --git a/resources/js/components/transactions/category-select.tsx b/resources/js/components/transactions/category-select.tsx
index 96879e45..24def989 100644
--- a/resources/js/components/transactions/category-select.tsx
+++ b/resources/js/components/transactions/category-select.tsx
@@ -104,5 +104,3 @@ export function CategorySelect({
);
}
-
-
diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx
index 39c27a36..fc5f856b 100644
--- a/resources/js/components/transactions/edit-transaction-dialog.tsx
+++ b/resources/js/components/transactions/edit-transaction-dialog.tsx
@@ -315,18 +315,22 @@ export function EditTransactionDialog({
/>
) : (
- {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);
+ })()}
)}
@@ -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),
+ )}
)}
diff --git a/resources/js/components/transactions/import-transactions-button.tsx b/resources/js/components/transactions/import-transactions-button.tsx
index 43f3a4b5..4f3196ce 100644
--- a/resources/js/components/transactions/import-transactions-button.tsx
+++ b/resources/js/components/transactions/import-transactions-button.tsx
@@ -53,7 +53,9 @@ export function ImportTransactionsButton({
- {!isKeySet ? `Unlock encryption to import transactions` : `Import transactions from CSV/Excel`}
+ {!isKeySet
+ ? `Unlock encryption to import transactions`
+ : `Import transactions from CSV/Excel`}
diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx
index cb52b810..2476121a 100644
--- a/resources/js/components/transactions/import-transactions-drawer.tsx
+++ b/resources/js/components/transactions/import-transactions-drawer.tsx
@@ -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';
diff --git a/resources/js/components/transactions/transaction-actions-menu.tsx b/resources/js/components/transactions/transaction-actions-menu.tsx
index 56eeb787..05d6eb07 100644
--- a/resources/js/components/transactions/transaction-actions-menu.tsx
+++ b/resources/js/components/transactions/transaction-actions-menu.tsx
@@ -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 (
<>
@@ -64,8 +94,12 @@ export function TransactionActionsMenu({
@@ -120,5 +165,3 @@ export function TransactionActionsMenu({
>
);
}
-
-
diff --git a/resources/js/hooks/use-online-status.ts b/resources/js/hooks/use-online-status.ts
index 9ff93a79..5e049b41 100644
--- a/resources/js/hooks/use-online-status.ts
+++ b/resources/js/hooks/use-online-status.ts
@@ -23,3 +23,4 @@ export function useOnlineStatus() {
}
+
diff --git a/resources/js/hooks/use-re-evaluate-all-transactions.ts b/resources/js/hooks/use-re-evaluate-all-transactions.ts
new file mode 100644
index 00000000..daec790a
--- /dev/null
+++ b/resources/js/hooks/use-re-evaluate-all-transactions.ts
@@ -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 };
+}
+
diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx
index aca9b1ff..5129bc15 100644
--- a/resources/js/pages/transactions/index.tsx
+++ b/resources/js/pages/transactions/index.tsx
@@ -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);
+ }}
/>
>
diff --git a/resources/js/services/account-sync.ts b/resources/js/services/account-sync.ts
index 177a4212..7c5da08e 100644
--- a/resources/js/services/account-sync.ts
+++ b/resources/js/services/account-sync.ts
@@ -47,3 +47,4 @@ class AccountSyncService {
export const accountSyncService = new AccountSyncService();
+
diff --git a/resources/js/services/bank-sync.ts b/resources/js/services/bank-sync.ts
index f875ee54..b3eaefdc 100644
--- a/resources/js/services/bank-sync.ts
+++ b/resources/js/services/bank-sync.ts
@@ -43,3 +43,4 @@ class BankSyncService {
export const bankSyncService = new BankSyncService();
+
diff --git a/resources/js/services/category-sync.ts b/resources/js/services/category-sync.ts
index 4971993a..9a41aaaa 100644
--- a/resources/js/services/category-sync.ts
+++ b/resources/js/services/category-sync.ts
@@ -47,3 +47,4 @@ class CategorySyncService {
export const categorySyncService = new CategorySyncService();
+
diff --git a/resources/js/types/automation-rule.ts b/resources/js/types/automation-rule.ts
index a0f5197f..482c07a2 100644
--- a/resources/js/types/automation-rule.ts
+++ b/resources/js/types/automation-rule.ts
@@ -62,3 +62,4 @@ export function formatRuleActions(rule: AutomationRule): string {
}
+
diff --git a/tests/Feature/Sync/AccountSyncTest.php b/tests/Feature/Sync/AccountSyncTest.php
index ccdc6473..d5be0633 100644
--- a/tests/Feature/Sync/AccountSyncTest.php
+++ b/tests/Feature/Sync/AccountSyncTest.php
@@ -78,5 +78,3 @@ it('requires authentication', function () {
$response->assertUnauthorized();
});
-
-
diff --git a/tests/Feature/Sync/CategorySyncTest.php b/tests/Feature/Sync/CategorySyncTest.php
index f3c8d92f..871e91a8 100644
--- a/tests/Feature/Sync/CategorySyncTest.php
+++ b/tests/Feature/Sync/CategorySyncTest.php
@@ -52,5 +52,3 @@ it('requires authentication', function () {
$response->assertUnauthorized();
});
-
-