diff --git a/app/Api/V1/Controllers/Models/Rule/ExpressionController.php b/app/Api/V1/Controllers/Models/Rule/ExpressionController.php new file mode 100644 index 0000000000..8019919161 --- /dev/null +++ b/app/Api/V1/Controllers/Models/Rule/ExpressionController.php @@ -0,0 +1,51 @@ +. + */ + +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, + ]); + } +} diff --git a/app/Api/V1/Requests/Models/Rule/StoreRequest.php b/app/Api/V1/Requests/Models/Rule/StoreRequest.php index dff5c76d8d..669604a99a 100644 --- a/app/Api/V1/Requests/Models/Rule/StoreRequest.php +++ b/app/Api/V1/Requests/Models/Rule/StoreRequest.php @@ -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()], diff --git a/app/Api/V1/Requests/Models/Rule/UpdateRequest.php b/app/Api/V1/Requests/Models/Rule/UpdateRequest.php index 3e89716d7f..2d7488b505 100644 --- a/app/Api/V1/Requests/Models/Rule/UpdateRequest.php +++ b/app/Api/V1/Requests/Models/Rule/UpdateRequest.php @@ -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()], diff --git a/app/Api/V1/Requests/Models/Rule/ValidateExpressionRequest.php b/app/Api/V1/Requests/Models/Rule/ValidateExpressionRequest.php new file mode 100644 index 0000000000..b0ef5a3998 --- /dev/null +++ b/app/Api/V1/Requests/Models/Rule/ValidateExpressionRequest.php @@ -0,0 +1,65 @@ +. + */ + +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) + ); + } +} diff --git a/app/Http/Requests/RuleFormRequest.php b/app/Http/Requests/RuleFormRequest.php index 6d6f831815..29c2352a56 100644 --- a/app/Http/Requests/RuleFormRequest.php +++ b/app/Http/Requests/RuleFormRequest.php @@ -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', ]; diff --git a/app/Models/RuleAction.php b/app/Models/RuleAction.php index f8f8e5fe12..a6f8d44c71 100644 --- a/app/Models/RuleAction.php +++ b/app/Models/RuleAction.php @@ -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); + } } diff --git a/app/Providers/FireflyServiceProvider.php b/app/Providers/FireflyServiceProvider.php index 3f304c33da..ff7a4f6d30 100644 --- a/app/Providers/FireflyServiceProvider.php +++ b/app/Providers/FireflyServiceProvider.php @@ -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) { diff --git a/app/Rules/IsValidActionExpression.php b/app/Rules/IsValidActionExpression.php new file mode 100644 index 0000000000..3022043fa7 --- /dev/null +++ b/app/Rules/IsValidActionExpression.php @@ -0,0 +1,52 @@ +. + */ + +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() + ]); + } + } +} diff --git a/app/TransactionRules/Actions/AddTag.php b/app/TransactionRules/Actions/AddTag.php index e9a0ed716d..3381c86277 100644 --- a/app/TransactionRules/Actions/AddTag.php +++ b/app/TransactionRules/Actions/AddTag.php @@ -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; } diff --git a/app/TransactionRules/Actions/AppendDescription.php b/app/TransactionRules/Actions/AppendDescription.php index 5b5b318ce0..42d6c58151 100644 --- a/app/TransactionRules/Actions/AppendDescription.php +++ b/app/TransactionRules/Actions/AppendDescription.php @@ -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 diff --git a/app/TransactionRules/Actions/AppendNotes.php b/app/TransactionRules/Actions/AppendNotes.php index bb990d438f..e584a48251 100644 --- a/app/TransactionRules/Actions/AppendNotes.php +++ b/app/TransactionRules/Actions/AppendNotes.php @@ -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; diff --git a/app/TransactionRules/Actions/ConvertToDeposit.php b/app/TransactionRules/Actions/ConvertToDeposit.php index e8f9abf59e..942df9f6a6 100644 --- a/app/TransactionRules/Actions/ConvertToDeposit.php +++ b/app/TransactionRules/Actions/ConvertToDeposit.php @@ -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') diff --git a/app/TransactionRules/Actions/ConvertToTransfer.php b/app/TransactionRules/Actions/ConvertToTransfer.php index 323270f35c..f2a0438f74 100644 --- a/app/TransactionRules/Actions/ConvertToTransfer.php +++ b/app/TransactionRules/Actions/ConvertToTransfer.php @@ -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; } diff --git a/app/TransactionRules/Actions/ConvertToWithdrawal.php b/app/TransactionRules/Actions/ConvertToWithdrawal.php index d7b9684826..79c18a0333 100644 --- a/app/TransactionRules/Actions/ConvertToWithdrawal.php +++ b/app/TransactionRules/Actions/ConvertToWithdrawal.php @@ -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') diff --git a/app/TransactionRules/Actions/LinkToBill.php b/app/TransactionRules/Actions/LinkToBill.php index f9e0406d34..a073c657e1 100644 --- a/app/TransactionRules/Actions/LinkToBill.php +++ b/app/TransactionRules/Actions/LinkToBill.php @@ -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']) { diff --git a/app/TransactionRules/Actions/PrependDescription.php b/app/TransactionRules/Actions/PrependDescription.php index f00db5d501..47fda2aab5 100644 --- a/app/TransactionRules/Actions/PrependDescription.php +++ b/app/TransactionRules/Actions/PrependDescription.php @@ -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 diff --git a/app/TransactionRules/Actions/PrependNotes.php b/app/TransactionRules/Actions/PrependNotes.php index 7bbfd70f71..31e455a38b 100644 --- a/app/TransactionRules/Actions/PrependNotes.php +++ b/app/TransactionRules/Actions/PrependNotes.php @@ -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(); diff --git a/app/TransactionRules/Actions/RemoveTag.php b/app/TransactionRules/Actions/RemoveTag.php index 55ef91531a..a717043ce2 100644 --- a/app/TransactionRules/Actions/RemoveTag.php +++ b/app/TransactionRules/Actions/RemoveTag.php @@ -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']) diff --git a/app/TransactionRules/Actions/SetBudget.php b/app/TransactionRules/Actions/SetBudget.php index 260ea63cb5..067dc93a49 100644 --- a/app/TransactionRules/Actions/SetBudget.php +++ b/app/TransactionRules/Actions/SetBudget.php @@ -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) { diff --git a/app/TransactionRules/Actions/SetCategory.php b/app/TransactionRules/Actions/SetCategory.php index 8785ade043..760f0e01d5 100644 --- a/app/TransactionRules/Actions/SetCategory.php +++ b/app/TransactionRules/Actions/SetCategory.php @@ -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'))); diff --git a/app/TransactionRules/Actions/SetDescription.php b/app/TransactionRules/Actions/SetDescription.php index 3d5e392014..f423806e5e 100644 --- a/app/TransactionRules/Actions/SetDescription.php +++ b/app/TransactionRules/Actions/SetDescription.php @@ -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; } diff --git a/app/TransactionRules/Actions/SetDestinationAccount.php b/app/TransactionRules/Actions/SetDestinationAccount.php index 455acb4dfd..4b8498de8d 100644 --- a/app/TransactionRules/Actions/SetDestinationAccount.php +++ b/app/TransactionRules/Actions/SetDestinationAccount.php @@ -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, diff --git a/app/TransactionRules/Actions/SetNotes.php b/app/TransactionRules/Actions/SetNotes.php index 260ccfe4d4..e8e3e92f2c 100644 --- a/app/TransactionRules/Actions/SetNotes.php +++ b/app/TransactionRules/Actions/SetNotes.php @@ -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; } diff --git a/app/TransactionRules/Actions/SetSourceAccount.php b/app/TransactionRules/Actions/SetSourceAccount.php index aafe75569b..9da778d2a6 100644 --- a/app/TransactionRules/Actions/SetSourceAccount.php +++ b/app/TransactionRules/Actions/SetSourceAccount.php @@ -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, diff --git a/app/TransactionRules/Actions/UpdatePiggybank.php b/app/TransactionRules/Actions/UpdatePiggybank.php index 4bd1d1de19..a978ed4946 100644 --- a/app/TransactionRules/Actions/UpdatePiggybank.php +++ b/app/TransactionRules/Actions/UpdatePiggybank.php @@ -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 diff --git a/app/TransactionRules/Expressions/ActionExpression.php b/app/TransactionRules/Expressions/ActionExpression.php new file mode 100644 index 0000000000..59b9770e14 --- /dev/null +++ b/app/TransactionRules/Expressions/ActionExpression.php @@ -0,0 +1,155 @@ +. + */ + +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); + } +} diff --git a/app/TransactionRules/Expressions/ActionExpressionLanguageProvider.php b/app/TransactionRules/Expressions/ActionExpressionLanguageProvider.php new file mode 100644 index 0000000000..2c54752b00 --- /dev/null +++ b/app/TransactionRules/Expressions/ActionExpressionLanguageProvider.php @@ -0,0 +1,39 @@ +. + */ + +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") + ]; + } +} diff --git a/app/Validation/FireflyValidator.php b/app/Validation/FireflyValidator.php index 891fcc6163..9a554d79b4 100644 --- a/app/Validation/FireflyValidator.php +++ b/app/Validation/FireflyValidator.php @@ -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 */ diff --git a/composer.json b/composer.json index 8aedc9f70f..5a8d4c7eee 100644 --- a/composer.json +++ b/composer.json @@ -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/", diff --git a/composer.lock b/composer.lock index 6e709a93d4..0c43ded69a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "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", diff --git a/resources/lang/en_US/validation.php b/resources/lang/en_US/validation.php index 5d2abd4939..362a3cc502 100644 --- a/resources/lang/en_US/validation.php +++ b/resources/lang/en_US/validation.php @@ -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".', diff --git a/routes/api.php b/routes/api.php index 0bf6c67c65..dfb7da5268 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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']);