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:
parent
40d4b3cfe7
commit
8ce0adf8ae
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ use App\Events\TransactionCreated;
|
||||||
use App\Events\TransactionDeleted;
|
use App\Events\TransactionDeleted;
|
||||||
use App\Events\TransactionUpdated;
|
use App\Events\TransactionUpdated;
|
||||||
use App\Http\Responses\RegisterResponse;
|
use App\Http\Responses\RegisterResponse;
|
||||||
|
use App\Listeners\ApplyAutomationRules;
|
||||||
use App\Listeners\AssignTransactionToBudget;
|
use App\Listeners\AssignTransactionToBudget;
|
||||||
use App\Listeners\UnassignTransactionFromBudget;
|
use App\Listeners\UnassignTransactionFromBudget;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
@ -40,6 +41,7 @@ class AppServiceProvider extends ServiceProvider
|
||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
Event::listen(TransactionCreated::class, ApplyAutomationRules::class);
|
||||||
Event::listen(TransactionCreated::class, AssignTransactionToBudget::class);
|
Event::listen(TransactionCreated::class, AssignTransactionToBudget::class);
|
||||||
Event::listen(TransactionUpdated::class, AssignTransactionToBudget::class);
|
Event::listen(TransactionUpdated::class, AssignTransactionToBudget::class);
|
||||||
Event::listen(TransactionDeleted::class, UnassignTransactionFromBudget::class);
|
Event::listen(TransactionDeleted::class, UnassignTransactionFromBudget::class);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"firebase/php-jwt": "^7.0",
|
"firebase/php-jwt": "^7.0",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
|
"jwadhams/json-logic-php": "^1.5",
|
||||||
"laravel/cashier": "^16.1",
|
"laravel/cashier": "^16.1",
|
||||||
"laravel/fortify": "^1.30",
|
"laravel/fortify": "^1.30",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "98b80c7fbbd588b5674d2a769d135250",
|
"content-hash": "6542d8efaa85230382eec1ee7879b3a2",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
|
|
@ -1350,6 +1350,49 @@
|
||||||
},
|
},
|
||||||
"time": "2025-03-19T14:43:43+00:00"
|
"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",
|
"name": "laravel/cashier",
|
||||||
"version": "v16.2.0",
|
"version": "v16.2.0",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue