diff --git a/app/Api/V1/Requests/TransactionRequest.php b/app/Api/V1/Requests/TransactionRequest.php new file mode 100644 index 0000000000..d7dad26783 --- /dev/null +++ b/app/Api/V1/Requests/TransactionRequest.php @@ -0,0 +1,418 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Api\V1\Requests; + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\Account; +use FireflyIII\Models\AccountType; +use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\Rules\BelongsUser; +use Illuminate\Validation\Validator; + + +/** + * Class TransactionRequest + */ +class TransactionRequest extends Request +{ + /** + * @return bool + */ + public function authorize(): bool + { + // Only allow authenticated users + return auth()->check(); + } + + /** + * @return array + */ + public function getAll(): array + { + $data = [ + // basic fields for journal: + 'type' => $this->string('type'), + 'date' => $this->date('date'), + 'description' => $this->string('description'), + 'piggy_bank_id' => $this->integer('piggy_bank_id'), + 'piggy_bank_name' => $this->string('piggy_bank_name'), + 'bill_id' => $this->integer('bill_id'), + 'bill_name' => $this->string('bill_name'), + + // then, custom fields for journal + 'interest_date' => $this->date('interest_date'), + 'book_date' => $this->date('book_date'), + 'process_date' => $this->date('process_date'), + 'due_date' => $this->date('due_date'), + 'payment_date' => $this->date('payment_date'), + 'invoice_date' => $this->date('invoice_date'), + 'internal_reference' => $this->string('internal_reference'), + 'notes' => $this->string('notes'), + + // then, transactions (see below). + 'transactions' => [], + + ]; + foreach ($this->get('transactions') as $transaction) { + $array = [ + 'description' => $transaction['description'] ?? null, + 'amount' => $transaction['amount'], + 'currency_id' => isset($transaction['currency_id']) ? intval($transaction['currency_id']) : null, + 'currency_code' => isset($transaction['currency_code']) ? $transaction['currency_code'] : null, + 'foreign_amount' => $transaction['foreign_amount'] ?? null, + 'foreign_currency_id' => isset($transaction['foreign_currency_id']) ? intval($transaction['foreign_currency_id']) : null, + 'foreign_currency_code' => $transaction['foreign_currency_code'] ?? null, + 'budget_id' => isset($transaction['budget_id']) ? intval($transaction['budget_id']) : null, + 'budget_name' => $transaction['budget_name'] ?? null, + 'category_id' => isset($transaction['category_id']) ? intval($transaction['category_id']) : null, + 'category_name' => $transaction['category_name'] ?? null, + 'source_account_id' => isset($transaction['source_account_id']) ? intval($transaction['source_account_id']) : null, + 'source_account_name' => $transaction['source_account_name'] ?? null, + 'destination_account_id' => isset($transaction['destination_account_id']) ? intval($transaction['destination_account_id']) : null, + 'destination_account_name' => $transaction['destination_account_name'] ?? null, + 'reconciled' => intval($transaction['reconciled'] ?? 0) === 1 ? true : false, + 'identifier' => isset($transaction['identifier']) ? intval($transaction['identifier']) : 0, + ]; + $data['transactions'][] = $array; + } + + return $data; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + // basic fields for journal: + 'type' => 'required|in:withdrawal,deposit,transfer', + 'date' => 'required|date', + 'description' => 'between:1,255', + 'piggy_bank_id' => ['numeric', 'nullable', 'mustExist:piggy_banks,id', new BelongsUser], + 'piggy_bank_name' => ['between:1,255', 'nullable', new BelongsUser], + 'bill_id' => ['numeric', 'nullable', 'mustExist:bills,id', new BelongsUser], + 'bill_name' => ['between:1,255', 'nullable', new BelongsUser], + + // then, custom fields for journal + 'interest_date' => 'date|nullable', + 'book_date' => 'date|nullable', + 'process_date' => 'date|nullable', + 'due_date' => 'date|nullable', + 'payment_date' => 'date|nullable', + 'invoice_date' => 'date|nullable', + 'internal_reference' => 'min:1,max:255|nullable', + 'notes' => 'min:1,max:50000|nullable', + + // transaction rules (in array for splits): + 'transactions.*.description' => 'nullable|between:1,255', + 'transactions.*.amount' => 'required|numeric|more:0', + 'transactions.*.currency_id' => 'numeric|exists:transaction_currencies,id|required_without:transactions.*.currency_code', + 'transactions.*.currency_code' => 'min:3|max:3|exists:transaction_currencies,code|required_without:transactions.*.currency_id', + 'transactions.*.foreign_amount' => 'numeric|more:0', + 'transactions.*.foreign_currency_id' => 'numeric|exists:transaction_currencies,id', + 'transactions.*.foreign_currency_code' => 'min:3|max:3|exists:transaction_currencies,code', + 'transactions.*.budget_id' => ['mustExist:budgets,id', new BelongsUser], + 'transactions.*.budget_name' => ['between:1,255', 'nullable', new BelongsUser], + 'transactions.*.category_id' => ['mustExist:categories,id', new BelongsUser], + 'transactions.*.category_name' => 'between:1,255|nullable', + 'transactions.*.reconciled' => 'boolean|nullable', + 'transactions.*.identifier' => 'numeric|nullable', + // basic rules will be expanded later. + 'transactions.*.source_account_id' => ['numeric', 'nullable', new BelongsUser], + 'transactions.*.source_account_name' => 'between:1,255|nullable', + 'transactions.*.destination_account_id' => ['numeric', 'nullable', new BelongsUser], + 'transactions.*.destination_account_name' => 'between:1,255|nullable', + + // todo tags + + ]; + } + + /** + * Configure the validator instance. + * + * @param Validator $validator + * + * @return void + */ + public function withValidator(Validator $validator): void + { + $validator->after( + function (Validator $validator) { + $this->atLeastOneTransaction($validator); + $this->checkValidDescriptions($validator); + $this->equalToJournalDescription($validator); + $this->emptySplitDescriptions($validator); + $this->foreignCurrencyInformation($validator); + $this->validateAccountInformation($validator); + } + ); + } + + /** + * Throws an error when this asset account is invalid. + * + * @param Validator $validator + * @param int|null $accountId + * @param null|string $accountName + * @param string $idField + * @param string $nameField + */ + protected function assetAccountExists(Validator $validator, ?int $accountId, ?string $accountName, string $idField, string $nameField): void + { + $accountId = intval($accountId); + $accountName = strval($accountName); + // both empty? hard exit. + if ($accountId < 1 && strlen($accountName) === 0) { + $validator->errors()->add($idField, trans('validation.filled', ['attribute' => $idField])); + + return; + } + // ID belongs to user and is asset account: + /** @var AccountRepositoryInterface $repository */ + $repository = app(AccountRepositoryInterface::class); + $repository->setUser(auth()->user()); + $set = $repository->getAccountsById([$accountId]); + if ($set->count() === 1) { + /** @var Account $first */ + $first = $set->first(); + if ($first->accountType->type !== AccountType::ASSET) { + $validator->errors()->add($idField, trans('validation.belongs_user')); + + return; + } + + // we ignore the account name at this point. + return; + } + $account = $repository->findByName($accountName, [AccountType::ASSET]); + if (is_null($account)) { + $validator->errors()->add($nameField, trans('validation.belongs_user')); + } + + return; + } + + /** + * Adds an error to the validator when there are no transactions in the array of data. + * + * @param Validator $validator + */ + protected function atLeastOneTransaction(Validator $validator): void + { + $data = $validator->getData(); + $transactions = $data['transactions'] ?? []; + // need at least one transaction + if (count($transactions) === 0) { + $validator->errors()->add('description', trans('validation.at_least_one_transaction')); + } + } + + /** + * Adds an error to the "description" field when the user has submitted no descriptions and no + * journal description. + * + * @param Validator $validator + */ + protected function checkValidDescriptions(Validator $validator) + { + $data = $validator->getData(); + $transactions = $data['transactions'] ?? []; + $journalDescription = strval($data['description'] ?? ''); + $validDescriptions = 0; + foreach ($transactions as $index => $transaction) { + if (strlen(strval($transaction['description'] ?? '')) > 0) { + $validDescriptions++; + } + } + + // no valid descriptions and empty journal description? error. + if ($validDescriptions === 0 && strlen($journalDescription) === 0) { + $validator->errors()->add('description', trans('validation.filled', ['attribute' => trans('validation.attributes.description')])); + } + + } + + /** + * Adds an error to the validator when the user submits a split transaction (more than 1 transactions) + * but does not give them a description. + * + * @param Validator $validator + */ + protected function emptySplitDescriptions(Validator $validator): void + { + $data = $validator->getData(); + $transactions = $data['transactions'] ?? []; + foreach ($transactions as $index => $transaction) { + $description = strval($transaction['description'] ?? ''); + // filled description is mandatory for split transactions. + if (count($transactions) > 1 && strlen($description) === 0) { + $validator->errors()->add( + 'transactions.' . $index . '.description', + trans('validation.filled', ['attribute' => trans('validation.attributes.transaction_description')]) + ); + } + } + } + + /** + * Adds an error to the validator when any transaction descriptions are equal to the journal description. + * + * @param Validator $validator + */ + protected function equalToJournalDescription(Validator $validator): void + { + $data = $validator->getData(); + $transactions = $data['transactions'] ?? []; + $journalDescription = strval($data['description'] ?? ''); + foreach ($transactions as $index => $transaction) { + $description = strval($transaction['description'] ?? ''); + // description cannot be equal to journal description. + if ($description === $journalDescription) { + $validator->errors()->add('transactions.' . $index . '.description', trans('validation.equal_description')); + } + } + } + + /** + * If the transactions contain foreign amounts, there must also be foreign currency information. + * + * @param Validator $validator + */ + protected function foreignCurrencyInformation(Validator $validator): void + { + $data = $validator->getData(); + $transactions = $data['transactions'] ?? []; + foreach ($transactions as $index => $transaction) { + // must have currency info. + if (isset($transaction['foreign_amount']) + && !(isset($transaction['foreign_currency_id']) + || isset($transaction['foreign_currency_code']))) { + $validator->errors()->add( + 'transactions.' . $index . '.foreign_amount', + trans('validation.require_currency_info') + ); + } + } + } + + /** + * Throws an error when the given opping account (of type $type) is invalid. + * Empty data is allowed, system will default to cash. + * + * @param Validator $validator + * @param string $type + * @param int|null $accountId + * @param null|string $accountName + * @param string $idField + * @param string $nameField + */ + protected function opposingAccountExists(Validator $validator, string $type, ?int $accountId, ?string $accountName, string $idField, string $nameField + ): void { + $accountId = intval($accountId); + $accountName = strval($accountName); + // both empty? done! + if ($accountId < 1 && strlen($accountName) === 0) { + return; + } + // ID belongs to user and is $type account: + /** @var AccountRepositoryInterface $repository */ + $repository = app(AccountRepositoryInterface::class); + $repository->setUser(auth()->user()); + $set = $repository->getAccountsById([$accountId]); + if ($set->count() === 1) { + /** @var Account $first */ + $first = $set->first(); + if ($first->accountType->type !== $type) { + $validator->errors()->add($idField, trans('validation.belongs_user')); + + return; + } + + // we ignore the account name at this point. + return; + } + $account = $repository->findByName($accountName, [$type]); + if (is_null($account)) { + $validator->errors()->add($nameField, trans('validation.belongs_user')); + } + + return; + } + + /** + * Validates the given account information. Switches on given transaction type. + * + * @param Validator $validator + * + * @throws FireflyException + */ + protected function validateAccountInformation(Validator $validator): void + { + $data = $validator->getData(); + $transactions = $data['transactions'] ?? []; + foreach ($transactions as $index => $transaction) { + + $sourceId = isset($transaction['source_account_id']) ? intval($transaction['source_account_id']) : null; + $sourceName = $transaction['source_account_name'] ?? null; + $destinationId = isset($transaction['destination_account_id']) ? intval($transaction['destination_account_id']) : null; + $destinationName = $transaction['destination_account_name'] ?? null; + + switch ($data['type']) { + case 'withdrawal': + $idField = 'transactions.' . $index . '.source_account_id'; + $nameField = 'transactions.' . $index . '.source_account_name'; + $this->assetAccountExists($validator, $sourceId, $sourceName, $idField, $nameField); + + $idField = 'transactions.' . $index . '.destination_account_id'; + $nameField = 'transactions.' . $index . '.destination_account_name'; + $this->opposingAccountExists($validator, AccountType::EXPENSE, $destinationId, $destinationName, $idField, $nameField); + break; + case 'deposit': + $idField = 'transactions.' . $index . '.source_account_id'; + $nameField = 'transactions.' . $index . '.source_account_name'; + $this->opposingAccountExists($validator, AccountType::REVENUE, $sourceId, $sourceName, $idField, $nameField); + + $idField = 'transactions.' . $index . '.destination_account_id'; + $nameField = 'transactions.' . $index . '.destination_account_name'; + $this->assetAccountExists($validator, $destinationId, $destinationName, $idField, $nameField); + break; + case 'transfer': + $idField = 'transactions.' . $index . '.source_account_id'; + $nameField = 'transactions.' . $index . '.source_account_name'; + $this->assetAccountExists($validator, $sourceId, $sourceName, $idField, $nameField); + + $idField = 'transactions.' . $index . '.destination_account_id'; + $nameField = 'transactions.' . $index . '.destination_account_name'; + $this->assetAccountExists($validator, $destinationId, $destinationName, $idField, $nameField); + break; + default: + throw new FireflyException(sprintf('The validator cannot handle transaction type "%s".', $data['type'])); + + } + } + } + +} \ No newline at end of file