User can submit new journal through API.

This commit is contained in:
James Cole
2019-03-31 13:36:49 +02:00
parent c07ef3658b
commit b692cccdfb
30 changed files with 1461 additions and 711 deletions

View File

@@ -31,10 +31,10 @@ use FireflyIII\Models\AccountType;
use FireflyIII\Models\Transaction;
use FireflyIII\Models\TransactionCurrency;
use FireflyIII\Models\TransactionJournal;
use FireflyIII\Models\TransactionType;
use FireflyIII\Repositories\Account\AccountRepositoryInterface;
use FireflyIII\Support\NullArrayObject;
use FireflyIII\User;
use FireflyIII\Validation\AccountValidator;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Log;
@@ -46,6 +46,8 @@ class TransactionFactory
{
/** @var AccountRepositoryInterface */
private $accountRepository;
/** @var AccountValidator */
private $accountValidator;
/** @var TransactionJournal */
private $journal;
/** @var User */
@@ -60,6 +62,7 @@ class TransactionFactory
Log::warning(sprintf('%s should not be instantiated in the TEST environment!', \get_class($this)));
}
$this->accountRepository = app(AccountRepositoryInterface::class);
$this->accountValidator = app(AccountValidator::class);
}
/**
@@ -110,16 +113,17 @@ class TransactionFactory
*/
public function createPair(NullArrayObject $data, TransactionCurrency $currency, ?TransactionCurrency $foreignCurrency): Collection
{
$sourceAccount = $this->getAccount('source', $data['source'], (int)$data['source_id'], $data['source_name']);
$destinationAccount = $this->getAccount('destination', $data['destination'], (int)$data['destination_id'], $data['destination_name']);
$amount = $this->getAmount($data['amount']);
$foreignAmount = $this->getForeignAmount($data['foreign_amount']);
// validate source and destination using a new Validator.
$this->validateAccounts($data);
$this->makeDramaOverAccountTypes($sourceAccount, $destinationAccount);
// create or get source and destination accounts:
$sourceAccount = $this->getAccount('source', (int)$data['source_id'], $data['source_name']);
$destinationAccount = $this->getAccount('destination', (int)$data['destination_id'], $data['destination_name']);
$one = $this->create($sourceAccount, $currency, app('steam')->negative($amount));
$two = $this->create($destinationAccount, $currency, app('steam')->positive($amount));
$amount = $this->getAmount($data['amount']);
$foreignAmount = $this->getForeignAmount($data['foreign_amount']);
$one = $this->create($sourceAccount, $currency, app('steam')->negative($amount));
$two = $this->create($destinationAccount, $currency, app('steam')->positive($amount));
$one->reconciled = $data['reconciled'] ?? false;
$two->reconciled = $data['reconciled'] ?? false;
@@ -128,8 +132,8 @@ class TransactionFactory
if (null !== $foreignCurrency) {
$one->foreign_currency_id = $foreignCurrency->id;
$two->foreign_currency_id = $foreignCurrency->id;
$one->foreign_amount = $foreignAmount;
$two->foreign_amount = $foreignAmount;
$one->foreign_amount = app('steam')->negative($foreignAmount);
$two->foreign_amount = app('steam')->positive($foreignAmount);
}
@@ -141,18 +145,21 @@ class TransactionFactory
}
/**
* @param string $direction
* @param Account|null $source
* @param int|null $sourceId
* @param string|null $sourceName
* @param string $direction
* @param int|null $accountId
* @param string|null $accountName
*
* @return Account
* @throws FireflyException
*/
public function getAccount(string $direction, ?Account $source, ?int $sourceId, ?string $sourceName): Account
public function getAccount(string $direction, ?int $accountId, ?string $accountName): Account
{
Log::debug(sprintf('Now in getAccount(%s)', $direction));
Log::debug(sprintf('Parameters: ((account), %s, %s)', var_export($sourceId, true), var_export($sourceName, true)));
// some debug logging:
Log::debug(sprintf('Now in getAccount(%s, %d, %s)', $direction, $accountId, $accountName));
// final result:
$result = null;
// expected type of source account, in order of preference
/** @var array $array */
$array = config('firefly.expected_source_types');
@@ -161,64 +168,62 @@ class TransactionFactory
// and now try to find it, based on the type of transaction.
$transactionType = $this->journal->transactionType->type;
Log::debug(
sprintf(
'Based on the fact that the transaction is a %s, the %s account should be in: %s', $transactionType, $direction,
implode(', ', $expectedTypes[$transactionType])
)
);
$message = 'Based on the fact that the transaction is a %s, the %s account should be in: %s';
Log::debug(sprintf($message, $transactionType, $direction, implode(', ', $expectedTypes[$transactionType])));
// first attempt, check the "source" object.
if (null !== $source && $source->user_id === $this->user->id && \in_array($source->accountType->type, $expectedTypes[$transactionType], true)) {
Log::debug(sprintf('Found "account" object for %s: #%d, %s', $direction, $source->id, $source->name));
return $source;
}
// second attempt, find by ID.
if (null !== $sourceId) {
$source = $this->accountRepository->findNull($sourceId);
if (null !== $source && \in_array($source->accountType->type, $expectedTypes[$transactionType], true)) {
// first attempt, find by ID.
if (null !== $accountId) {
$search = $this->accountRepository->findNull($accountId);
if (null !== $search && in_array($search->accountType->type, $expectedTypes[$transactionType], true)) {
Log::debug(
sprintf('Found "account_id" object for %s: #%d, "%s" of type %s', $direction, $source->id, $source->name, $source->accountType->type)
sprintf('Found "account_id" object for %s: #%d, "%s" of type %s', $direction, $search->id, $search->name, $search->accountType->type)
);
return $source;
$result = $search;
}
}
// third attempt, find by name.
if (null !== $sourceName) {
// second attempt, find by name.
if (null === $result && null !== $accountName) {
Log::debug('Found nothing by account ID.');
// find by preferred type.
$source = $this->accountRepository->findByName($sourceName, [$expectedTypes[$transactionType][0]]);
// or any type.
$source = $source ?? $this->accountRepository->findByName($sourceName, $expectedTypes[$transactionType]);
$source = $this->accountRepository->findByName($accountName, [$expectedTypes[$transactionType][0]]);
// or any expected type.
$source = $source ?? $this->accountRepository->findByName($accountName, $expectedTypes[$transactionType]);
if (null !== $source) {
Log::debug(sprintf('Found "account_name" object for %s: #%d, %s', $direction, $source->id, $source->name));
return $source;
$result = $source;
}
}
if (null === $sourceName && \in_array(AccountType::CASH, $expectedTypes[$transactionType], true)) {
return $this->accountRepository->getCashAccount();
}
$sourceName = $sourceName ?? '(no name)';
// final attempt, create it.
$preferredType = $expectedTypes[$transactionType][0];
if (AccountType::ASSET === $preferredType) {
throw new FireflyException(sprintf('TransactionFactory: Cannot create asset account with ID #%d or name "%s".', $sourceId, $sourceName));
// return cash account.
if (null === $result && null === $accountName
&& in_array(AccountType::CASH, $expectedTypes[$transactionType], true)) {
$result = $this->accountRepository->getCashAccount();
}
return $this->accountRepository->store(
[
'account_type_id' => null,
'accountType' => $preferredType,
'name' => $sourceName,
'active' => true,
'iban' => null,
]
);
// return new account.
if (null === $result) {
$accountName = $accountName ?? '(no name)';
// final attempt, create it.
$preferredType = $expectedTypes[$transactionType][0];
if (AccountType::ASSET === $preferredType) {
throw new FireflyException(sprintf('TransactionFactory: Cannot create asset account with ID #%d or name "%s".', $accountId, $accountName));
}
$result = $this->accountRepository->store(
[
'account_type_id' => null,
'accountType' => $preferredType,
'name' => $accountName,
'active' => true,
'iban' => null,
]
);
}
return $result;
}
/**
@@ -246,6 +251,7 @@ class TransactionFactory
*/
public function getForeignAmount(?string $amount): ?string
{
$result = null;
if (null === $amount) {
Log::debug('No foreign amount info in array. Return NULL');
@@ -266,31 +272,6 @@ class TransactionFactory
return $amount;
}
/**
* This method will throw a Firefly III Exception of the source and destination account types are not OK.
*
* @throws FireflyException
*
* @param Account $source
* @param Account $destination
*/
public function makeDramaOverAccountTypes(Account $source, Account $destination): void
{
// if the source is X, then Y is allowed as destination.
$combinations = config('firefly.source_dests');
$sourceType = $source->accountType->type;
$destType = $destination->accountType->type;
$journalType = $this->journal->transactionType->type;
$allowed = $combinations[$journalType][$sourceType] ?? [];
if (!\in_array($destType, $allowed, true)) {
throw new FireflyException(
sprintf(
'Journal of type "%s" has a source account of type "%s" and cannot accept a "%s"-account as destination, but only accounts of: %s', $journalType, $sourceType,
$destType, implode(', ', $combinations[$journalType][$sourceType])
)
);
}
}
/**
* @param TransactionJournal $journal
@@ -308,4 +289,33 @@ class TransactionFactory
$this->user = $user;
$this->accountRepository->setUser($user);
}
/**
* @param NullArrayObject $data
*
* @throws FireflyException
*/
private function validateAccounts(NullArrayObject $data): void
{
$transactionType = $data['type'] ?? 'invalid';
$this->accountValidator->setTransactionType($transactionType);
// validate source account.
$sourceId = isset($data['source_id']) ? (int)$data['source_id'] : null;
$sourceName = $data['source_name'] ?? null;
$validSource = $this->accountValidator->validateSource($sourceId, $sourceName);
// do something with result:
if (false === $validSource) {
throw new FireflyException($this->accountValidator->sourceError);
}
// validate destination account
$destinationId = isset($data['destination_id']) ? (int)$data['destination_id'] : null;
$destinationName = $data['destination_name'] ?? null;
$validDestination = $this->accountValidator->validateDestination($destinationId, $destinationName);
// do something with result:
if (false === $validDestination) {
throw new FireflyException($this->accountValidator->destError);
}
}
}

View File

@@ -0,0 +1,84 @@
<?php
/**
* TransactionGroupFactory.php
* Copyright (c) 2019 thegrumpydictator@gmail.com
*
* This file is part of Firefly III.
*
* Firefly III is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Firefly III 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Firefly III. If not, see <http://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace FireflyIII\Factory;
use Exception;
use FireflyIII\Exceptions\FireflyException;
use FireflyIII\Models\TransactionGroup;
use FireflyIII\Models\TransactionJournal;
use FireflyIII\User;
/**
* Class TransactionGroupFactory
*/
class TransactionGroupFactory
{
/** @var TransactionJournalFactory */
private $journalFactory;
/** @var User The user */
private $user;
/**
* TransactionGroupFactory constructor.
*/
public function __construct()
{
$this->journalFactory = app(TransactionJournalFactory::class);
}
/**
* Store a new transaction journal.
*
* @param array $data
*
* @return TransactionGroup
* @throws FireflyException
*/
public function create(array $data): TransactionGroup
{
$this->journalFactory->setUser($this->user);
$collection = $this->journalFactory->create($data);
$title = $data['group_title'] ?? null;
/** @var TransactionJournal $first */
$first = $collection->first();
$group = new TransactionGroup;
$group->user()->associate($first->user);
$group->title = $title ?? $first->description;
$group->save();
$group->transactionJournals()->saveMany($collection);
return $group;
}
/**
* Set the user.
*
* @param User $user
*/
public function setUser(User $user): void
{
$this->user = $user;
}
}

View File

@@ -26,9 +26,9 @@ namespace FireflyIII\Factory;
use Carbon\Carbon;
use Exception;
use FireflyIII\Exceptions\FireflyException;
use FireflyIII\Models\Note;
use FireflyIII\Models\TransactionCurrency;
use FireflyIII\Models\TransactionGroup;
use FireflyIII\Models\TransactionJournal;
use FireflyIII\Models\TransactionType;
use FireflyIII\Repositories\Bill\BillRepositoryInterface;
@@ -77,14 +77,25 @@ class TransactionJournalFactory
*/
public function __construct()
{
$this->fields = ['sepa-cc', 'sepa-ct-op', 'sepa-ct-id', 'sepa-db', 'sepa-country', 'sepa-ep', 'sepa-ci', 'interest_date', 'book_date', 'process_date',
'due_date', 'recurrence_id', 'payment_date', 'invoice_date', 'internal_reference', 'bunq_payment_id', 'importHash', 'importHashV2',
'external_id', 'sepa-batch-id', 'original-source'];
$this->fields = [
// sepa
'sepa_cc', 'sepa_ct_op', 'sepa_ct_id',
'sepa_db', 'sepa_country', 'sepa_ep',
'sepa_ci', 'sepa_batch_id',
// dates
'interest_date', 'book_date', 'process_date',
'due_date', 'payment_date', 'invoice_date',
// others
'recurrence_id', 'internal_reference', 'bunq_payment_id',
'import_hash', 'import_hash_v2', 'external_id', 'original_source'];
if ('testing' === config('app.env')) {
Log::warning(sprintf('%s should not be instantiated in the TEST environment!', \get_class($this)));
}
$this->currencyRepository = app(CurrencyRepositoryInterface::class);
$this->typeRepository = app(TransactionTypeRepositoryInterface::class);
$this->transactionFactory = app(TransactionFactory::class);
@@ -97,26 +108,22 @@ class TransactionJournalFactory
}
/**
* Store a new transaction journal.
* Store a new (set of) transaction journals.
*
* @param array $data
*
* @return TransactionGroup
* @throws Exception
* @return Collection
* @throws FireflyException
*/
public function create(array $data): TransactionGroup
public function create(array $data): Collection
{
// convert to special object.
$data = new NullArrayObject($data);
Log::debug('Start of TransactionJournalFactory::create()');
$collection = new Collection;
$transactions = $data['transactions'] ?? [];
$type = $this->typeRepository->findTransactionType(null, $data['type']);
$carbon = $data['date'] ?? new Carbon;
$carbon->setTimezone(config('app.timezone'));
Log::debug(sprintf('Going to store a %s.', $type->type));
if (0 === \count($transactions)) {
if (0 === count($transactions)) {
Log::error('There are no transactions in the array, the TransactionJournalFactory cannot continue.');
return new Collection;
@@ -124,79 +131,15 @@ class TransactionJournalFactory
/** @var array $row */
foreach ($transactions as $index => $row) {
$transaction = new NullArrayObject($row);
Log::debug(sprintf('Now creating journal %d/%d', $index + 1, \count($transactions)));
/** Get basic fields */
Log::debug(sprintf('Now creating journal %d/%d', $index + 1, count($transactions)));
$currency = $this->currencyRepository->findCurrency(
$transaction['currency'], (int)$transaction['currency_id'], $transaction['currency_code']
);
$foreignCurrency = $this->findForeignCurrency($transaction);
$bill = $this->billRepository->findBill($transaction['bill'], (int)$transaction['bill_id'], $transaction['bill_name']);
$billId = TransactionType::WITHDRAWAL === $type->type && null !== $bill ? $bill->id : null;
$description = app('steam')->cleanString((string)$transaction['description']);
/** Create a basic journal. */
$journal = TransactionJournal::create(
[
'user_id' => $this->user->id,
'transaction_type_id' => $type->id,
'bill_id' => $billId,
'transaction_currency_id' => $currency->id,
'description' => '' === $description ? '(empty description)' : $description,
'date' => $carbon->format('Y-m-d H:i:s'),
'order' => 0,
'tag_count' => 0,
'completed' => 0,
]
);
Log::debug(sprintf('Created new journal #%d: "%s"', $journal->id, $journal->description));
/** Create two transactions. */
$this->transactionFactory->setJournal($journal);
$this->transactionFactory->createPair($transaction, $currency, $foreignCurrency);
// verify that journal has two transactions. Otherwise, delete and cancel.
$count = $journal->transactions()->count();
if (2 !== $count) {
// @codeCoverageIgnoreStart
Log::error(sprintf('The journal unexpectedly has %d transaction(s). This is not OK. Cancel operation.', $count));
$journal->delete();
return new Collection;
// @codeCoverageIgnoreEnd
$journal = $this->createJournal(new NullArrayObject($row));
if (null !== $journal) {
$collection->push($journal);
}
$journal->completed =true;
$journal->save();
/** Link all other data to the journal. */
/** Link budget */
$this->storeBudget($journal, $transaction);
/** Link category */
$this->storeCategory($journal, $transaction);
/** Set notes */
$this->storeNote($journal, $transaction['notes']);
/** Set piggy bank */
$this->storePiggyEvent($journal, $transaction);
/** Set tags */
$this->storeTags($journal, $transaction['tags']);
/** set all meta fields */
$this->storeMetaFields($journal, $transaction);
$collection->push($journal);
}
$group = $this->storeGroup($collection, $data['group_title']);
return $group;
return $collection;
}
/**
@@ -215,31 +158,6 @@ class TransactionJournalFactory
$this->piggyRepository->setUser($this->user);
}
/**
* Join multiple journals in a group.
*
* @param Collection $collection
* @param string|null $title
*
* @return TransactionGroup|null
*/
public function storeGroup(Collection $collection, ?string $title): ?TransactionGroup
{
if ($collection->count() < 2) {
return null; // @codeCoverageIgnore
}
/** @var TransactionJournal $first */
$first = $collection->first();
$group = new TransactionGroup;
$group->user()->associate($first->user);
$group->title = $title ?? $first->description;
$group->save();
$group->transactionJournals()->saveMany($collection);
return $group;
}
/**
* Link a piggy bank to this journal.
*
@@ -332,6 +250,88 @@ class TransactionJournalFactory
}
}
/**
* @param NullArrayObject $row
*
* @return TransactionJournal|null
* @throws FireflyException
*/
private function createJournal(NullArrayObject $row): ?TransactionJournal
{
$row['import_hash_v2'] = $this->hashArray($row);
/** Get basic fields */
$type = $this->typeRepository->findTransactionType(null, $row['type']);
$carbon = $row['date'] ?? new Carbon;
$currency = $this->currencyRepository->findCurrency((int)$row['currency_id'], $row['currency_code']);
$foreignCurrency = $this->findForeignCurrency($row);
$bill = $this->billRepository->findBill((int)$row['bill_id'], $row['bill_name']);
$billId = TransactionType::WITHDRAWAL === $type->type && null !== $bill ? $bill->id : null;
$description = app('steam')->cleanString((string)$row['description']);
/** Manipulate basic fields */
$carbon->setTimezone(config('app.timezone'));
/** Create a basic journal. */
$journal = TransactionJournal::create(
[
'user_id' => $this->user->id,
'transaction_type_id' => $type->id,
'bill_id' => $billId,
'transaction_currency_id' => $currency->id,
'description' => '' === $description ? '(empty description)' : $description,
'date' => $carbon->format('Y-m-d H:i:s'),
'order' => 0,
'tag_count' => 0,
'completed' => 0,
]
);
Log::debug(sprintf('Created new journal #%d: "%s"', $journal->id, $journal->description));
/** Create two transactions. */
$this->transactionFactory->setJournal($journal);
$this->transactionFactory->createPair($row, $currency, $foreignCurrency);
// verify that journal has two transactions. Otherwise, delete and cancel.
$count = $journal->transactions()->count();
if (2 !== $count) {
// @codeCoverageIgnoreStart
Log::error(sprintf('The journal unexpectedly has %d transaction(s). This is not OK. Cancel operation.', $count));
try {
$journal->delete();
} catch (Exception $e) {
Log::debug(sprintf('Dont care: %s.', $e->getMessage()));
}
return null;
// @codeCoverageIgnoreEnd
}
$journal->completed = true;
$journal->save();
/** Link all other data to the journal. */
/** Link budget */
$this->storeBudget($journal, $row);
/** Link category */
$this->storeCategory($journal, $row);
/** Set notes */
$this->storeNote($journal, $row['notes']);
/** Set piggy bank */
$this->storePiggyEvent($journal, $row);
/** Set tags */
$this->storeTags($journal, $row['tags']);
/** set all meta fields */
$this->storeMetaFields($journal, $row);
return $journal;
}
/**
* This is a separate function because "findCurrency" will default to EUR and that may not be what we want.
*
@@ -345,17 +345,38 @@ class TransactionJournalFactory
return null;
}
return $this->currencyRepository->findCurrency(
$transaction['foreign_currency'], (int)$transaction['foreign_currency_id'], $transaction['foreign_currency_code']
);
return $this->currencyRepository->findCurrency((int)$transaction['foreign_currency_id'], $transaction['foreign_currency_code']);
}
/**
* @param NullArrayObject $row
*
* @return string
*/
private function hashArray(NullArrayObject $row): string
{
$row['import_hash_v2'] = null;
$row['original_source'] = null;
$json = json_encode($row);
if (false === $json) {
$json = json_encode((string)microtime());
}
$hash = hash('sha256', $json);
Log::debug(sprintf('The hash is: %s', $hash));
return $hash;
}
/**
* @param TransactionJournal $journal
* @param NullArrayObject $data
*/
private function storeBudget(TransactionJournal $journal, NullArrayObject $data): void
{
if (TransactionType::WITHDRAWAL !== $journal->transactionType->type) {
return;
}
$budget = $this->budgetRepository->findBudget($data['budget'], $data['budget_id'], $data['budget_name']);
if (null !== $budget) {
Log::debug(sprintf('Link budget #%d to journal #%d', $budget->id, $journal->id));