From 8ce0adf8aec1e297ba87ab2ce4b39c851241fae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Thu, 12 Feb 2026 14:20:55 +0100 Subject: [PATCH] feat: Apply automation rules to bank-synced transactions (#112) ## Summary - Adds backend automation rule evaluation for bank-synced transactions using `jwadhams/json-logic-php` (same engine as the frontend `json-logic-js`) for exact rule parity - Synchronous `ApplyAutomationRules` listener on `TransactionCreated` runs before `AssignTransactionToBudget`, auto-assigning categories and labels via `saveQuietly()` / `syncWithoutDetaching()` - Skips encrypted transactions and `action_note` (encrypted, can't handle server-side) ## Test plan - [x] 17 new Pest tests covering all JsonLogic operators, category/label assignment, encrypted transaction skip, priority ordering, case-insensitive matching, user scoping, bank_name matching, amount in dollars, no `TransactionUpdated` event dispatch, compound rules, and end-to-end listener integration - [x] Existing `TransactionSyncServiceTest` passes (7 tests) - [x] Full test suite passes (546 tests, 0 failures) - [x] Pint formatting clean --- app/Listeners/ApplyAutomationRules.php | 16 + app/Providers/AppServiceProvider.php | 2 + app/Services/AutomationRuleService.php | 140 ++++++ composer.json | 1 + composer.lock | 45 +- .../Feature/AutomationRuleEvaluationTest.php | 411 ++++++++++++++++++ 6 files changed, 614 insertions(+), 1 deletion(-) create mode 100644 app/Listeners/ApplyAutomationRules.php create mode 100644 app/Services/AutomationRuleService.php create mode 100644 tests/Feature/AutomationRuleEvaluationTest.php diff --git a/app/Listeners/ApplyAutomationRules.php b/app/Listeners/ApplyAutomationRules.php new file mode 100644 index 00000000..b9070efb --- /dev/null +++ b/app/Listeners/ApplyAutomationRules.php @@ -0,0 +1,16 @@ +automationRuleService->applyRules($event->transaction); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 16a11b60..4c06f720 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,6 +7,7 @@ use App\Events\TransactionCreated; use App\Events\TransactionDeleted; use App\Events\TransactionUpdated; use App\Http\Responses\RegisterResponse; +use App\Listeners\ApplyAutomationRules; use App\Listeners\AssignTransactionToBudget; use App\Listeners\UnassignTransactionFromBudget; use App\Models\User; @@ -40,6 +41,7 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + Event::listen(TransactionCreated::class, ApplyAutomationRules::class); Event::listen(TransactionCreated::class, AssignTransactionToBudget::class); Event::listen(TransactionUpdated::class, AssignTransactionToBudget::class); Event::listen(TransactionDeleted::class, UnassignTransactionFromBudget::class); diff --git a/app/Services/AutomationRuleService.php b/app/Services/AutomationRuleService.php new file mode 100644 index 00000000..75217730 --- /dev/null +++ b/app/Services/AutomationRuleService.php @@ -0,0 +1,140 @@ +description_iv !== null) { + return; + } + + $rules = AutomationRule::query() + ->where('user_id', $transaction->user_id) + ->with('labels') + ->orderBy('priority') + ->get(); + + if ($rules->isEmpty()) { + return; + } + + $transactionData = $this->prepareTransactionData($transaction); + $matchedRule = $this->evaluateRules($rules, $transactionData); + + if ($matchedRule) { + $this->applyActions($transaction, $matchedRule); + } + } + + /** + * @return array{description: string, amount: float, transaction_date: string, bank_name: string, account_name: string, category: string|null, notes: string|null} + */ + private function prepareTransactionData(Transaction $transaction): array + { + $transaction->loadMissing(['account.bank', 'category']); + + $account = $transaction->account; + $bank = $account?->bank; + + $accountName = ''; + if ($account && ! $account->encrypted) { + $accountName = trim($account->name); + } + + return [ + 'description' => $this->normalizeWhitespace(mb_strtolower($transaction->description ?? '')), + 'amount' => $transaction->amount / 100, + 'transaction_date' => $transaction->transaction_date->format('Y-m-d'), + 'bank_name' => mb_strtolower($bank->name ?? ''), + 'account_name' => mb_strtolower($accountName), + 'category' => $transaction->category?->name, + 'notes' => $transaction->notes + ? $this->normalizeWhitespace(mb_strtolower($transaction->notes)) + : null, + ]; + } + + /** + * @param Collection $rules + * @param array $transactionData + */ + private function evaluateRules(Collection $rules, array $transactionData): ?AutomationRule + { + foreach ($rules as $rule) { + try { + $normalizedRulesJson = $this->normalizeRuleJson($rule->rules_json); + $result = JsonLogic::apply($normalizedRulesJson, $transactionData); + + if ($result === true) { + return $rule; + } + } catch (\Throwable) { + continue; + } + } + + return null; + } + + private function applyActions(Transaction $transaction, AutomationRule $rule): void + { + if ($rule->action_category_id) { + $transaction->category_id = $rule->action_category_id; + $transaction->saveQuietly(); + } + + $labelIds = $rule->labels->pluck('id')->all(); + if (! empty($labelIds)) { + $transaction->labels()->syncWithoutDetaching($labelIds); + } + } + + private function normalizeRuleJson(mixed $rulesJson): mixed + { + if (is_string($rulesJson)) { + $decoded = json_decode($rulesJson, true); + if (is_array($decoded)) { + return $this->normalizeRuleJson($decoded); + } + + return mb_strtolower($rulesJson); + } + + if (is_array($rulesJson)) { + if (array_is_list($rulesJson)) { + return array_map(function (mixed $item, int $index): mixed { + if ($index === 0 && is_string($item)) { + return mb_strtolower($item); + } + + if (is_array($item) && isset($item['var']) && in_array($item['var'], ['description', 'notes'])) { + return $item; + } + + return $this->normalizeRuleJson($item); + }, $rulesJson, array_keys($rulesJson)); + } + + $normalized = []; + foreach ($rulesJson as $key => $value) { + $normalized[$key] = $this->normalizeRuleJson($value); + } + + return $normalized; + } + + return $rulesJson; + } + + private function normalizeWhitespace(string $str): string + { + return trim(preg_replace('/\s+/', ' ', $str)); + } +} diff --git a/composer.json b/composer.json index 3d80962f..2f3ceefe 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "php": "^8.2", "firebase/php-jwt": "^7.0", "inertiajs/inertia-laravel": "^2.0", + "jwadhams/json-logic-php": "^1.5", "laravel/cashier": "^16.1", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", diff --git a/composer.lock b/composer.lock index 46ef34ce..5b8107fe 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "98b80c7fbbd588b5674d2a769d135250", + "content-hash": "6542d8efaa85230382eec1ee7879b3a2", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1350,6 +1350,49 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "jwadhams/json-logic-php", + "version": "1.5.1", + "source": { + "type": "git", + "url": "https://github.com/jwadhams/json-logic-php.git", + "reference": "060aab5ad36ae1fdd74d3006131b197ca777fa48" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jwadhams/json-logic-php/zipball/060aab5ad36ae1fdd74d3006131b197ca777fa48", + "reference": "060aab5ad36ae1fdd74d3006131b197ca777fa48", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3.3" + }, + "type": "library", + "autoload": { + "psr-0": { + "JWadhams": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Wadhams", + "email": "jwadhams@dealerinspire.com" + } + ], + "description": "Build rules with complex comparisons and boolean operators, serialized as JSON, and execute them in PHP", + "support": { + "issues": "https://github.com/jwadhams/json-logic-php/issues", + "source": "https://github.com/jwadhams/json-logic-php/tree/1.5.1" + }, + "time": "2024-07-09T15:20:54+00:00" + }, { "name": "laravel/cashier", "version": "v16.2.0", diff --git a/tests/Feature/AutomationRuleEvaluationTest.php b/tests/Feature/AutomationRuleEvaluationTest.php new file mode 100644 index 00000000..9af83ec7 --- /dev/null +++ b/tests/Feature/AutomationRuleEvaluationTest.php @@ -0,0 +1,411 @@ +user = User::factory()->onboarded()->create(); + $this->bank = Bank::factory()->create(['name' => 'Test Bank', 'user_id' => $this->user->id]); + $this->account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + 'name' => 'Checking Account', + 'encrypted' => false, + ]); + $this->category = Category::factory()->create(['user_id' => $this->user->id]); +}); + +test('assigns category when "in" operator matches description', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['grocery', ['var' => 'description']]], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Grocery Store Purchase', + 'amount' => -5000, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBe($this->category->id); +}); + +test('assigns labels when rule matches', function () { + $label = Label::factory()->create(['user_id' => $this->user->id]); + + $rule = AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['grocery', ['var' => 'description']]], + 'action_category_id' => null, + ]); + $rule->labels()->attach($label); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Grocery Store', + 'amount' => -2000, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->labels)->toHaveCount(1) + ->and($transaction->fresh()->labels->first()->id)->toBe($label->id); +}); + +test('assigns both category and labels', function () { + $label = Label::factory()->create(['user_id' => $this->user->id]); + + $rule = AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['coffee', ['var' => 'description']]], + 'action_category_id' => $this->category->id, + ]); + $rule->labels()->attach($label); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Coffee Shop Downtown', + 'amount' => -450, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + $fresh = $transaction->fresh(); + expect($fresh->category_id)->toBe($this->category->id) + ->and($fresh->labels)->toHaveCount(1); +}); + +test('skips encrypted transactions', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['grocery', ['var' => 'description']]], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Grocery Store', + 'description_iv' => 'some-iv-value', + 'amount' => -5000, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->not->toBe($this->category->id); +}); + +test('uses first-match-wins with priority ordering', function () { + $categoryLow = Category::factory()->create(['user_id' => $this->user->id]); + $categoryHigh = Category::factory()->create(['user_id' => $this->user->id]); + + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['store', ['var' => 'description']]], + 'action_category_id' => $categoryLow->id, + ]); + + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 10, + 'rules_json' => ['in' => ['store', ['var' => 'description']]], + 'action_category_id' => $categoryHigh->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Electronics Store', + 'amount' => -10000, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBe($categoryLow->id); +}); + +test('matches case-insensitively', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['GROCERY', ['var' => 'description']]], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'grocery store', + 'amount' => -3000, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBe($this->category->id); +}); + +test('does not apply rules from other users', function () { + $otherUser = User::factory()->onboarded()->create(); + + AutomationRule::factory()->create([ + 'user_id' => $otherUser->id, + 'priority' => 1, + 'rules_json' => ['in' => ['grocery', ['var' => 'description']]], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Grocery Store', + 'amount' => -5000, + 'category_id' => null, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBeNull(); +}); + +test('matches bank_name field', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['==' => [['var' => 'bank_name'], 'test bank']], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Some Payment', + 'amount' => -1000, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBe($this->category->id); +}); + +test('evaluates amount in dollars', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['>' => [['var' => 'amount'], 50]], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Salary', + 'amount' => 10000, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBe($this->category->id); +}); + +test('does not match when amount condition fails', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['>' => [['var' => 'amount'], 200]], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Small purchase', + 'amount' => 5000, + 'category_id' => null, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBeNull(); +}); + +test('does not fire TransactionUpdated when applying category', function () { + Event::fake([TransactionUpdated::class]); + + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['grocery', ['var' => 'description']]], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Grocery Store', + 'amount' => -5000, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBe($this->category->id); + Event::assertNotDispatched(TransactionUpdated::class); +}); + +test('evaluates compound "and" rules', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => [ + 'and' => [ + ['in' => ['grocery', ['var' => 'description']]], + ['<' => [['var' => 'amount'], 0]], + ], + ], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Grocery Store', + 'amount' => -5000, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBe($this->category->id); +}); + +test('compound "and" rule does not match when one condition fails', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => [ + 'and' => [ + ['in' => ['grocery', ['var' => 'description']]], + ['>' => [['var' => 'amount'], 0]], + ], + ], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Grocery Store', + 'amount' => -5000, + 'category_id' => null, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBeNull(); +}); + +test('evaluates "==" operator', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['==' => [['var' => 'description'], 'salary payment']], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Salary Payment', + 'amount' => 100000, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBe($this->category->id); +}); + +test('normalizes whitespace in description', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['grocery store', ['var' => 'description']]], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => ' Grocery Store Purchase ', + 'amount' => -5000, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBe($this->category->id); +}); + +test('treats encrypted account names as empty string', function () { + $encryptedAccount = Account::factory()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $this->bank->id, + 'name' => 'encrypted-data', + 'name_iv' => 'some-iv', + 'encrypted' => true, + ]); + + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['==' => [['var' => 'account_name'], '']], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $encryptedAccount->id, + 'description' => 'Some payment', + 'amount' => -1000, + ]); + + app(AutomationRuleService::class)->applyRules($transaction); + + expect($transaction->fresh()->category_id)->toBe($this->category->id); +}); + +test('applies automation rules via listener on TransactionCreated event', function () { + AutomationRule::factory()->create([ + 'user_id' => $this->user->id, + 'priority' => 1, + 'rules_json' => ['in' => ['grocery', ['var' => 'description']]], + 'action_category_id' => $this->category->id, + ]); + + $transaction = Transaction::factory()->enableBanking()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'description' => 'Grocery Store', + 'amount' => -5000, + ]); + + // The TransactionCreated event is dispatched automatically via $dispatchesEvents. + // The listener should have already run. Verify the result. + expect($transaction->fresh()->category_id)->toBe($this->category->id); +});