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
This commit is contained in:
Víctor Falcón 2026-02-12 14:20:55 +01:00 committed by GitHub
parent 40d4b3cfe7
commit 8ce0adf8ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 614 additions and 1 deletions

View File

@ -0,0 +1,16 @@
<?php
namespace App\Listeners;
use App\Events\TransactionCreated;
use App\Services\AutomationRuleService;
class ApplyAutomationRules
{
public function __construct(protected AutomationRuleService $automationRuleService) {}
public function handle(TransactionCreated $event): void
{
$this->automationRuleService->applyRules($event->transaction);
}
}

View File

@ -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);

View File

@ -0,0 +1,140 @@
<?php
namespace App\Services;
use App\Models\AutomationRule;
use App\Models\Transaction;
use Illuminate\Support\Collection;
use JWadhams\JsonLogic;
class AutomationRuleService
{
public function applyRules(Transaction $transaction): void
{
if ($transaction->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<int, AutomationRule> $rules
* @param array<string, mixed> $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));
}
}

View File

@ -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",

45
composer.lock generated
View File

@ -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",

View File

@ -0,0 +1,411 @@
<?php
use App\Events\TransactionCreated;
use App\Events\TransactionUpdated;
use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use App\Services\AutomationRuleService;
use Illuminate\Support\Facades\Event;
beforeEach(function () {
$this->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);
});