feat(automation-rules): simplify smart rules UI, fix re-evaluation, and localize amounts (#161)

## Why

**Problem:** Bulk re-evaluation of transactions against smart rules was
broken because the code required an encryption key that no longer exists
in the app. Additionally, the rule builder UI had unnecessary complexity
(priority field, labels/tags, date/category/notes conditions) that added
cognitive load without value. Currency amounts were also always
formatted with US conventions regardless of the user's locale.

## What

**Automation rules – re-evaluation fix:**
- Remove encryption key requirement from `evaluateRules`,
`evaluateRulesForTransactions`, `evaluateRulesForNewTransaction`, and
`prepareTransactionData` (accept `CryptoKey | null`)
- Drop `isKeySet` guards from single-transaction and bulk re-evaluate
handlers in `transaction-list.tsx` and `transaction-actions-menu.tsx`
- Simplify `use-re-evaluate-all-transactions.tsx` to pass `null` as key
directly

**Automation rules – UI cleanup:**
- Hide priority field from create/edit dialogs (still sends `priority:
0` / `rule.priority` to satisfy backend validation)
- Remove labels/tags from create/edit dialogs, actions column, and table
display
- Remove `date`, `category`, and `notes` from rule condition field
options in `rule-builder-utils.ts`
- Add `opacity-30` to disabled X button when only one condition/group
remains

**Amount localization:**
- Replace hardcoded `'en-US'` with `useLocale()` in `AmountDisplay` so
symbol position and number format follow the user's locale (e.g.
`89.705,00 €` in Spanish)

## Verification

- 38/38 `AutomationRuleTest` + `AutomationRuleEvaluationTest` tests pass
- Re-evaluation works without an encryption key set
- Spanish locale users see `89.705,00 €` instead of `€89,705.00`
This commit is contained in:
Víctor Falcón 2026-02-28 13:24:57 +00:00 committed by GitHub
parent 0c5ba916bf
commit b1f01e4a8f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 46 additions and 398 deletions

View File

@ -19,7 +19,6 @@ import { useMemo, useState } from 'react';
import { CreateAutomationRuleDialog } from '@/components/automation-rules/create-automation-rule-dialog';
import { DeleteAutomationRuleDialog } from '@/components/automation-rules/delete-automation-rule-dialog';
import { EditAutomationRuleDialog } from '@/components/automation-rules/edit-automation-rule-dialog';
import { LabelBadges } from '@/components/shared/label-combobox';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -52,10 +51,8 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { type AutomationRule, getRuleActions } from '@/types/automation-rule';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { type Label } from '@/types/label';
import { __ } from '@/utils/i18n';
interface AutomationRulesDialogProps {
@ -66,11 +63,9 @@ interface AutomationRulesDialogProps {
function AutomationRuleActions({
rule,
categories,
labels,
}: {
rule: AutomationRule;
categories: Category[];
labels: Label[];
}) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@ -105,7 +100,6 @@ function AutomationRuleActions({
<EditAutomationRuleDialog
rule={rule}
categories={categories}
labels={labels}
open={editOpen}
onOpenChange={setEditOpen}
/>
@ -121,11 +115,9 @@ function AutomationRuleActions({
function AutomationRuleRow({
row,
categories,
labels,
}: {
row: Row<AutomationRule>;
categories: Category[];
labels: Label[];
}) {
const rule = row.original;
const [editOpen, setEditOpen] = useState(false);
@ -171,7 +163,6 @@ function AutomationRuleRow({
<EditAutomationRuleDialog
rule={rule}
categories={categories}
labels={labels}
open={editOpen}
onOpenChange={setEditOpen}
/>
@ -188,14 +179,12 @@ export function AutomationRulesDialog({
open,
onOpenChange,
}: AutomationRulesDialogProps) {
const { isKeySet } = useEncryptionKey();
const { automationRules: rawRules } = usePage<{
automationRules: AutomationRule[];
}>().props;
// Get categories and labels from globally shared Inertia data
// Get categories from globally shared Inertia data
const categories = usePage().props.categories as Category[];
const labels = usePage().props.labels as Label[];
const rules = useMemo(
() =>
rawRules.map((rule) => ({
@ -207,29 +196,13 @@ export function AutomationRulesDialog({
})),
[rawRules],
);
const [sorting, setSorting] = useState<SortingState>([
{
id: 'priority',
desc: false,
},
]);
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
);
const columns: ColumnDef<AutomationRule>[] = [
{
accessorKey: 'priority',
header: __('Priority'),
cell: ({ row }) => {
return (
<div className="font-medium">
{row.getValue('priority')}
</div>
);
},
},
{
accessorKey: 'title',
header: __('Title'),
@ -265,14 +238,6 @@ export function AutomationRulesDialog({
{actions.category.name}
</Badge>
)}
{actions.hasLabels && actions.labels && (
<LabelBadges labels={actions.labels} max={2} />
)}
{actions.hasNote && (
<span className="text-sm text-muted-foreground">
+ note
</span>
)}
</div>
);
}
@ -294,13 +259,9 @@ export function AutomationRulesDialog({
);
}
if (actions.type === 'labels' && actions.labels) {
return <LabelBadges labels={actions.labels} max={3} />;
}
return (
<span className="text-sm text-muted-foreground">
{__('Add note')}
{__('No action set')}
</span>
);
},
@ -312,7 +273,6 @@ export function AutomationRulesDialog({
<AutomationRuleActions
rule={row.original}
categories={categories}
labels={labels}
/>
),
},
@ -362,11 +322,7 @@ export function AutomationRulesDialog({
}
className="max-w-sm"
/>
<CreateAutomationRuleDialog
categories={categories}
labels={labels}
disabled={!isKeySet}
/>
<CreateAutomationRuleDialog categories={categories} />
</div>
<div className="max-h-[75vh] overflow-y-auto rounded-md border">
@ -400,7 +356,6 @@ export function AutomationRulesDialog({
key={row.id}
row={row}
categories={categories}
labels={labels}
/>
))
) : (

View File

@ -1,7 +1,6 @@
import { store } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import { RuleBuilder } from '@/components/automation-rules/rule-builder';
import { CategoryCombobox } from '@/components/shared/category-combobox';
import { LabelCombobox } from '@/components/shared/label-combobox';
import { Button } from '@/components/ui/button';
import { CreateButton } from '@/components/ui/create-button';
import {
@ -21,33 +20,28 @@ import {
type RuleStructure,
} from '@/lib/rule-builder-utils';
import type { Category } from '@/types/category';
import type { Label } from '@/types/label';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { useState } from 'react';
interface CreateAutomationRuleDialogProps {
categories: Category[];
labels: Label[];
disabled?: boolean;
onSuccess?: () => void;
}
export function CreateAutomationRuleDialog({
categories,
labels,
disabled = false,
onSuccess,
}: CreateAutomationRuleDialogProps) {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState('');
const [priority, setPriority] = useState('10');
const [ruleStructure, setRuleStructure] = useState<RuleStructure>({
groups: [createEmptyGroup()],
groupOperator: 'or',
});
const [categoryId, setCategoryId] = useState<string>('');
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
@ -68,10 +62,10 @@ export function CreateAutomationRuleDialog({
return;
}
if (!categoryId && selectedLabelIds.length === 0) {
if (!categoryId) {
setErrors((prev) => ({
...prev,
action_category_id: 'At least one action is required',
action_category_id: 'A category is required',
}));
return;
}
@ -85,13 +79,12 @@ export function CreateAutomationRuleDialog({
store().url,
{
title: title.trim(),
priority: parseInt(priority, 10),
priority: 0,
rules_json: JSON.stringify(jsonLogic),
action_category_id: categoryId || null,
action_category_id: categoryId,
action_note: null,
action_note_iv: null,
action_label_ids:
selectedLabelIds.length > 0 ? selectedLabelIds : null,
action_label_ids: null,
},
{
preserveState: true,
@ -99,13 +92,11 @@ export function CreateAutomationRuleDialog({
onSuccess: () => {
setOpen(false);
setTitle('');
setPriority('10');
setRuleStructure({
groups: [createEmptyGroup()],
groupOperator: 'and',
});
setCategoryId('');
setSelectedLabelIds([]);
setErrors({});
onSuccess?.();
},
@ -135,7 +126,7 @@ export function CreateAutomationRuleDialog({
<DialogTitle>{__('Create Automation Rule')}</DialogTitle>
<DialogDescription>
{__(
'Create a rule to automatically categorize transactions\n and add labels.',
'Create a rule to automatically categorize transactions.',
)}
</DialogDescription>
</DialogHeader>
@ -157,30 +148,6 @@ export function CreateAutomationRuleDialog({
)}
</div>
<div className="space-y-2">
<FormLabel htmlFor="priority">
{__('Priority')}
</FormLabel>
<Input
id="priority"
type="number"
min="0"
value={priority}
onChange={(e) => setPriority(e.target.value)}
placeholder="10"
required
/>
<p className="text-xs text-muted-foreground">
{__('Lower numbers execute first')}
</p>
{errors.priority && (
<p className="text-sm text-red-500">
{errors.priority}
</p>
)}
</div>
<RuleBuilder
value={ruleStructure}
onChange={setRuleStructure}
@ -189,9 +156,6 @@ export function CreateAutomationRuleDialog({
<div className="space-y-4 rounded-md border p-4">
<h4 className="font-medium">{__('Actions')}</h4>
<p className="text-sm text-muted-foreground">
{__('At least one action is required')}
</p>
<div className="space-y-2">
<FormLabel htmlFor="category">
@ -202,40 +166,18 @@ export function CreateAutomationRuleDialog({
value={categoryId}
onValueChange={setCategoryId}
categories={categories}
placeholder={__(
'Select a category (optional)',
)}
placeholder={__('Select a category')}
showUncategorized={false}
data-testid="action-category-select"
/>
</div>
</div>
<div className="space-y-2">
<FormLabel>{__('Add Labels')}</FormLabel>
<div className="mt-1">
<LabelCombobox
value={selectedLabelIds}
onValueChange={setSelectedLabelIds}
labels={labels}
placeholder={__('Select labels (optional)')}
allowCreate={true}
/>
</div>
</div>
{errors.action_category_id && (
<p className="text-sm text-red-500">
{errors.action_category_id}
</p>
)}
{(errors['action_label_ids.0'] ||
errors.action_label_ids) && (
<p className="text-sm text-red-500">
{errors['action_label_ids.0'] ||
errors.action_label_ids}
</p>
)}
</div>
<div className="flex justify-end gap-2">

View File

@ -1,7 +1,6 @@
import { update } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import { RuleBuilder } from '@/components/automation-rules/rule-builder';
import { CategoryCombobox } from '@/components/shared/category-combobox';
import { LabelCombobox } from '@/components/shared/label-combobox';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -21,7 +20,6 @@ import {
} from '@/lib/rule-builder-utils';
import type { AutomationRule } from '@/types/automation-rule';
import type { Category } from '@/types/category';
import type { Label as LabelType } from '@/types/label';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { useEffect, useState } from 'react';
@ -29,7 +27,6 @@ import { useEffect, useState } from 'react';
interface EditAutomationRuleDialogProps {
rule: AutomationRule;
categories: Category[];
labels: LabelType[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
@ -38,31 +35,26 @@ interface EditAutomationRuleDialogProps {
export function EditAutomationRuleDialog({
rule,
categories,
labels,
open,
onOpenChange,
onSuccess,
}: EditAutomationRuleDialogProps) {
const [title, setTitle] = useState('');
const [priority, setPriority] = useState('0');
const [ruleStructure, setRuleStructure] = useState<RuleStructure>({
groups: [createEmptyGroup()],
groupOperator: 'and',
});
const [categoryId, setCategoryId] = useState<string>('');
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (rule && open) {
setTitle(rule.title);
setPriority(String(rule.priority));
setRuleStructure(parseJsonLogic(rule.rules_json));
setCategoryId(
rule.action_category_id ? String(rule.action_category_id) : '',
);
setSelectedLabelIds(rule.labels?.map((l) => l.id) || []);
}
}, [rule, open]);
@ -83,10 +75,10 @@ export function EditAutomationRuleDialog({
return;
}
if (!categoryId && selectedLabelIds.length === 0) {
if (!categoryId) {
setErrors((prev) => ({
...prev,
action_category_id: 'At least one action is required',
action_category_id: 'A category is required',
}));
return;
}
@ -100,13 +92,12 @@ export function EditAutomationRuleDialog({
update(rule.id).url,
{
title: title.trim(),
priority: parseInt(priority, 10),
priority: rule.priority,
rules_json: JSON.stringify(jsonLogic),
action_category_id: categoryId || null,
action_category_id: categoryId,
action_note: null,
action_note_iv: null,
action_label_ids:
selectedLabelIds.length > 0 ? selectedLabelIds : null,
action_label_ids: null,
},
{
preserveState: true,
@ -137,7 +128,7 @@ export function EditAutomationRuleDialog({
<DialogTitle>{__('Edit Automation Rule')}</DialogTitle>
<DialogDescription>
{__(
'Update the rule to automatically categorize transactions\n and add labels.',
'Update the rule to automatically categorize transactions.',
)}
</DialogDescription>
</DialogHeader>
@ -159,28 +150,6 @@ export function EditAutomationRuleDialog({
)}
</div>
<div className="space-y-2">
<Label htmlFor="priority">{__('Priority')}</Label>
<Input
id="priority"
type="number"
min="0"
value={priority}
onChange={(e) => setPriority(e.target.value)}
placeholder="0"
required
/>
<p className="text-xs text-muted-foreground">
{__('Lower numbers execute first')}
</p>
{errors.priority && (
<p className="text-sm text-red-500">
{errors.priority}
</p>
)}
</div>
<RuleBuilder
value={ruleStructure}
onChange={setRuleStructure}
@ -189,9 +158,6 @@ export function EditAutomationRuleDialog({
<div className="space-y-4 rounded-md border p-4">
<h4 className="font-medium">{__('Actions')}</h4>
<p className="text-sm text-muted-foreground">
{__('At least one action is required')}
</p>
<div className="space-y-2">
<Label htmlFor="category">
@ -201,35 +167,17 @@ export function EditAutomationRuleDialog({
value={categoryId}
onValueChange={setCategoryId}
categories={categories}
placeholder={__('Select a category (optional)')}
placeholder={__('Select a category')}
showUncategorized={false}
data-testid="action-category-select"
/>
</div>
<div className="space-y-2">
<Label>{__('Add Labels')}</Label>
<LabelCombobox
value={selectedLabelIds}
onValueChange={setSelectedLabelIds}
labels={labels}
placeholder={__('Select labels (optional)')}
allowCreate={true}
/>
</div>
{errors.action_category_id && (
<p className="text-sm text-red-500">
{errors.action_category_id}
</p>
)}
{(errors['action_label_ids.0'] ||
errors.action_label_ids) && (
<p className="text-sm text-red-500">
{errors['action_label_ids.0'] ||
errors.action_label_ids}
</p>
)}
</div>
<div className="flex justify-end gap-2">

View File

@ -272,12 +272,7 @@ function ConditionRow({
condition.operator !== 'is_empty' &&
condition.operator !== 'is_not_empty';
const inputType =
fieldConfig?.type === 'number'
? 'number'
: fieldConfig?.type === 'date'
? 'date'
: 'text';
const inputType = fieldConfig?.type === 'number' ? 'number' : 'text';
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
@ -331,7 +326,7 @@ function ConditionRow({
size="sm"
onClick={onRemove}
disabled={!canRemove}
className="self-end sm:self-auto"
className={`self-end sm:self-auto${!canRemove ? 'opacity-30' : ''}`}
>
<X className="h-4 w-4" />
</Button>

View File

@ -15,6 +15,7 @@ import {
} 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 AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
@ -73,13 +74,6 @@ export function TransactionActionsMenu({
};
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;
@ -199,11 +193,7 @@ export function TransactionActionsMenu({
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleReEvaluateAll}
disabled={
!isKeySet ||
isReEvaluating ||
!transactions.length
}
disabled={isReEvaluating || !transactions.length}
>
<WandSparkles className="mr-2 h-4 w-4" />
{__('Re-evaluate All Expenses')}

View File

@ -59,7 +59,6 @@ import { consoleDebug } from '@/lib/debug';
import { db } from '@/lib/dexie-db';
import { getStoredKey } from '@/lib/key-storage';
import { evaluateRules } from '@/lib/rule-engine';
import { appendNoteIfNotPresent } from '@/lib/utils';
import { transactionSyncService } from '@/services/transaction-sync';
import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
@ -846,18 +845,6 @@ export function TransactionList({
setIsReEvaluating(true);
try {
const keyString = getStoredKey();
if (!keyString || !isKeySet) {
consoleDebug('❌ Encryption key not set');
console.error('Encryption key not set');
toast.error(
'Please unlock your encryption key to re-evaluate rules',
);
return;
}
consoleDebug('✓ Encryption key found');
const key = await importKey(keyString);
consoleDebug(
`Found ${automationRules.length} automation rules`,
);
@ -874,7 +861,7 @@ export function TransactionList({
categories,
accounts,
banks,
key,
null,
);
consoleDebug('Rule evaluation result:', result);
@ -884,27 +871,6 @@ export function TransactionList({
let finalNotes = transaction.notes;
let finalNotesIv = transaction.notes_iv;
if (result.note && result.noteIv) {
consoleDebug('Adding note from rule');
const decryptedRuleNote = await decrypt(
result.note,
key,
result.noteIv,
);
const combinedNote = appendNoteIfNotPresent(
transaction.decryptedNotes,
decryptedRuleNote,
);
if (combinedNote !== transaction.decryptedNotes) {
finalNotes = combinedNote;
finalNotesIv = null;
consoleDebug('Combined notes with rule note');
} else {
consoleDebug('Rule note already present, skipping');
}
}
const updateData = {
category_id: result.categoryId,
notes: finalNotes,
@ -923,16 +889,7 @@ export function TransactionList({
null
: null;
let decryptedNotes = transaction.decryptedNotes;
if (finalNotes && !finalNotesIv) {
decryptedNotes = finalNotes;
} else if (finalNotes && finalNotesIv) {
decryptedNotes = await decrypt(
finalNotes,
key,
finalNotesIv,
);
}
const decryptedNotes = transaction.decryptedNotes;
const updatedTransaction = {
...transaction,
@ -961,14 +918,7 @@ export function TransactionList({
consoleDebug('=== Re-evaluation complete ===');
}
},
[
isKeySet,
categories,
accounts,
banks,
updateTransaction,
automationRules,
],
[categories, accounts, banks, updateTransaction, automationRules],
);
async function handleBulkReEvaluateRules() {
@ -983,18 +933,6 @@ export function TransactionList({
setIsReEvaluating(true);
try {
const keyString = getStoredKey();
if (!keyString || !isKeySet) {
consoleDebug('❌ Encryption key not set');
console.error('Encryption key not set');
toast.error(
'Please unlock your encryption key to re-evaluate rules',
);
return;
}
consoleDebug('✓ Encryption key found');
const key = await importKey(keyString);
consoleDebug(`Found ${automationRules.length} automation rules`);
if (automationRules.length === 0) {
@ -1031,36 +969,15 @@ export function TransactionList({
categories,
accounts,
banks,
key,
null,
);
consoleDebug('Rule evaluation result:', result);
if (result) {
consoleDebug('✓ Rule matched! Applying changes...');
let finalNotes = transaction.notes;
let finalNotesIv = transaction.notes_iv;
if (result.note && result.noteIv) {
consoleDebug('Adding note from rule');
const decryptedRuleNote = await decrypt(
result.note,
key,
result.noteIv,
);
const combinedNote = appendNoteIfNotPresent(
transaction.decryptedNotes,
decryptedRuleNote,
);
if (combinedNote !== transaction.decryptedNotes) {
finalNotes = combinedNote;
finalNotesIv = null;
consoleDebug('Combined notes with rule note');
} else {
consoleDebug('Rule note already present, skipping');
}
}
const finalNotes = transaction.notes;
const finalNotesIv = transaction.notes_iv;
const updateData = {
category_id: result.categoryId,
@ -1080,16 +997,7 @@ export function TransactionList({
null
: null;
let decryptedNotes = transaction.decryptedNotes;
if (finalNotes && !finalNotesIv) {
decryptedNotes = finalNotes;
} else if (finalNotes && finalNotesIv) {
decryptedNotes = await decrypt(
finalNotes,
key,
finalNotesIv,
);
}
const decryptedNotes = transaction.decryptedNotes;
updates.push({
transaction,

View File

@ -1,4 +1,5 @@
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
import { useEffect, useMemo, useState } from 'react';
@ -55,6 +56,7 @@ export function AmountDisplay({
highlightPositive = false,
}: AmountDisplayProps) {
const { isKeySet } = useEncryptionKey();
const locale = useLocale();
const [amount, setAmount] = useState<number>(amountInCents / 100);
const isPositive = amountInCents > 0
@ -73,13 +75,13 @@ export function AmountDisplay({
}, [amountInCents, shouldHideAmount]);
const formatted = useMemo(() => {
return new Intl.NumberFormat('en-US', {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
minimumFractionDigits,
maximumFractionDigits,
}).format(amount);
}, [amount, currencyCode, minimumFractionDigits, maximumFractionDigits]);
}, [amount, currencyCode, minimumFractionDigits, maximumFractionDigits, locale]);
const getBackgroundClass = (shouldHideAmount: boolean) => {
if (!highlightPositive && !shouldHideAmount) return '';

View File

@ -1,7 +1,4 @@
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { evaluateRules } from '@/lib/rule-engine';
import { appendNoteIfNotPresent } from '@/lib/utils';
import { transactionSyncService } from '@/services/transaction-sync';
import type { Account, Bank } from '@/types/account';
import type { AutomationRule } from '@/types/automation-rule';
@ -34,14 +31,6 @@ export function useReEvaluateAllTransactions() {
return;
}
const keyString = getStoredKey();
if (!keyString) {
toast.error('Please unlock your encryption key');
return;
}
const key = await importKey(keyString);
if (!automationRules.length) {
toast.error('No automation rules found');
return;
@ -71,34 +60,14 @@ export function useReEvaluateAllTransactions() {
categories,
accounts,
banks,
key,
null,
);
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;
}
}
await transactionSyncService.update(transaction.id, {
category_id: result.categoryId,
notes: finalNotes,
notes_iv: finalNotesIv,
notes: transaction.notes,
notes_iv: transaction.notes_iv,
});
successCount++;

View File

@ -1,4 +1,4 @@
export type FieldType = 'string' | 'number' | 'date' | 'nullable';
export type FieldType = 'string' | 'number';
export type Operator =
| 'contains'
@ -35,21 +35,11 @@ export const FIELD_CONFIG: Record<
type: 'string',
operators: ['contains', 'equals'],
},
notes: {
label: 'Notes',
type: 'nullable',
operators: ['contains', 'equals', 'is_empty', 'is_not_empty'],
},
amount: {
label: 'Amount',
type: 'number',
operators: ['equals', 'greater_than', 'less_than'],
},
transaction_date: {
label: 'Transaction Date',
type: 'date',
operators: ['equals'],
},
bank_name: {
label: 'Bank Name',
type: 'string',
@ -62,7 +52,7 @@ export const FIELD_CONFIG: Record<
},
category: {
label: 'Category',
type: 'nullable',
type: 'string',
operators: ['equals', 'is_empty', 'is_not_empty'],
},
};

View File

@ -102,7 +102,7 @@ export async function prepareTransactionData(
accounts: Account[],
banks: Bank[],
categories: Category[],
encryptionKey: CryptoKey,
encryptionKey: CryptoKey | null,
): Promise<TransactionData> {
const account = accounts.find((a) => a.id === transaction.account_id);
const bank = account?.bank?.id
@ -136,7 +136,7 @@ export async function evaluateRules(
categories: Category[],
accounts: Account[],
banks: Bank[],
encryptionKey: CryptoKey,
encryptionKey: CryptoKey | null,
): Promise<RuleEvaluationResult | null> {
const sortedRules = [...rules].sort((a, b) => a.priority - b.priority);
@ -201,7 +201,7 @@ export async function evaluateRulesForTransactions(
categories: Category[],
accounts: Account[],
banks: Bank[],
encryptionKey: CryptoKey,
encryptionKey: CryptoKey | null,
): Promise<Map<string, RuleEvaluationResult>> {
const results = new Map<string, RuleEvaluationResult>();
@ -237,7 +237,7 @@ export async function evaluateRulesForNewTransaction(
categories: Category[],
accounts: Account[],
banks: Bank[],
encryptionKey: CryptoKey,
encryptionKey: CryptoKey | null,
): Promise<RuleEvaluationResult | null> {
if (!rules || !categories || !accounts || !banks) {
consoleDebug(

View File

@ -21,7 +21,6 @@ import { CreateAutomationRuleDialog } from '@/components/automation-rules/create
import { DeleteAutomationRuleDialog } from '@/components/automation-rules/delete-automation-rule-dialog';
import { EditAutomationRuleDialog } from '@/components/automation-rules/edit-automation-rule-dialog';
import HeadingSmall from '@/components/heading-small';
import { LabelBadges } from '@/components/shared/label-combobox';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -47,13 +46,11 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { type BreadcrumbItem } from '@/types';
import { type AutomationRule, getRuleActions } from '@/types/automation-rule';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { type Label } from '@/types/label';
import { __ } from '@/utils/i18n';
const breadcrumbs: BreadcrumbItem[] = [
@ -66,11 +63,9 @@ const breadcrumbs: BreadcrumbItem[] = [
function AutomationRuleActions({
rule,
categories,
labels,
}: {
rule: AutomationRule;
categories: Category[];
labels: Label[];
}) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@ -105,7 +100,6 @@ function AutomationRuleActions({
<EditAutomationRuleDialog
rule={rule}
categories={categories}
labels={labels}
open={editOpen}
onOpenChange={setEditOpen}
/>
@ -121,11 +115,9 @@ function AutomationRuleActions({
function AutomationRuleRow({
row,
categories,
labels,
}: {
row: Row<AutomationRule>;
categories: Category[];
labels: Label[];
}) {
const rule = row.original;
const [editOpen, setEditOpen] = useState(false);
@ -171,7 +163,6 @@ function AutomationRuleRow({
<EditAutomationRuleDialog
rule={rule}
categories={categories}
labels={labels}
open={editOpen}
onOpenChange={setEditOpen}
/>
@ -185,14 +176,12 @@ function AutomationRuleRow({
}
export default function AutomationRules() {
const { isKeySet } = useEncryptionKey();
const { automationRules: rawRules } = usePage<{
automationRules: AutomationRule[];
}>().props;
// Get categories and labels from globally shared Inertia data
// Get categories from globally shared Inertia data
const categories = usePage().props.categories as Category[];
const labels = usePage().props.labels as Label[];
const rules = useMemo(
() =>
rawRules.map((rule) => ({
@ -204,29 +193,13 @@ export default function AutomationRules() {
})),
[rawRules],
);
const [sorting, setSorting] = useState<SortingState>([
{
id: 'priority',
desc: false,
},
]);
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
);
const columns: ColumnDef<AutomationRule>[] = [
{
accessorKey: 'priority',
header: __('Priority'),
cell: ({ row }) => {
return (
<div className="font-medium">
{row.getValue('priority')}
</div>
);
},
},
{
accessorKey: 'title',
header: __('Title'),
@ -262,14 +235,6 @@ export default function AutomationRules() {
{actions.category.name}
</Badge>
)}
{actions.hasLabels && actions.labels && (
<LabelBadges labels={actions.labels} max={2} />
)}
{actions.hasNote && (
<span className="text-sm text-muted-foreground">
+ note
</span>
)}
</div>
);
}
@ -291,13 +256,9 @@ export default function AutomationRules() {
);
}
if (actions.type === 'labels' && actions.labels) {
return <LabelBadges labels={actions.labels} max={3} />;
}
return (
<span className="text-sm text-muted-foreground">
{__('Add note')}
{__('No action set')}
</span>
);
},
@ -309,7 +270,6 @@ export default function AutomationRules() {
<AutomationRuleActions
rule={row.original}
categories={categories}
labels={labels}
/>
),
},
@ -362,8 +322,6 @@ export default function AutomationRules() {
/>
<CreateAutomationRuleDialog
categories={categories}
labels={labels}
disabled={!isKeySet}
/>
</div>
@ -405,7 +363,6 @@ export default function AutomationRules() {
key={row.id}
row={row}
categories={categories}
labels={labels}
/>
))
) : (

View File

@ -27,7 +27,6 @@ it('can create an automation rule with visual builder', function () {
->click('button:has-text("Create Rule")')
->wait(0.5)
->fill('title', 'Test Rule')
->fill('priority', '10')
->assertSee('Conditions')
->assertSee('Description')
->fill('input[placeholder="Value"]', 'grocery')
@ -42,7 +41,7 @@ it('can create an automation rule with visual builder', function () {
$this->assertDatabaseHas('automation_rules', [
'user_id' => $user->id,
'title' => 'Test Rule',
'priority' => 10,
'priority' => 0,
]);
});
@ -62,7 +61,6 @@ it('can add multiple conditions to a group', function () {
->click('button:has-text("Create Rule")')
->wait(0.5)
->fill('title', 'Multi-Condition Rule')
->fill('priority', '5')
->fill('input[placeholder="Value"]', 'grocery')
->click('Add Condition')
->wait(0.5)
@ -100,7 +98,6 @@ it('can add multiple groups', function () {
->click('button:has-text("Create Rule")')
->wait(0.5)
->fill('title', 'Multi-Group Rule')
->fill('priority', '3')
->fill('input[placeholder="Value"]', 'grocery')
->click('Add Group')
->wait(0.5)
@ -138,7 +135,6 @@ it('can select different field types and operators', function () {
->click('button:has-text("Create Rule")')
->wait(0.5)
->fill('title', 'Amount Rule')
->fill('priority', '1')
->click('button:has-text("Description")')
->wait(0.5)
->click('[role="option"]:has-text("Amount")')
@ -179,7 +175,6 @@ it('can edit an existing rule with visual builder', function () {
->click('button:has-text("Create Rule")')
->wait(0.5)
->fill('title', 'Original Rule')
->fill('priority', '5')
->fill('input[placeholder="Value"]', 'grocery')
->click('[data-testid="action-category-select"]')
->wait(0.5)
@ -222,7 +217,6 @@ it('validates that at least one condition is required', function () {
->click('button:has-text("Create Rule")')
->wait(0.5)
->fill('title', 'Invalid Rule')
->fill('priority', '1')
->click('[data-testid="action-category-select"]')
->wait(0.5)
->click('Entertainment')
@ -252,7 +246,6 @@ it('can toggle group operators between AND and OR', function () {
->click('button:has-text("Create Rule")')
->wait(0.5)
->fill('title', 'OR Rule')
->fill('priority', '1')
->fill('input[placeholder="Value"]', 'test')
->click('Add Condition')
->wait(0.5)
@ -293,7 +286,6 @@ it('can use is empty operator for nullable fields', function () {
->click('button:has-text("Create Rule")')
->wait(0.5)
->fill('title', 'Empty Category Rule')
->fill('priority', '1')
->click('button:has-text("Description")')
->wait(0.5)
->click('[role="option"]:has-text("Category")')