Improve rule evaluator to be case insensitive
This commit is contained in:
parent
a14e11775b
commit
302dff8909
|
|
@ -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"]}
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue