diff --git a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx index 43181ba7..ac172255 100644 --- a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx +++ b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx @@ -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({
+ Note: Use any case for description and notes - matching is automatic! +
-{`{"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"]}
diff --git a/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx b/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx
index e2cc32b8..587c3026 100644
--- a/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx
+++ b/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx
@@ -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({
Available fields:
- - description (string)
+ - description (string, case-insensitive)
+ - notes (string or null, case-insensitive)
- amount (number)
- transaction_date (string)
- bank_name (string)
- account_name (string)
- category (string or null)
+
+ Note: Use any case for description and notes - matching is automatic!
+
Example rules:
-{`{"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"]}
diff --git a/resources/js/lib/rule-engine.ts b/resources/js/lib/rule-engine.ts
index 3f8fb9f3..727222bc 100644
--- a/resources/js/lib/rule-engine.ts
+++ b/resources/js/lib/rule-engine.ts
@@ -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 = {};
+ 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);
diff --git a/tests/Feature/AutomationRuleTest.php b/tests/Feature/AutomationRuleTest.php
index 853b8e47..e8951f2f 100644
--- a/tests/Feature/AutomationRuleTest.php
+++ b/tests/Feature/AutomationRuleTest.php
@@ -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',
+ ]);
+});