mirror of
https://github.com/firefly-iii/firefly-iii.git
synced 2025-09-17 02:05:34 +00:00
Merge pull request #8650 from michaelhthomas/feat/expression-engine
[feat] Rules Expression Engine
This commit is contained in:
51
app/Api/V1/Controllers/Models/Rule/ExpressionController.php
Normal file
51
app/Api/V1/Controllers/Models/Rule/ExpressionController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/*
|
||||
* ExpressionController.php
|
||||
* Copyright (c) 2024 Michael Thomas
|
||||
*
|
||||
* This file is part of Firefly III (https://github.com/firefly-iii).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FireflyIII\Api\V1\Controllers\Models\Rule;
|
||||
|
||||
use FireflyIII\Api\V1\Controllers\Controller;
|
||||
use FireflyIII\Api\V1\Requests\Models\Rule\ValidateExpressionRequest;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* Class ExpressionController
|
||||
*/
|
||||
class ExpressionController extends Controller
|
||||
{
|
||||
/**
|
||||
* This endpoint is documented at:
|
||||
* https://api-docs.firefly-iii.org/?urls.primaryName=2.0.0%20(v1)#/rules/validateExpression
|
||||
*
|
||||
* @param ValidateExpressionRequest $request
|
||||
*
|
||||
* @return JsonResponse
|
||||
*
|
||||
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
|
||||
*/
|
||||
public function validateExpression(ValidateExpressionRequest $request): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
"valid" => true,
|
||||
]);
|
||||
}
|
||||
}
|
@@ -24,6 +24,7 @@ declare(strict_types=1);
|
||||
namespace FireflyIII\Api\V1\Requests\Models\Rule;
|
||||
|
||||
use FireflyIII\Rules\IsBoolean;
|
||||
use FireflyIII\Rules\IsValidActionExpression;
|
||||
use FireflyIII\Support\Request\ChecksLogin;
|
||||
use FireflyIII\Support\Request\ConvertsDataTypes;
|
||||
use FireflyIII\Support\Request\GetRuleConfiguration;
|
||||
@@ -123,7 +124,7 @@ class StoreRequest extends FormRequest
|
||||
'triggers.*.stop_processing' => [new IsBoolean()],
|
||||
'triggers.*.active' => [new IsBoolean()],
|
||||
'actions.*.type' => 'required|in:'.implode(',', $validActions),
|
||||
'actions.*.value' => 'required_if:actions.*.type,'.$contextActions.'|ruleActionValue',
|
||||
'actions.*.value' => ['required_if:actions.*.type,'.$contextActions, new IsValidActionExpression(), 'ruleActionValue'],
|
||||
'actions.*.stop_processing' => [new IsBoolean()],
|
||||
'actions.*.active' => [new IsBoolean()],
|
||||
'strict' => [new IsBoolean()],
|
||||
|
@@ -25,6 +25,7 @@ namespace FireflyIII\Api\V1\Requests\Models\Rule;
|
||||
|
||||
use FireflyIII\Models\Rule;
|
||||
use FireflyIII\Rules\IsBoolean;
|
||||
use FireflyIII\Rules\IsValidActionExpression;
|
||||
use FireflyIII\Support\Request\ChecksLogin;
|
||||
use FireflyIII\Support\Request\ConvertsDataTypes;
|
||||
use FireflyIII\Support\Request\GetRuleConfiguration;
|
||||
@@ -140,7 +141,7 @@ class UpdateRequest extends FormRequest
|
||||
'triggers.*.stop_processing' => [new IsBoolean()],
|
||||
'triggers.*.active' => [new IsBoolean()],
|
||||
'actions.*.type' => 'required|in:'.implode(',', $validActions),
|
||||
'actions.*.value' => 'required_if:actions.*.type,'.$contextActions.'|ruleActionValue',
|
||||
'actions.*.value' => ['required_if:actions.*.type,'.$contextActions, new IsValidActionExpression(), 'ruleActionValue'],
|
||||
'actions.*.stop_processing' => [new IsBoolean()],
|
||||
'actions.*.active' => [new IsBoolean()],
|
||||
'strict' => [new IsBoolean()],
|
||||
|
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ValidateExpressionRequest.php
|
||||
* Copyright (c) 2024 Michael Thomas
|
||||
*
|
||||
* This file is part of Firefly III (https://github.com/firefly-iii).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FireflyIII\Api\V1\Requests\Models\Rule;
|
||||
|
||||
use FireflyIII\Rules\IsValidActionExpression;
|
||||
use FireflyIII\Support\Request\ChecksLogin;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Class ValidateExpressionRequest
|
||||
*/
|
||||
class ValidateExpressionRequest extends FormRequest
|
||||
{
|
||||
use ChecksLogin;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return ['expression' => ['required', new IsValidActionExpression()]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a failed validation attempt.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Validation\Validator $validator
|
||||
* @return void
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
protected function failedValidation(Validator $validator): void
|
||||
{
|
||||
$error = $validator->errors()->first('expression');
|
||||
|
||||
throw new ValidationException(
|
||||
$validator,
|
||||
response()->json([
|
||||
'valid' => false,
|
||||
'error' => $error
|
||||
], 200)
|
||||
);
|
||||
}
|
||||
}
|
@@ -24,6 +24,7 @@ declare(strict_types=1);
|
||||
namespace FireflyIII\Http\Requests;
|
||||
|
||||
use FireflyIII\Models\Rule;
|
||||
use FireflyIII\Rules\IsValidActionExpression;
|
||||
use FireflyIII\Support\Request\ChecksLogin;
|
||||
use FireflyIII\Support\Request\ConvertsDataTypes;
|
||||
use FireflyIII\Support\Request\GetRuleConfiguration;
|
||||
@@ -147,7 +148,7 @@ class RuleFormRequest extends FormRequest
|
||||
'triggers.*.type' => 'required|in:'.implode(',', $validTriggers),
|
||||
'triggers.*.value' => sprintf('required_if:triggers.*.type,%s|max:1024|min:1|ruleTriggerValue', $contextTriggers),
|
||||
'actions.*.type' => 'required|in:'.implode(',', $validActions),
|
||||
'actions.*.value' => sprintf('required_if:actions.*.type,%s|min:0|max:1024|ruleActionValue', $contextActions),
|
||||
'actions.*.value' => [sprintf('required_if:actions.*.type,%s|min:0|max:1024', $contextActions), new IsValidActionExpression(), 'ruleActionValue'],
|
||||
'strict' => 'in:0,1',
|
||||
];
|
||||
|
||||
|
@@ -26,6 +26,7 @@ namespace FireflyIII\Models;
|
||||
use Carbon\Carbon;
|
||||
use Eloquent;
|
||||
use FireflyIII\Support\Models\ReturnsIntegerIdTrait;
|
||||
use FireflyIII\TransactionRules\Expressions\ActionExpression;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -93,4 +94,10 @@ class RuleAction extends Model
|
||||
get: static fn ($value) => (int)$value,
|
||||
);
|
||||
}
|
||||
|
||||
public function getValue(array $journal): string
|
||||
{
|
||||
$expr = new ActionExpression($this->action_value);
|
||||
return $expr->evaluate($journal);
|
||||
}
|
||||
}
|
||||
|
@@ -69,9 +69,11 @@ use FireflyIII\Support\Preferences;
|
||||
use FireflyIII\Support\Steam;
|
||||
use FireflyIII\TransactionRules\Engine\RuleEngineInterface;
|
||||
use FireflyIII\TransactionRules\Engine\SearchRuleEngine;
|
||||
use FireflyIII\TransactionRules\Expressions\ActionExpressionLanguageProvider;
|
||||
use FireflyIII\Validation\FireflyValidator;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
|
||||
|
||||
/**
|
||||
* Class FireflyServiceProvider.
|
||||
@@ -200,6 +202,16 @@ class FireflyServiceProvider extends ServiceProvider
|
||||
}
|
||||
);
|
||||
|
||||
// rule expression language
|
||||
$this->app->singleton(
|
||||
ExpressionLanguage::class,
|
||||
static function () {
|
||||
$expressionLanguage = new ExpressionLanguage();
|
||||
$expressionLanguage->registerProvider(new ActionExpressionLanguageProvider());
|
||||
return $expressionLanguage;
|
||||
}
|
||||
);
|
||||
|
||||
$this->app->bind(
|
||||
RuleEngineInterface::class,
|
||||
static function (Application $app) {
|
||||
|
52
app/Rules/IsValidActionExpression.php
Normal file
52
app/Rules/IsValidActionExpression.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
*
|
||||
* IsValidActionExpression.php
|
||||
* Copyright (c) 2024 Michael Thomas
|
||||
*
|
||||
* This file is part of Firefly III (https://github.com/firefly-iii).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FireflyIII\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use FireflyIII\TransactionRules\Expressions\ActionExpression;
|
||||
|
||||
class IsValidActionExpression implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Check that the given action expression is syntactically valid.
|
||||
*
|
||||
* @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail
|
||||
*
|
||||
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
$value ??= '';
|
||||
$expr = new ActionExpression($value);
|
||||
|
||||
if (!$expr->isValid()) {
|
||||
$fail('validation.rule_action_expression')->translate([
|
||||
'error' => $expr->getValidationError()->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
@@ -54,11 +54,12 @@ class AddTag implements ActionInterface
|
||||
/** @var User $user */
|
||||
$user = User::find($journal['user_id']);
|
||||
$factory->setUser($user);
|
||||
$tag = $factory->findOrCreate($this->action->action_value);
|
||||
$tagName = $this->action->getValue($journal);
|
||||
$tag = $factory->findOrCreate($tagName);
|
||||
|
||||
if (null === $tag) {
|
||||
// could not find, could not create tag.
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.find_or_create_tag_failed', ['tag' => $this->action->action_value])));
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.find_or_create_tag_failed', ['tag' => $tagName])));
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -84,7 +85,7 @@ class AddTag implements ActionInterface
|
||||
app('log')->debug(
|
||||
sprintf('RuleAction AddTag fired but tag %d ("%s") was already added to journal %d.', $tag->id, $tag->tag, $journal['transaction_journal_id'])
|
||||
);
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.tag_already_added', ['tag' => $this->action->action_value])));
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.tag_already_added', ['tag' => $tagName])));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
@@ -44,7 +44,8 @@ class AppendDescription implements ActionInterface
|
||||
|
||||
public function actOnArray(array $journal): bool
|
||||
{
|
||||
$description = sprintf('%s %s', $journal['description'], $this->action->action_value);
|
||||
$append = $this->action->getValue($journal);
|
||||
$description = sprintf('%s %s', $journal['description'], $append);
|
||||
\DB::table('transaction_journals')->where('id', $journal['transaction_journal_id'])->limit(1)->update(['description' => $description]);
|
||||
|
||||
// event for audit log entry
|
||||
|
@@ -55,15 +55,16 @@ class AppendNotes implements ActionInterface
|
||||
$dbNote->noteable_type = TransactionJournal::class;
|
||||
$dbNote->text = '';
|
||||
}
|
||||
app('log')->debug(sprintf('RuleAction AppendNotes appended "%s" to "%s".', $this->action->action_value, $dbNote->text));
|
||||
$before = $dbNote->text;
|
||||
$text = sprintf('%s%s', $dbNote->text, $this->action->action_value);
|
||||
$append = $this->action->getValue($journal);
|
||||
$text = sprintf('%s%s', $dbNote->text, $append);
|
||||
$dbNote->text = $text;
|
||||
$dbNote->save();
|
||||
|
||||
/** @var TransactionJournal $object */
|
||||
$object = TransactionJournal::where('user_id', $journal['user_id'])->find($journal['transaction_journal_id']);
|
||||
|
||||
app('log')->debug(sprintf('RuleAction AppendNotes appended "%s" to "%s".', $append, $before));
|
||||
event(new TriggeredAuditLog($this->action->rule, $object, 'update_notes', $before, $text));
|
||||
|
||||
return true;
|
||||
|
@@ -52,16 +52,18 @@ class ConvertToDeposit implements ActionInterface
|
||||
|
||||
public function actOnArray(array $journal): bool
|
||||
{
|
||||
$actionValue = $this->action->getValue($journal);
|
||||
|
||||
// make object from array (so the data is fresh).
|
||||
/** @var null|TransactionJournal $object */
|
||||
$object = TransactionJournal::where('user_id', $journal['user_id'])->find($journal['transaction_journal_id']);
|
||||
$object = TransactionJournal::where('user_id', $journal['user_id'])->find($journal['transaction_journal_id']);
|
||||
if (null === $object) {
|
||||
app('log')->error(sprintf('Cannot find journal #%d, cannot convert to deposit.', $journal['transaction_journal_id']));
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.journal_not_found')));
|
||||
|
||||
return false;
|
||||
}
|
||||
$groupCount = TransactionJournal::where('transaction_group_id', $journal['transaction_group_id'])->count();
|
||||
$groupCount = TransactionJournal::where('transaction_group_id', $journal['transaction_group_id'])->count();
|
||||
if ($groupCount > 1) {
|
||||
app('log')->error(sprintf('Group #%d has more than one transaction in it, cannot convert to deposit.', $journal['transaction_group_id']));
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.split_group')));
|
||||
@@ -70,7 +72,7 @@ class ConvertToDeposit implements ActionInterface
|
||||
}
|
||||
|
||||
app('log')->debug(sprintf('Convert journal #%d to deposit.', $journal['transaction_journal_id']));
|
||||
$type = $object->transactionType->type;
|
||||
$type = $object->transactionType->type;
|
||||
if (TransactionType::DEPOSIT === $type) {
|
||||
app('log')->error(sprintf('Journal #%d is already a deposit (rule #%d).', $journal['transaction_journal_id'], $this->action->rule_id));
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.is_already_deposit')));
|
||||
@@ -82,7 +84,7 @@ class ConvertToDeposit implements ActionInterface
|
||||
app('log')->debug('Going to transform a withdrawal to a deposit.');
|
||||
|
||||
try {
|
||||
$res = $this->convertWithdrawalArray($object);
|
||||
$res = $this->convertWithdrawalArray($object, $actionValue);
|
||||
} catch (FireflyException $e) {
|
||||
app('log')->debug('Could not convert withdrawal to deposit.');
|
||||
app('log')->error($e->getMessage());
|
||||
@@ -99,7 +101,7 @@ class ConvertToDeposit implements ActionInterface
|
||||
app('log')->debug('Going to transform a transfer to a deposit.');
|
||||
|
||||
try {
|
||||
$res = $this->convertTransferArray($object);
|
||||
$res = $this->convertTransferArray($object, $actionValue);
|
||||
} catch (FireflyException $e) {
|
||||
app('log')->debug('Could not convert transfer to deposit.');
|
||||
app('log')->error($e->getMessage());
|
||||
@@ -122,7 +124,7 @@ class ConvertToDeposit implements ActionInterface
|
||||
*
|
||||
* @throws FireflyException
|
||||
*/
|
||||
private function convertWithdrawalArray(TransactionJournal $journal): bool
|
||||
private function convertWithdrawalArray(TransactionJournal $journal, string $actionValue = ''): bool
|
||||
{
|
||||
$user = $journal->user;
|
||||
|
||||
@@ -139,7 +141,7 @@ class ConvertToDeposit implements ActionInterface
|
||||
|
||||
// get the action value, or use the original destination name in case the action value is empty:
|
||||
// this becomes a new or existing (revenue) account, which is the source of the new deposit.
|
||||
$opposingName = '' === $this->action->action_value ? $destAccount->name : $this->action->action_value;
|
||||
$opposingName = '' === $actionValue ? $destAccount->name : $actionValue;
|
||||
// we check all possible source account types if one exists:
|
||||
$validTypes = config('firefly.expected_source_types.source.Deposit');
|
||||
$opposingAccount = $repository->findByName($opposingName, $validTypes);
|
||||
@@ -147,7 +149,7 @@ class ConvertToDeposit implements ActionInterface
|
||||
$opposingAccount = $factory->findOrCreate($opposingName, AccountType::REVENUE);
|
||||
}
|
||||
|
||||
app('log')->debug(sprintf('ConvertToDeposit. Action value is "%s", new opposing name is "%s"', $this->action->action_value, $opposingAccount->name));
|
||||
app('log')->debug(sprintf('ConvertToDeposit. Action value is "%s", new opposing name is "%s"', $actionValue, $opposingAccount->name));
|
||||
|
||||
// update the source transaction and put in the new revenue ID.
|
||||
\DB::table('transactions')
|
||||
@@ -211,7 +213,7 @@ class ConvertToDeposit implements ActionInterface
|
||||
*
|
||||
* @throws FireflyException
|
||||
*/
|
||||
private function convertTransferArray(TransactionJournal $journal): bool
|
||||
private function convertTransferArray(TransactionJournal $journal, string $actionValue = ''): bool
|
||||
{
|
||||
$user = $journal->user;
|
||||
|
||||
@@ -227,7 +229,7 @@ class ConvertToDeposit implements ActionInterface
|
||||
|
||||
// get the action value, or use the original source name in case the action value is empty:
|
||||
// this becomes a new or existing (revenue) account, which is the source of the new deposit.
|
||||
$opposingName = '' === $this->action->action_value ? $sourceAccount->name : $this->action->action_value;
|
||||
$opposingName = '' === $actionValue ? $sourceAccount->name : $actionValue;
|
||||
// we check all possible source account types if one exists:
|
||||
$validTypes = config('firefly.expected_source_types.source.Deposit');
|
||||
$opposingAccount = $repository->findByName($opposingName, $validTypes);
|
||||
@@ -235,7 +237,7 @@ class ConvertToDeposit implements ActionInterface
|
||||
$opposingAccount = $factory->findOrCreate($opposingName, AccountType::REVENUE);
|
||||
}
|
||||
|
||||
app('log')->debug(sprintf('ConvertToDeposit. Action value is "%s", revenue name is "%s"', $this->action->action_value, $opposingAccount->name));
|
||||
app('log')->debug(sprintf('ConvertToDeposit. Action value is "%s", revenue name is "%s"', $actionValue, $opposingAccount->name));
|
||||
|
||||
// update source transaction(s) to be revenue account
|
||||
\DB::table('transactions')
|
||||
|
@@ -55,6 +55,8 @@ class ConvertToTransfer implements ActionInterface
|
||||
*/
|
||||
public function actOnArray(array $journal): bool
|
||||
{
|
||||
$accountName = $this->action->getValue($journal);
|
||||
|
||||
// make object from array (so the data is fresh).
|
||||
/** @var null|TransactionJournal $object */
|
||||
$object = TransactionJournal::where('user_id', $journal['user_id'])->find($journal['transaction_journal_id']);
|
||||
@@ -102,7 +104,7 @@ class ConvertToTransfer implements ActionInterface
|
||||
$expectedType = $this->getDestinationType($journalId);
|
||||
// Deposit? Replace source with account with same type as destination.
|
||||
}
|
||||
$opposing = $repository->findByName($this->action->action_value, [$expectedType]);
|
||||
$opposing = $repository->findByName($accountName, [$expectedType]);
|
||||
|
||||
if (null === $opposing) {
|
||||
app('log')->error(
|
||||
@@ -110,11 +112,11 @@ class ConvertToTransfer implements ActionInterface
|
||||
'Journal #%d cannot be converted because no valid %s account with name "%s" exists (rule #%d).',
|
||||
$expectedType,
|
||||
$journalId,
|
||||
$this->action->action_value,
|
||||
$accountName,
|
||||
$this->action->rule_id
|
||||
)
|
||||
);
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.no_valid_opposing', ['name' => $this->action->action_value])));
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.no_valid_opposing', ['name' => $accountName])));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
@@ -52,16 +52,18 @@ class ConvertToWithdrawal implements ActionInterface
|
||||
|
||||
public function actOnArray(array $journal): bool
|
||||
{
|
||||
$actionValue = $this->action->getValue($journal);
|
||||
|
||||
// make object from array (so the data is fresh).
|
||||
/** @var null|TransactionJournal $object */
|
||||
$object = TransactionJournal::where('user_id', $journal['user_id'])->find($journal['transaction_journal_id']);
|
||||
$object = TransactionJournal::where('user_id', $journal['user_id'])->find($journal['transaction_journal_id']);
|
||||
if (null === $object) {
|
||||
app('log')->error(sprintf('Cannot find journal #%d, cannot convert to withdrawal.', $journal['transaction_journal_id']));
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.journal_not_found')));
|
||||
|
||||
return false;
|
||||
}
|
||||
$groupCount = TransactionJournal::where('transaction_group_id', $journal['transaction_group_id'])->count();
|
||||
$groupCount = TransactionJournal::where('transaction_group_id', $journal['transaction_group_id'])->count();
|
||||
if ($groupCount > 1) {
|
||||
app('log')->error(sprintf('Group #%d has more than one transaction in it, cannot convert to withdrawal.', $journal['transaction_group_id']));
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.split_group')));
|
||||
@@ -69,7 +71,7 @@ class ConvertToWithdrawal implements ActionInterface
|
||||
return false;
|
||||
}
|
||||
|
||||
$type = $object->transactionType->type;
|
||||
$type = $object->transactionType->type;
|
||||
if (TransactionType::WITHDRAWAL === $type) {
|
||||
app('log')->error(sprintf('Journal #%d is already a withdrawal (rule #%d).', $journal['transaction_journal_id'], $this->action->rule_id));
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.is_already_withdrawal')));
|
||||
@@ -85,7 +87,7 @@ class ConvertToWithdrawal implements ActionInterface
|
||||
app('log')->debug('Going to transform a deposit to a withdrawal.');
|
||||
|
||||
try {
|
||||
$res = $this->convertDepositArray($object);
|
||||
$res = $this->convertDepositArray($object, $actionValue);
|
||||
} catch (FireflyException $e) {
|
||||
app('log')->debug('Could not convert transfer to deposit.');
|
||||
app('log')->error($e->getMessage());
|
||||
@@ -101,7 +103,7 @@ class ConvertToWithdrawal implements ActionInterface
|
||||
app('log')->debug('Going to transform a transfer to a withdrawal.');
|
||||
|
||||
try {
|
||||
$res = $this->convertTransferArray($object);
|
||||
$res = $this->convertTransferArray($object, $actionValue);
|
||||
} catch (FireflyException $e) {
|
||||
app('log')->debug('Could not convert transfer to deposit.');
|
||||
app('log')->error($e->getMessage());
|
||||
@@ -117,7 +119,7 @@ class ConvertToWithdrawal implements ActionInterface
|
||||
/**
|
||||
* @throws FireflyException
|
||||
*/
|
||||
private function convertDepositArray(TransactionJournal $journal): bool
|
||||
private function convertDepositArray(TransactionJournal $journal, string $actionValue = ''): bool
|
||||
{
|
||||
$user = $journal->user;
|
||||
|
||||
@@ -133,7 +135,7 @@ class ConvertToWithdrawal implements ActionInterface
|
||||
|
||||
// get the action value, or use the original source name in case the action value is empty:
|
||||
// this becomes a new or existing (expense) account, which is the destination of the new withdrawal.
|
||||
$opposingName = '' === $this->action->action_value ? $sourceAccount->name : $this->action->action_value;
|
||||
$opposingName = '' === $actionValue ? $sourceAccount->name : $actionValue;
|
||||
// we check all possible source account types if one exists:
|
||||
$validTypes = config('firefly.expected_source_types.destination.Withdrawal');
|
||||
$opposingAccount = $repository->findByName($opposingName, $validTypes);
|
||||
@@ -141,7 +143,7 @@ class ConvertToWithdrawal implements ActionInterface
|
||||
$opposingAccount = $factory->findOrCreate($opposingName, AccountType::EXPENSE);
|
||||
}
|
||||
|
||||
app('log')->debug(sprintf('ConvertToWithdrawal. Action value is "%s", expense name is "%s"', $this->action->action_value, $opposingName));
|
||||
app('log')->debug(sprintf('ConvertToWithdrawal. Action value is "%s", expense name is "%s"', $actionValue, $opposingName));
|
||||
|
||||
// update source transaction(s) to be the original destination account
|
||||
\DB::table('transactions')
|
||||
@@ -203,7 +205,7 @@ class ConvertToWithdrawal implements ActionInterface
|
||||
*
|
||||
* @throws FireflyException
|
||||
*/
|
||||
private function convertTransferArray(TransactionJournal $journal): bool
|
||||
private function convertTransferArray(TransactionJournal $journal, string $actionValue = ''): bool
|
||||
{
|
||||
// find or create expense account.
|
||||
$user = $journal->user;
|
||||
@@ -219,7 +221,7 @@ class ConvertToWithdrawal implements ActionInterface
|
||||
|
||||
// get the action value, or use the original source name in case the action value is empty:
|
||||
// this becomes a new or existing (expense) account, which is the destination of the new withdrawal.
|
||||
$opposingName = '' === $this->action->action_value ? $destAccount->name : $this->action->action_value;
|
||||
$opposingName = '' === $actionValue ? $destAccount->name : $actionValue;
|
||||
// we check all possible source account types if one exists:
|
||||
$validTypes = config('firefly.expected_source_types.destination.Withdrawal');
|
||||
$opposingAccount = $repository->findByName($opposingName, $validTypes);
|
||||
@@ -227,7 +229,7 @@ class ConvertToWithdrawal implements ActionInterface
|
||||
$opposingAccount = $factory->findOrCreate($opposingName, AccountType::EXPENSE);
|
||||
}
|
||||
|
||||
app('log')->debug(sprintf('ConvertToWithdrawal. Action value is "%s", destination name is "%s"', $this->action->action_value, $opposingName));
|
||||
app('log')->debug(sprintf('ConvertToWithdrawal. Action value is "%s", destination name is "%s"', $actionValue, $opposingName));
|
||||
|
||||
// update destination transaction(s) to be new expense account.
|
||||
\DB::table('transactions')
|
||||
|
@@ -54,7 +54,7 @@ class LinkToBill implements ActionInterface
|
||||
/** @var BillRepositoryInterface $repository */
|
||||
$repository = app(BillRepositoryInterface::class);
|
||||
$repository->setUser($user);
|
||||
$billName = (string)$this->action->action_value;
|
||||
$billName = $this->action->getValue($journal);
|
||||
$bill = $repository->findByName($billName);
|
||||
|
||||
if (null !== $bill && TransactionType::WITHDRAWAL === $journal['transaction_type_type']) {
|
||||
|
@@ -45,7 +45,7 @@ class PrependDescription implements ActionInterface
|
||||
public function actOnArray(array $journal): bool
|
||||
{
|
||||
$before = $journal['description'];
|
||||
$after = sprintf('%s%s', $this->action->action_value, $journal['description']);
|
||||
$after = sprintf('%s%s', $this->action->getValue($journal), $journal['description']);
|
||||
\DB::table('transaction_journals')->where('id', $journal['transaction_journal_id'])->limit(1)->update(['description' => $after]);
|
||||
|
||||
// journal
|
||||
|
@@ -56,8 +56,9 @@ class PrependNotes implements ActionInterface
|
||||
$dbNote->text = '';
|
||||
}
|
||||
$before = $dbNote->text;
|
||||
app('log')->debug(sprintf('RuleAction PrependNotes prepended "%s" to "%s".', $this->action->action_value, $dbNote->text));
|
||||
$text = sprintf('%s%s', $this->action->action_value, $dbNote->text);
|
||||
$after = $this->action->getValue($journal);
|
||||
app('log')->debug(sprintf('RuleAction PrependNotes prepended "%s" to "%s".', $after, $dbNote->text));
|
||||
$text = sprintf('%s%s', $after, $dbNote->text);
|
||||
$dbNote->text = $text;
|
||||
$dbNote->save();
|
||||
|
||||
|
@@ -46,13 +46,13 @@ class RemoveTag implements ActionInterface
|
||||
|
||||
public function actOnArray(array $journal): bool
|
||||
{
|
||||
// if tag does not exist, no need to continue:
|
||||
$name = $this->action->action_value;
|
||||
$name = $this->action->getValue($journal);
|
||||
|
||||
/** @var User $user */
|
||||
$user = User::find($journal['user_id']);
|
||||
$tag = $user->tags()->where('tag', $name)->first();
|
||||
|
||||
// if tag does not exist, no need to continue:
|
||||
if (null === $tag) {
|
||||
app('log')->debug(
|
||||
sprintf('RuleAction RemoveTag tried to remove tag "%s" from journal #%d but no such tag exists.', $name, $journal['transaction_journal_id'])
|
||||
|
@@ -49,7 +49,7 @@ class SetBudget implements ActionInterface
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = User::find($journal['user_id']);
|
||||
$search = $this->action->action_value;
|
||||
$search = $this->action->getValue($journal);
|
||||
|
||||
$budget = $user->budgets()->where('name', $search)->first();
|
||||
if (null === $budget) {
|
||||
|
@@ -49,7 +49,7 @@ class SetCategory implements ActionInterface
|
||||
{
|
||||
/** @var null|User $user */
|
||||
$user = User::find($journal['user_id']);
|
||||
$search = $this->action->action_value;
|
||||
$search = $this->action->getValue($journal);
|
||||
if (null === $user) {
|
||||
app('log')->error(sprintf('Journal has no valid user ID so action SetCategory("%s") cannot be applied', $search), $journal);
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.no_such_journal')));
|
||||
|
@@ -47,10 +47,11 @@ class SetDescription implements ActionInterface
|
||||
/** @var TransactionJournal $object */
|
||||
$object = TransactionJournal::where('user_id', $journal['user_id'])->find($journal['transaction_journal_id']);
|
||||
$before = $object->description;
|
||||
$after = $this->action->getValue($journal);
|
||||
|
||||
\DB::table('transaction_journals')
|
||||
->where('id', '=', $journal['transaction_journal_id'])
|
||||
->update(['description' => $this->action->action_value])
|
||||
->update(['description' => $after])
|
||||
;
|
||||
|
||||
app('log')->debug(
|
||||
@@ -58,11 +59,11 @@ class SetDescription implements ActionInterface
|
||||
'RuleAction SetDescription changed the description of journal #%d from "%s" to "%s".',
|
||||
$journal['transaction_journal_id'],
|
||||
$journal['description'],
|
||||
$this->action->action_value
|
||||
$after
|
||||
)
|
||||
);
|
||||
$object->refresh();
|
||||
event(new TriggeredAuditLog($this->action->rule, $object, 'update_description', $before, $this->action->action_value));
|
||||
event(new TriggeredAuditLog($this->action->rule, $object, 'update_description', $before, $after));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@@ -51,6 +51,8 @@ class SetDestinationAccount implements ActionInterface
|
||||
|
||||
public function actOnArray(array $journal): bool
|
||||
{
|
||||
$accountName = $this->action->getValue($journal);
|
||||
|
||||
/** @var User $user */
|
||||
$user = User::find($journal['user_id']);
|
||||
|
||||
@@ -68,16 +70,16 @@ class SetDestinationAccount implements ActionInterface
|
||||
$this->repository->setUser($user);
|
||||
|
||||
// if this is a transfer or a deposit, the new destination account must be an asset account or a default account, and it MUST exist:
|
||||
$newAccount = $this->findAssetAccount($type);
|
||||
$newAccount = $this->findAssetAccount($type, $accountName);
|
||||
if ((TransactionType::DEPOSIT === $type || TransactionType::TRANSFER === $type) && null === $newAccount) {
|
||||
app('log')->error(
|
||||
sprintf(
|
||||
'Cant change destination account of journal #%d because no asset account with name "%s" exists.',
|
||||
$object->id,
|
||||
$this->action->action_value
|
||||
$accountName
|
||||
)
|
||||
);
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.cannot_find_asset', ['name' => $this->action->action_value])));
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.cannot_find_asset', ['name' => $accountName])));
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -115,7 +117,7 @@ class SetDestinationAccount implements ActionInterface
|
||||
// if this is a withdrawal, the new destination account must be a expense account and may be created:
|
||||
// or it is a liability, in which case it must be returned.
|
||||
if (TransactionType::WITHDRAWAL === $type) {
|
||||
$newAccount = $this->findWithdrawalDestinationAccount();
|
||||
$newAccount = $this->findWithdrawalDestinationAccount($accountName);
|
||||
}
|
||||
|
||||
app('log')->debug(sprintf('New destination account is #%d ("%s").', $newAccount->id, $newAccount->name));
|
||||
@@ -134,23 +136,23 @@ class SetDestinationAccount implements ActionInterface
|
||||
return true;
|
||||
}
|
||||
|
||||
private function findAssetAccount(string $type): ?Account
|
||||
private function findAssetAccount(string $type, string $accountName): ?Account
|
||||
{
|
||||
// switch on type:
|
||||
$allowed = config(sprintf('firefly.expected_source_types.destination.%s', $type));
|
||||
$allowed = is_array($allowed) ? $allowed : [];
|
||||
app('log')->debug(sprintf('Check config for expected_source_types.destination.%s, result is', $type), $allowed);
|
||||
|
||||
return $this->repository->findByName($this->action->action_value, $allowed);
|
||||
return $this->repository->findByName($accountName, $allowed);
|
||||
}
|
||||
|
||||
private function findWithdrawalDestinationAccount(): Account
|
||||
private function findWithdrawalDestinationAccount(string $accountName): Account
|
||||
{
|
||||
$allowed = config('firefly.expected_source_types.destination.Withdrawal');
|
||||
$account = $this->repository->findByName($this->action->action_value, $allowed);
|
||||
$account = $this->repository->findByName($accountName, $allowed);
|
||||
if (null === $account) {
|
||||
$data = [
|
||||
'name' => $this->action->action_value,
|
||||
'name' => $accountName,
|
||||
'account_type_name' => 'expense',
|
||||
'account_type_id' => null,
|
||||
'virtual_balance' => 0,
|
||||
|
@@ -55,7 +55,8 @@ class SetNotes implements ActionInterface
|
||||
$dbNote->text = '';
|
||||
}
|
||||
$oldNotes = $dbNote->text;
|
||||
$dbNote->text = $this->action->action_value;
|
||||
$newNotes = $this->action->getValue($journal);
|
||||
$dbNote->text = $newNotes;
|
||||
$dbNote->save();
|
||||
|
||||
app('log')->debug(
|
||||
@@ -63,14 +64,14 @@ class SetNotes implements ActionInterface
|
||||
'RuleAction SetNotes changed the notes of journal #%d from "%s" to "%s".',
|
||||
$journal['transaction_journal_id'],
|
||||
$oldNotes,
|
||||
$this->action->action_value
|
||||
$newNotes
|
||||
)
|
||||
);
|
||||
|
||||
/** @var TransactionJournal $object */
|
||||
$object = TransactionJournal::where('user_id', $journal['user_id'])->find($journal['transaction_journal_id']);
|
||||
|
||||
event(new TriggeredAuditLog($this->action->rule, $object, 'update_notes', $oldNotes, $this->action->action_value));
|
||||
event(new TriggeredAuditLog($this->action->rule, $object, 'update_notes', $oldNotes, $newNotes));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@@ -51,6 +51,8 @@ class SetSourceAccount implements ActionInterface
|
||||
|
||||
public function actOnArray(array $journal): bool
|
||||
{
|
||||
$accountName = $this->action->getValue($journal);
|
||||
|
||||
/** @var User $user */
|
||||
$user = User::find($journal['user_id']);
|
||||
|
||||
@@ -67,12 +69,12 @@ class SetSourceAccount implements ActionInterface
|
||||
$this->repository->setUser($user);
|
||||
|
||||
// if this is a transfer or a withdrawal, the new source account must be an asset account or a default account, and it MUST exist:
|
||||
$newAccount = $this->findAssetAccount($type);
|
||||
$newAccount = $this->findAssetAccount($type, $accountName);
|
||||
if ((TransactionType::WITHDRAWAL === $type || TransactionType::TRANSFER === $type) && null === $newAccount) {
|
||||
app('log')->error(
|
||||
sprintf('Cant change source account of journal #%d because no asset account with name "%s" exists.', $object->id, $this->action->action_value)
|
||||
sprintf('Cant change source account of journal #%d because no asset account with name "%s" exists.', $object->id, $accountName)
|
||||
);
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.cannot_find_asset', ['name' => $this->action->action_value])));
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.cannot_find_asset', ['name' => $accountName])));
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -109,7 +111,7 @@ class SetSourceAccount implements ActionInterface
|
||||
// if this is a deposit, the new source account must be a revenue account and may be created:
|
||||
// or it's a liability
|
||||
if (TransactionType::DEPOSIT === $type) {
|
||||
$newAccount = $this->findDepositSourceAccount();
|
||||
$newAccount = $this->findDepositSourceAccount($accountName);
|
||||
}
|
||||
|
||||
app('log')->debug(sprintf('New source account is #%d ("%s").', $newAccount->id, $newAccount->name));
|
||||
@@ -128,24 +130,24 @@ class SetSourceAccount implements ActionInterface
|
||||
return true;
|
||||
}
|
||||
|
||||
private function findAssetAccount(string $type): ?Account
|
||||
private function findAssetAccount(string $type, string $accountName): ?Account
|
||||
{
|
||||
// switch on type:
|
||||
$allowed = config(sprintf('firefly.expected_source_types.source.%s', $type));
|
||||
$allowed = is_array($allowed) ? $allowed : [];
|
||||
app('log')->debug(sprintf('Check config for expected_source_types.source.%s, result is', $type), $allowed);
|
||||
|
||||
return $this->repository->findByName($this->action->action_value, $allowed);
|
||||
return $this->repository->findByName($accountName, $allowed);
|
||||
}
|
||||
|
||||
private function findDepositSourceAccount(): Account
|
||||
private function findDepositSourceAccount(string $accountName): Account
|
||||
{
|
||||
$allowed = config('firefly.expected_source_types.source.Deposit');
|
||||
$account = $this->repository->findByName($this->action->action_value, $allowed);
|
||||
$account = $this->repository->findByName($accountName, $allowed);
|
||||
if (null === $account) {
|
||||
// create new revenue account with this name:
|
||||
$data = [
|
||||
'name' => $this->action->action_value,
|
||||
'name' => $accountName,
|
||||
'account_type_name' => 'revenue',
|
||||
'account_type_id' => null,
|
||||
'virtual_balance' => 0,
|
||||
|
@@ -50,6 +50,8 @@ class UpdatePiggybank implements ActionInterface
|
||||
|
||||
public function actOnArray(array $journal): bool
|
||||
{
|
||||
$actionValue = $this->action->getValue($journal);
|
||||
|
||||
app('log')->debug(sprintf('Triggered rule action UpdatePiggybank on journal #%d', $journal['transaction_journal_id']));
|
||||
|
||||
// refresh the transaction type.
|
||||
@@ -59,12 +61,12 @@ class UpdatePiggybank implements ActionInterface
|
||||
/** @var TransactionJournal $journalObj */
|
||||
$journalObj = $user->transactionJournals()->find($journal['transaction_journal_id']);
|
||||
|
||||
$piggyBank = $this->findPiggyBank($user);
|
||||
$piggyBank = $this->findPiggyBank($user, $actionValue);
|
||||
if (null === $piggyBank) {
|
||||
app('log')->info(
|
||||
sprintf('No piggy bank named "%s", cant execute action #%d of rule #%d', $this->action->action_value, $this->action->id, $this->action->rule_id)
|
||||
sprintf('No piggy bank named "%s", cant execute action #%d of rule #%d', $actionValue, $this->action->id, $this->action->rule_id)
|
||||
);
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.cannot_find_piggy', ['name' => $this->action->action_value])));
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.cannot_find_piggy', ['name' => $actionValue])));
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -126,14 +128,14 @@ class UpdatePiggybank implements ActionInterface
|
||||
$destination->account_id
|
||||
)
|
||||
);
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.no_link_piggy', ['name' => $this->action->action_value])));
|
||||
event(new RuleActionFailedOnArray($this->action, $journal, trans('rules.no_link_piggy', ['name' => $actionValue])));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function findPiggyBank(User $user): ?PiggyBank
|
||||
private function findPiggyBank(User $user, string $name): ?PiggyBank
|
||||
{
|
||||
return $user->piggyBanks()->where('piggy_banks.name', $this->action->action_value)->first();
|
||||
return $user->piggyBanks()->where('piggy_banks.name', $name)->first();
|
||||
}
|
||||
|
||||
private function removeAmount(PiggyBank $piggyBank, TransactionJournal $journal, string $amount): void
|
||||
|
155
app/TransactionRules/Expressions/ActionExpression.php
Normal file
155
app/TransactionRules/Expressions/ActionExpression.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ActionExpression.php
|
||||
* Copyright (c) 2024 Michael Thomas
|
||||
*
|
||||
* This file is part of Firefly III (https://github.com/firefly-iii).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FireflyIII\TransactionRules\Expressions;
|
||||
|
||||
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
|
||||
use Symfony\Component\ExpressionLanguage\SyntaxError;
|
||||
|
||||
class ActionExpression
|
||||
{
|
||||
private static array $NAMES = array(
|
||||
"transaction_group_id",
|
||||
"user_id",
|
||||
"user_group_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"transaction_group_title",
|
||||
"group_created_at",
|
||||
"group_updated_at",
|
||||
"transaction_journal_id",
|
||||
"transaction_type_id",
|
||||
"description",
|
||||
"date",
|
||||
"order",
|
||||
"transaction_type_type",
|
||||
"source_transaction_id",
|
||||
"source_account_id",
|
||||
"reconciled",
|
||||
"amount",
|
||||
"currency_id",
|
||||
"currency_code",
|
||||
"currency_name",
|
||||
"currency_symbol",
|
||||
"currency_decimal_places",
|
||||
"foreign_amount",
|
||||
"foreign_currency_id",
|
||||
"foreign_currency_code",
|
||||
"foreign_currency_name",
|
||||
"foreign_currency_symbol",
|
||||
"foreign_currency_decimal_places",
|
||||
"destination_account_id",
|
||||
"source_account_name",
|
||||
"source_account_iban",
|
||||
"source_account_type",
|
||||
"destination_account_name",
|
||||
"destination_account_iban",
|
||||
"destination_account_type",
|
||||
"category_id",
|
||||
"category_name",
|
||||
"budget_id",
|
||||
"budget_name",
|
||||
"tags",
|
||||
"attachments",
|
||||
"interest_date",
|
||||
"payment_date",
|
||||
"invoice_date",
|
||||
"book_date",
|
||||
"due_date",
|
||||
"process_date",
|
||||
"destination_transaction_id"
|
||||
);
|
||||
|
||||
private ExpressionLanguage $expressionLanguage;
|
||||
private string $expr;
|
||||
private bool $isExpression;
|
||||
private ?SyntaxError $validationError;
|
||||
|
||||
public function __construct(string $expr)
|
||||
{
|
||||
$this->expressionLanguage = app(ExpressionLanguage::class);
|
||||
$this->expr = $expr;
|
||||
|
||||
$this->isExpression = self::isExpression($expr);
|
||||
$this->validationError = $this->validate();
|
||||
}
|
||||
|
||||
private static function isExpression(string $expr): bool
|
||||
{
|
||||
return str_starts_with($expr, "=");
|
||||
}
|
||||
|
||||
private function validate(): ?SyntaxError
|
||||
{
|
||||
if (!$this->isExpression) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->lint();
|
||||
return null;
|
||||
} catch (SyntaxError $e) {
|
||||
return $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function lintExpression(string $expr): void
|
||||
{
|
||||
$this->expressionLanguage->lint($expr, self::$NAMES);
|
||||
}
|
||||
|
||||
private function lint(): void
|
||||
{
|
||||
if (!$this->isExpression) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->lintExpression(substr($this->expr, 1));
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->validationError === null;
|
||||
}
|
||||
|
||||
public function getValidationError(): ?SyntaxError
|
||||
{
|
||||
return $this->validationError;
|
||||
}
|
||||
|
||||
private function evaluateExpression(string $expr, array $journal): string
|
||||
{
|
||||
$result = $this->expressionLanguage->evaluate($expr, $journal);
|
||||
return strval($result);
|
||||
}
|
||||
|
||||
public function evaluate(array $journal): string
|
||||
{
|
||||
if (!$this->isExpression) {
|
||||
return $this->expr;
|
||||
}
|
||||
|
||||
return $this->evaluateExpression(substr($this->expr, 1), $journal);
|
||||
}
|
||||
}
|
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* ActionExpressionLanguageProvider.php
|
||||
* Copyright (c) 2024 Michael Thomas
|
||||
*
|
||||
* This file is part of Firefly III (https://github.com/firefly-iii).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FireflyIII\TransactionRules\Expressions;
|
||||
|
||||
use Symfony\Component\ExpressionLanguage\ExpressionFunction;
|
||||
use Symfony\Component\ExpressionLanguage\ExpressionFunctionProviderInterface;
|
||||
|
||||
class ActionExpressionLanguageProvider implements ExpressionFunctionProviderInterface
|
||||
{
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
ExpressionFunction::fromPhp("substr"),
|
||||
ExpressionFunction::fromPhp("strlen")
|
||||
];
|
||||
}
|
||||
}
|
@@ -35,6 +35,7 @@ use FireflyIII\Repositories\Budget\BudgetRepositoryInterface;
|
||||
use FireflyIII\Repositories\PiggyBank\PiggyBankRepositoryInterface;
|
||||
use FireflyIII\Services\Password\Verifier;
|
||||
use FireflyIII\Support\ParseDateString;
|
||||
use FireflyIII\TransactionRules\Expressions\ActionExpression;
|
||||
use FireflyIII\User;
|
||||
use Illuminate\Validation\Validator;
|
||||
use PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException;
|
||||
@@ -268,6 +269,11 @@ class FireflyValidator extends Validator
|
||||
return false;
|
||||
}
|
||||
|
||||
// if value is an expression, assume valid
|
||||
if (str_starts_with($value, '=')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if it's set_budget, verify the budget name:
|
||||
if ('set_budget' === $actionType) {
|
||||
/** @var BudgetRepositoryInterface $repository */
|
||||
|
@@ -105,6 +105,7 @@
|
||||
"spatie/laravel-html": "^3.2",
|
||||
"spatie/laravel-ignition": "^2",
|
||||
"spatie/period": "^2.4",
|
||||
"symfony/expression-language": "^6.4",
|
||||
"symfony/http-client": "^7.0",
|
||||
"symfony/mailgun-mailer": "^7.0",
|
||||
"therobfonz/laravel-mandrill-driver": "^5.0"
|
||||
@@ -124,8 +125,7 @@
|
||||
"phpunit/phpunit": "^10",
|
||||
"thecodingmachine/phpstan-strict-rules": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
},
|
||||
"suggest": {},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FireflyIII\\": "app/",
|
||||
|
312
composer.lock
generated
312
composer.lock
generated
@@ -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": "58ae8806859163b7e368713d917a12e0",
|
||||
"content-hash": "9cfa71fcc341ecf39399d0a0ff39dd69",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@@ -6026,6 +6026,178 @@
|
||||
},
|
||||
"time": "2023-02-20T14:31:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/cache",
|
||||
"version": "v7.0.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/cache.git",
|
||||
"reference": "fc822951dd360a593224bb2cef90a087d0dff60f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/cache/zipball/fc822951dd360a593224bb2cef90a087d0dff60f",
|
||||
"reference": "fc822951dd360a593224bb2cef90a087d0dff60f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"psr/cache": "^2.0|^3.0",
|
||||
"psr/log": "^1.1|^2|^3",
|
||||
"symfony/cache-contracts": "^2.5|^3",
|
||||
"symfony/service-contracts": "^2.5|^3",
|
||||
"symfony/var-exporter": "^6.4|^7.0"
|
||||
},
|
||||
"conflict": {
|
||||
"doctrine/dbal": "<3.6",
|
||||
"symfony/dependency-injection": "<6.4",
|
||||
"symfony/http-kernel": "<6.4",
|
||||
"symfony/var-dumper": "<6.4"
|
||||
},
|
||||
"provide": {
|
||||
"psr/cache-implementation": "2.0|3.0",
|
||||
"psr/simple-cache-implementation": "1.0|2.0|3.0",
|
||||
"symfony/cache-implementation": "1.1|2.0|3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"cache/integration-tests": "dev-master",
|
||||
"doctrine/dbal": "^3.6|^4",
|
||||
"predis/predis": "^1.1|^2.0",
|
||||
"psr/simple-cache": "^1.0|^2.0|^3.0",
|
||||
"symfony/config": "^6.4|^7.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0",
|
||||
"symfony/filesystem": "^6.4|^7.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0",
|
||||
"symfony/messenger": "^6.4|^7.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Cache\\": ""
|
||||
},
|
||||
"classmap": [
|
||||
"Traits/ValueWrapper.php"
|
||||
],
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides extended PSR-6, PSR-16 (and tags) implementations",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"caching",
|
||||
"psr6"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/cache/tree/v7.0.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-02-22T20:27:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/cache-contracts",
|
||||
"version": "v3.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/cache-contracts.git",
|
||||
"reference": "1d74b127da04ffa87aa940abe15446fa89653778"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/cache-contracts/zipball/1d74b127da04ffa87aa940abe15446fa89653778",
|
||||
"reference": "1d74b127da04ffa87aa940abe15446fa89653778",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"psr/cache": "^3.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "3.4-dev"
|
||||
},
|
||||
"thanks": {
|
||||
"name": "symfony/contracts",
|
||||
"url": "https://github.com/symfony/contracts"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Contracts\\Cache\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Generic abstractions related to caching",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"abstractions",
|
||||
"contracts",
|
||||
"decoupling",
|
||||
"interfaces",
|
||||
"interoperability",
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/cache-contracts/tree/v3.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2023-09-25T12:52:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/console",
|
||||
"version": "v6.4.4",
|
||||
@@ -6483,6 +6655,70 @@
|
||||
],
|
||||
"time": "2023-05-23T14:45:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/expression-language",
|
||||
"version": "v6.4.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/expression-language.git",
|
||||
"reference": "b4a4ae33fbb33a99d23c5698faaecadb76ad0fe4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/expression-language/zipball/b4a4ae33fbb33a99d23c5698faaecadb76ad0fe4",
|
||||
"reference": "b4a4ae33fbb33a99d23c5698faaecadb76ad0fe4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"symfony/cache": "^5.4|^6.0|^7.0",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\ExpressionLanguage\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides an engine that can compile and evaluate expressions",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/expression-language/tree/v6.4.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-01-23T14:51:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/finder",
|
||||
"version": "v6.4.0",
|
||||
@@ -8584,6 +8820,80 @@
|
||||
],
|
||||
"time": "2024-02-15T11:23:52+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/var-exporter",
|
||||
"version": "v7.0.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/var-exporter.git",
|
||||
"reference": "dfb0acb6803eb714f05d97dd4c5abe6d5fa9fe41"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/var-exporter/zipball/dfb0acb6803eb714f05d97dd4c5abe6d5fa9fe41",
|
||||
"reference": "dfb0acb6803eb714f05d97dd4c5abe6d5fa9fe41",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/var-dumper": "^6.4|^7.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\VarExporter\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Allows exporting any serializable PHP data structure to plain PHP code",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"clone",
|
||||
"construct",
|
||||
"export",
|
||||
"hydrate",
|
||||
"instantiate",
|
||||
"lazy-loading",
|
||||
"proxy",
|
||||
"serialize"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/var-exporter/tree/v7.0.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-02-26T10:35:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "therobfonz/laravel-mandrill-driver",
|
||||
"version": "5.0.0",
|
||||
|
@@ -46,6 +46,7 @@ return [
|
||||
'reconciled_forbidden_field' => 'This transaction is already reconciled, you cannot change the ":field"',
|
||||
'deleted_user' => 'Due to security constraints, you cannot register using this email address.',
|
||||
'rule_trigger_value' => 'This value is invalid for the selected trigger.',
|
||||
'rule_action_expression' => 'Invalid expression. :error',
|
||||
'rule_action_value' => 'This value is invalid for the selected action.',
|
||||
'file_already_attached' => 'Uploaded file ":name" is already attached to this object.',
|
||||
'file_attached' => 'Successfully uploaded file ":name".',
|
||||
|
@@ -606,6 +606,8 @@ Route::group(
|
||||
Route::put('{rule}', ['uses' => 'UpdateController@update', 'as' => 'update']);
|
||||
Route::delete('{rule}', ['uses' => 'DestroyController@destroy', 'as' => 'delete']);
|
||||
|
||||
Route::post('validate-expression', ['uses' => 'ExpressionController@validateExpression', 'as' => 'validate']);
|
||||
|
||||
Route::get('{rule}/test', ['uses' => 'TriggerController@testRule', 'as' => 'test']);
|
||||
// TODO give results back
|
||||
Route::post('{rule}/trigger', ['uses' => 'TriggerController@triggerRule', 'as' => 'trigger']);
|
||||
|
Reference in New Issue
Block a user