Improve rule evaluator to be case insensitive

This commit is contained in:
Víctor Falcón 2025-11-10 12:46:12 +00:00
parent a14e11775b
commit 302dff8909
4 changed files with 139 additions and 9 deletions

View File

@ -202,7 +202,7 @@ export function CreateAutomationRuleDialog({
id="rules_json"
value={rulesJson}
onChange={(e) => setRulesJson(e.target.value)}
placeholder='{"==": [{"var": "description"}, "GROCERY"]}'
placeholder='{"in": ["grocery", {"var": "description"}]}'
rows={4}
className="font-mono text-sm"
required
@ -230,18 +230,24 @@ export function CreateAutomationRuleDialog({
<div>
<strong>Available fields:</strong>
<ul className="ml-4 mt-1 list-disc">
<li>description (string)</li>
<li>description (string, case-insensitive)</li>
<li>notes (string or null, case-insensitive)</li>
<li>amount (number)</li>
<li>transaction_date (string)</li>
<li>bank_name (string)</li>
<li>account_name (string)</li>
<li>category (string or null)</li>
</ul>
<p className="text-muted-foreground mt-2 text-xs">
Note: Use any case for description and notes - matching is automatic!
</p>
</div>
<div>
<strong>Example rules:</strong>
<pre className="bg-muted mt-1 overflow-x-auto rounded p-2 text-xs">
{`{"in": ["GROCERY", {"var": "description"}]}
{`{"in": ["grocery", {"var": "description"}]}
{"in": ["M3 SPORT", {"var": "description"}]}
{"in": ["important", {"var": "notes"}]}
{"and": [
{">": [{"var": "amount"}, 100]},
{"==": [{"var": "bank_name"}, "Chase"]}

View File

@ -229,7 +229,7 @@ export function EditAutomationRuleDialog({
id="rules_json"
value={rulesJson}
onChange={(e) => setRulesJson(e.target.value)}
placeholder='{"==": [{"var": "description"}, "GROCERY"]}'
placeholder='{"in": ["grocery", {"var": "description"}]}'
rows={4}
className="font-mono text-sm"
required
@ -257,18 +257,24 @@ export function EditAutomationRuleDialog({
<div>
<strong>Available fields:</strong>
<ul className="ml-4 mt-1 list-disc">
<li>description (string)</li>
<li>description (string, case-insensitive)</li>
<li>notes (string or null, case-insensitive)</li>
<li>amount (number)</li>
<li>transaction_date (string)</li>
<li>bank_name (string)</li>
<li>account_name (string)</li>
<li>category (string or null)</li>
</ul>
<p className="text-muted-foreground mt-2 text-xs">
Note: Use any case for description and notes - matching is automatic!
</p>
</div>
<div>
<strong>Example rules:</strong>
<pre className="bg-muted mt-1 overflow-x-auto rounded p-2 text-xs">
{`{"in": ["GROCERY", {"var": "description"}]}
{`{"in": ["grocery", {"var": "description"}]}
{"in": ["M3 SPORT", {"var": "description"}]}
{"in": ["important", {"var": "notes"}]}
{"and": [
{">": [{"var": "amount"}, 100]},
{"==": [{"var": "bank_name"}, "Chase"]}

View File

@ -19,6 +19,40 @@ export interface TransactionData {
bank_name: string;
account_name: string;
category: string | null;
notes: string | null;
}
function normalizeRuleJson(rulesJson: unknown): unknown {
if (typeof rulesJson === 'string') {
return rulesJson.toLowerCase();
}
if (Array.isArray(rulesJson)) {
return rulesJson.map((item, index) => {
if (index === 0 && typeof item === 'string') {
return item.toLowerCase();
}
if (
typeof item === 'object' &&
item !== null &&
'var' in item &&
(item.var === 'description' || item.var === 'notes')
) {
return item;
}
return normalizeRuleJson(item);
});
}
if (typeof rulesJson === 'object' && rulesJson !== null) {
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rulesJson)) {
normalized[key] = normalizeRuleJson(value);
}
return normalized;
}
return rulesJson;
}
export function prepareTransactionData(
@ -36,12 +70,13 @@ export function prepareTransactionData(
: null;
return {
description: transaction.decryptedDescription || '',
description: (transaction.decryptedDescription || '').toLowerCase(),
amount: parseFloat(transaction.amount),
transaction_date: transaction.transaction_date,
bank_name: bank?.name || '',
account_name: account?.name || '',
category: category?.name || null,
notes: transaction.decryptedNotes?.toLowerCase() || null,
};
}
@ -71,7 +106,10 @@ export function evaluateRules(
);
consoleDebug('[Rule Engine] Rule JSON:', rule.rules_json);
const result = jsonLogic.apply(rule.rules_json, transactionData);
const normalizedRulesJson = normalizeRuleJson(rule.rules_json);
consoleDebug('[Rule Engine] Normalized Rule JSON:', normalizedRulesJson);
const result = jsonLogic.apply(normalizedRulesJson, transactionData);
consoleDebug(`[Rule Engine] Rule #${rule.id} result:`, result);

View File

@ -24,7 +24,7 @@ test('user can create an automation rule with category action', function () {
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
'title' => 'Grocery Rule',
'priority' => 10,
'rules_json' => json_encode(['in' => ['GROCERY', ['var' => 'description']]]),
'rules_json' => json_encode(['in' => ['grocery', ['var' => 'description']]]),
'action_category_id' => $category->id,
'action_note' => null,
'action_note_iv' => null,
@ -203,3 +203,83 @@ test('user cannot use another users category in rule', function () {
$response->assertSessionHasErrors('action_category_id');
});
test('rules with description filter are case insensitive with lowercase rule', function () {
$user = User::factory()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
'title' => 'Case Insensitive Description Rule (lowercase)',
'priority' => 10,
'rules_json' => json_encode(['in' => ['m3 sport', ['var' => 'description']]]),
'action_category_id' => $category->id,
'action_note' => null,
'action_note_iv' => null,
]);
$response->assertRedirect(route('automation-rules.index'));
$this->assertDatabaseHas('automation_rules', [
'user_id' => $user->id,
'title' => 'Case Insensitive Description Rule (lowercase)',
]);
});
test('rules with description filter are case insensitive with uppercase rule', function () {
$user = User::factory()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
'title' => 'Case Insensitive Description Rule (uppercase)',
'priority' => 10,
'rules_json' => json_encode(['in' => ['M3 SPORT', ['var' => 'description']]]),
'action_category_id' => $category->id,
'action_note' => null,
'action_note_iv' => null,
]);
$response->assertRedirect(route('automation-rules.index'));
$this->assertDatabaseHas('automation_rules', [
'user_id' => $user->id,
'title' => 'Case Insensitive Description Rule (uppercase)',
]);
});
test('rules with description filter are case insensitive with mixed case rule', function () {
$user = User::factory()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
'title' => 'Case Insensitive Description Rule (mixed)',
'priority' => 10,
'rules_json' => json_encode(['in' => ['M3 Sport Academy', ['var' => 'description']]]),
'action_category_id' => $category->id,
'action_note' => null,
'action_note_iv' => null,
]);
$response->assertRedirect(route('automation-rules.index'));
$this->assertDatabaseHas('automation_rules', [
'user_id' => $user->id,
'title' => 'Case Insensitive Description Rule (mixed)',
]);
});
test('rules with notes filter are case insensitive', function () {
$user = User::factory()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->post(route('automation-rules.store'), [
'title' => 'Case Insensitive Notes Rule',
'priority' => 10,
'rules_json' => json_encode(['in' => ['IMPORTANT NOTE', ['var' => 'notes']]]),
'action_category_id' => $category->id,
'action_note' => null,
'action_note_iv' => null,
]);
$response->assertRedirect(route('automation-rules.index'));
$this->assertDatabaseHas('automation_rules', [
'user_id' => $user->id,
'title' => 'Case Insensitive Notes Rule',
]);
});