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\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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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