From e9b6b45fc4461732927bb6b576dfa16c570a713f Mon Sep 17 00:00:00 2001 From: James Cole Date: Wed, 21 Feb 2018 08:51:30 +0100 Subject: [PATCH] Expand code to be able to handle updates. --- .../V1/Controllers/TransactionController.php | 46 ++- app/Api/V1/Requests/TransactionRequest.php | 29 +- app/Factory/TransactionJournalFactory.php | 1 - app/Factory/TransactionJournalMetaFactory.php | 15 +- .../Transaction/SingleController.php | 8 +- .../Internal/JournalUpdateService.php | 219 +++++++++++ .../Internal/TransactionUpdateService.php | 342 ++++++++++++++++++ .../Twig/Extension/TransactionJournal.php | 4 +- 8 files changed, 643 insertions(+), 21 deletions(-) create mode 100644 app/Services/Internal/JournalUpdateService.php create mode 100644 app/Services/Internal/TransactionUpdateService.php diff --git a/app/Api/V1/Controllers/TransactionController.php b/app/Api/V1/Controllers/TransactionController.php index c0ef38d5fe..4ffb7a1875 100644 --- a/app/Api/V1/Controllers/TransactionController.php +++ b/app/Api/V1/Controllers/TransactionController.php @@ -25,14 +25,15 @@ namespace FireflyIII\Api\V1\Controllers; use FireflyIII\Api\V1\Requests\TransactionRequest; use FireflyIII\Factory\TransactionJournalFactory; -use FireflyIII\Helpers\Collector\JournalCollector; use FireflyIII\Helpers\Collector\JournalCollectorInterface; use FireflyIII\Helpers\Filter\InternalTransferFilter; use FireflyIII\Helpers\Filter\NegativeAmountFilter; use FireflyIII\Helpers\Filter\PositiveAmountFilter; use FireflyIII\Models\Transaction; +use FireflyIII\Models\TransactionJournal; use FireflyIII\Models\TransactionType; use FireflyIII\Repositories\Journal\JournalRepositoryInterface; +use FireflyIII\Services\Internal\JournalUpdateService; use FireflyIII\Transformers\TransactionTransformer; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -169,7 +170,7 @@ class TransactionController extends Controller } $transactions = $collector->getJournals(); - $resource = new Item($transactions->first(), new TransactionTransformer($this->parameters), 'transactions'); + $resource = new Item($transactions->first(), new TransactionTransformer($this->parameters), 'transactions'); return response()->json($manager->createData($resource)->toArray())->header('Content-Type', 'application/vnd.api+json'); } @@ -222,21 +223,48 @@ class TransactionController extends Controller /** - * @param BillRequest $request - * @param Bill $bill + * @param TransactionRequest $request + * @param TransactionJournal $journal * * @return \Illuminate\Http\JsonResponse */ - public function update(BillRequest $request, Bill $bill) + public function update(TransactionRequest $request, Transaction $transaction) { - die('todo'); - $data = $request->getAll(); - $bill = $this->repository->update($bill, $data); + $data = $request->getAll(); + $data['user'] = auth()->user()->id; + + /** @var JournalUpdateService $service */ + $service = app(JournalUpdateService::class); + $service->setUser(auth()->user()); + $journal = $service->update($transaction->transactionJournal, $data); + $manager = new Manager(); $baseUrl = $request->getSchemeAndHttpHost() . '/api/v1'; $manager->setSerializer(new JsonApiSerializer($baseUrl)); - $resource = new Item($bill, new BillTransformer($this->parameters), 'bills'); + // add include parameter: + $include = $request->get('include') ?? ''; + $manager->parseIncludes($include); + + // needs a lot of extra data to match the journal collector. Or just expand that one. + // collect transactions using the journal collector + $collector = app(JournalCollectorInterface::class); + $collector->setUser(auth()->user()); + $collector->withOpposingAccount()->withCategoryInformation()->withBudgetInformation(); + // filter on specific journals. + $collector->setJournals(new Collection([$journal])); + + // add filter to remove transactions: + $transactionType = $journal->transactionType->type; + if ($transactionType === TransactionType::WITHDRAWAL) { + $collector->addFilter(PositiveAmountFilter::class); + } + if (!($transactionType === TransactionType::WITHDRAWAL)) { + $collector->addFilter(NegativeAmountFilter::class); + } + + $transactions = $collector->getJournals(); + $resource = new FractalCollection($transactions, new TransactionTransformer($this->parameters), 'transactions'); return response()->json($manager->createData($resource)->toArray())->header('Content-Type', 'application/vnd.api+json'); diff --git a/app/Api/V1/Requests/TransactionRequest.php b/app/Api/V1/Requests/TransactionRequest.php index e4d0ced399..374a15a801 100644 --- a/app/Api/V1/Requests/TransactionRequest.php +++ b/app/Api/V1/Requests/TransactionRequest.php @@ -26,6 +26,7 @@ namespace FireflyIII\Api\V1\Requests; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\Account; use FireflyIII\Models\AccountType; +use FireflyIII\Models\Transaction; use FireflyIII\Repositories\Account\AccountRepositoryInterface; use FireflyIII\Rules\BelongsUser; use Illuminate\Validation\Validator; @@ -106,7 +107,7 @@ class TransactionRequest extends Request */ public function rules(): array { - return [ + $rules = [ // basic fields for journal: 'type' => 'required|in:withdrawal,deposit,transfer', 'date' => 'required|date', @@ -146,6 +147,19 @@ class TransactionRequest extends Request 'transactions.*.destination_id' => ['numeric', 'nullable', new BelongsUser], 'transactions.*.destination_name' => 'between:1,255|nullable', ]; + + switch ($this->method()) { + default: + break; + case 'PUT': + case 'PATCH': + unset($rules['type'], $rules['piggy_bank_id'], $rules['piggy_bank_name']); + break; + } + + return $rules; + + } /** @@ -181,6 +195,7 @@ class TransactionRequest extends Request */ protected function assetAccountExists(Validator $validator, ?int $accountId, ?string $accountName, string $idField, string $nameField): void { + $accountId = intval($accountId); $accountName = strval($accountName); // both empty? hard exit. @@ -206,6 +221,7 @@ class TransactionRequest extends Request // 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')); @@ -369,11 +385,16 @@ class TransactionRequest extends Request { $data = $validator->getData(); $transactions = $data['transactions'] ?? []; - if(!isset($data['type'])) { - return; + if (!isset($data['type'])) { + // the journal may exist in the request: + /** @var Transaction $transaction */ + $transaction = $this->route()->parameter('transaction'); + if (is_null($transaction)) { + return; + } + $data['type'] = strtolower($transaction->transactionJournal->transactionType->type); } foreach ($transactions as $index => $transaction) { - $sourceId = isset($transaction['source_id']) ? intval($transaction['source_id']) : null; $sourceName = $transaction['source_name'] ?? null; $destinationId = isset($transaction['destination_id']) ? intval($transaction['destination_id']) : null; diff --git a/app/Factory/TransactionJournalFactory.php b/app/Factory/TransactionJournalFactory.php index d735a3c98e..aa55c29aa9 100644 --- a/app/Factory/TransactionJournalFactory.php +++ b/app/Factory/TransactionJournalFactory.php @@ -81,7 +81,6 @@ class TransactionJournalFactory // link piggy bank (if transfer) $this->connectPiggyBank($journal, $data); - // link tags: $this->connectTags($journal, $data); diff --git a/app/Factory/TransactionJournalMetaFactory.php b/app/Factory/TransactionJournalMetaFactory.php index af4814da39..a9c0ebcbed 100644 --- a/app/Factory/TransactionJournalMetaFactory.php +++ b/app/Factory/TransactionJournalMetaFactory.php @@ -34,16 +34,25 @@ class TransactionJournalMetaFactory /** * @param array $data * - * @return TransactionJournalMeta + * @return TransactionJournalMeta|null + * @throws \Exception */ - public function updateOrCreate(array $data): TransactionJournalMeta + public function updateOrCreate(array $data): ?TransactionJournalMeta { $value = $data['data']; + /** @var TransactionJournalMeta $entry */ + $entry = $data['journal']->transactionJournalMeta()->where('name', $data['name'])->first(); + if (is_null($value) && !is_null($entry)) { + $entry->delete(); + + return null; + } + if ($data['data'] instanceof Carbon) { $value = $data['data']->toW3cString(); } - $entry = $data['journal']->transactionJournalMeta()->where('name', $data['name'])->first(); + if (null === $entry) { $entry = new TransactionJournalMeta(); $entry->transactionJournal()->associate($data['journal']); diff --git a/app/Http/Controllers/Transaction/SingleController.php b/app/Http/Controllers/Transaction/SingleController.php index 4a7d2475d4..0ee52c19be 100644 --- a/app/Http/Controllers/Transaction/SingleController.php +++ b/app/Http/Controllers/Transaction/SingleController.php @@ -26,6 +26,7 @@ use Carbon\Carbon; use ExpandedForm; use FireflyIII\Events\StoredTransactionJournal; use FireflyIII\Events\UpdatedTransactionJournal; +use FireflyIII\Factory\TransactionJournalFactory; use FireflyIII\Helpers\Attachments\AttachmentHelperInterface; use FireflyIII\Http\Controllers\Controller; use FireflyIII\Http\Requests\JournalFormRequest; @@ -341,9 +342,10 @@ class SingleController extends Controller $data = $request->getJournalData(); // todo call factory instead of repository - - - $journal = $repository->store($data); + $factory = app(TransactionJournalFactory::class); + $factory->setUser(auth()->user()); + $journal = $repository->store($data); + //$journal = $repository->store($data); if (null === $journal->id) { // error! Log::error('Could not store transaction journal: ', $journal->getErrors()->toArray()); diff --git a/app/Services/Internal/JournalUpdateService.php b/app/Services/Internal/JournalUpdateService.php new file mode 100644 index 0000000000..9254da0af8 --- /dev/null +++ b/app/Services/Internal/JournalUpdateService.php @@ -0,0 +1,219 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Services\Internal; + +use FireflyIII\Factory\BillFactory; +use FireflyIII\Factory\TagFactory; +use FireflyIII\Factory\TransactionFactory; +use FireflyIII\Factory\TransactionJournalMetaFactory; +use FireflyIII\Models\Note; +use FireflyIII\Models\Transaction; +use FireflyIII\Models\TransactionJournal; +use FireflyIII\User; +use Illuminate\Support\Collection; + +/** + * Class to centralise code that updates a journal given the input by system. + * + * Class JournalUpdateService + */ +class JournalUpdateService +{ + /** @var User */ + private $user; + + /** + * @param User $user + */ + public function setUser(User $user): void + { + $this->user = $user; + } + + /** + * @param TransactionJournal $journal + * @param array $data + * + * @return TransactionJournal + * @throws \FireflyIII\Exceptions\FireflyException + */ + public function update(TransactionJournal $journal, array $data): TransactionJournal + { + // update journal: + $journal->description = $data['description']; + $journal->date = $data['date']; + $journal->save(); + + // update transactions: + /** @var TransactionUpdateService $service */ + $service = app(TransactionUpdateService::class); + $service->setUser($this->user); + + // create transactions + /** @var TransactionFactory $factory */ + $factory = app(TransactionFactory::class); + $factory->setUser($this->user); + + /** + * @var int $identifier + * @var array $trData + */ + foreach ($data['transactions'] as $identifier => $trData) { + // exists transaction(s) with this identifier? update! + /** @var Collection $existing */ + $existing = $journal->transactions()->where('identifier', $identifier)->get(); + if ($existing->count() > 0) { + $existing->each( + function (Transaction $transaction) use ($service, $trData) { + $service->update($transaction, $trData); + } + ); + continue; + } + // otherwise, create! + $factory->createPair($journal, $trData); + } + + // connect bill: + $this->connectBill($journal, $data); + + // connect tags: + $this->connectTags($journal, $data); + + // update or create custom fields: + // store date meta fields (if present): + $this->storeMeta($journal, $data, 'interest_date'); + $this->storeMeta($journal, $data, 'book_date'); + $this->storeMeta($journal, $data, 'process_date'); + $this->storeMeta($journal, $data, 'due_date'); + $this->storeMeta($journal, $data, 'payment_date'); + $this->storeMeta($journal, $data, 'invoice_date'); + $this->storeMeta($journal, $data, 'internal_reference'); + + // store note: + $this->storeNote($journal, $data['notes']); + + + return $journal; + } + + /** + * TODO seems duplicate of connectBill in JournalFactory. + * TODO this one is better than journal factory + * Connect bill if present. + * + * @param TransactionJournal $journal + * @param array $data + */ + protected function connectBill(TransactionJournal $journal, array $data): void + { + /** @var BillFactory $factory */ + $factory = app(BillFactory::class); + $factory->setUser($this->user); + $bill = $factory->find($data['bill_id'], $data['bill_name']); + + if (!is_null($bill)) { + $journal->bill_id = $bill->id; + $journal->save(); + + return; + } + $journal->bill_id = null; + $journal->save(); + + return; + } + + /** + * TODO seems duplicate or very equal to connectTags() in JournalFactory. + * + * @param TransactionJournal $journal + * @param array $data + */ + protected function connectTags(TransactionJournal $journal, array $data): void + { + /** @var TagFactory $factory */ + $factory = app(TagFactory::class); + $factory->setUser($journal->user); + $set = []; + foreach ($data['tags'] as $string) { + if (strlen($string) > 0) { + $tag = $factory->findOrCreate($string); + $set[] = $tag->id; + } + } + $journal->tags()->sync($set); + } + + /** + * TODO seems duplicate of storeMeta() in journalfactory. + * TODO this one is better than the one in journal factory (NULL)> + * + * @param TransactionJournal $journal + * @param array $data + * @param string $field + * + * @throws \Exception + */ + protected function storeMeta(TransactionJournal $journal, array $data, string $field): void + { + $set = [ + 'journal' => $journal, + 'name' => $field, + 'data' => $data[$field], + ]; + /** @var TransactionJournalMetaFactory $factory */ + $factory = app(TransactionJournalMetaFactory::class); + $factory->updateOrCreate($set); + } + + /** + * TODO is duplicate of storeNote in journal factory. + * + * @param TransactionJournal $journal + * @param string $notes + */ + protected function storeNote(TransactionJournal $journal, string $notes): void + { + if (strlen($notes) > 0) { + $note = $journal->notes()->first(); + if (is_null($note)) { + $note = new Note; + $note->noteable()->associate($journal); + } + $note->text = $notes; + $note->save(); + + return; + } + $note = $journal->notes()->first(); + if (!is_null($note)) { + $note->delete(); + } + + return; + + } + +} \ No newline at end of file diff --git a/app/Services/Internal/TransactionUpdateService.php b/app/Services/Internal/TransactionUpdateService.php new file mode 100644 index 0000000000..c175a8fa5c --- /dev/null +++ b/app/Services/Internal/TransactionUpdateService.php @@ -0,0 +1,342 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Services\Internal; + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Factory\AccountFactory; +use FireflyIII\Factory\BudgetFactory; +use FireflyIII\Factory\CategoryFactory; +use FireflyIII\Factory\TransactionCurrencyFactory; +use FireflyIII\Models\Account; +use FireflyIII\Models\AccountType; +use FireflyIII\Models\Budget; +use FireflyIII\Models\Category; +use FireflyIII\Models\Transaction; +use FireflyIII\Models\TransactionCurrency; +use FireflyIII\Models\TransactionJournal; +use FireflyIII\Models\TransactionType; +use FireflyIII\Repositories\Account\AccountRepositoryInterface; +use FireflyIII\User; + +/** + * Class TransactionUpdateService + */ +class TransactionUpdateService +{ + /** @var AccountRepositoryInterface */ + private $accountRepository; + /** @var User */ + private $user; + + public function __construct() + { + $this->accountRepository = app(AccountRepositoryInterface::class); + } + + /** + * @param User $user + */ + public function setUser(User $user): void + { + $this->user = $user; + $this->accountRepository->setUser($user); + } + + /** + * @param Transaction $transaction + * @param array $data + * + * @return Transaction + * @throws FireflyException + */ + public function update(Transaction $transaction, array $data): Transaction + { + $currency = $this->findCurrency($data['currency_id'], $data['currency_code']); + $journal = $transaction->transactionJournal; + $description = $journal->description === $data['description'] ? null : $data['description']; + + // update description: + $transaction->description = $description; + if (floatval($transaction->amount) < 0) { + // this is the source transaction. + $type = $this->accountType($journal, 'source'); + $account = $this->findAccount($type, $data['source_id'], $data['source_name']); + $amount = app('steam')->negative(strval($data['amount'])); + $foreignAmount = app('steam')->negative(strval($data['foreign_amount'])); + } + + if (floatval($transaction->amount) > 0) { + // this is the destination transaction. + $type = $this->accountType($journal, 'destination'); + $account = $this->findAccount($type, $data['destination_id'], $data['destination_name']); + $amount = app('steam')->positive(strval($data['amount'])); + $foreignAmount = app('steam')->positive(strval($data['foreign_amount'])); + } + + // update the actual transaction: + $transaction->description = $description; + $transaction->amount = $amount; + $transaction->foreign_amount = null; + $transaction->transaction_currency_id = $currency->id; + $transaction->account_id = $account->id; + $transaction->reconciled = $data['reconciled']; + $transaction->save(); + + // set foreign currency + $foreign = $this->findCurrency($data['foreign_currency_id'], $data['foreign_currency_code']); + // set foreign amount: + if (!is_null($data['foreign_amount'])) { + $this->setForeignCurrency($transaction, $foreign); + $this->setForeignAmount($transaction, $foreignAmount); + } + if (is_null($data['foreign_amount'])) { + $this->setForeignCurrency($transaction, null); + $this->setForeignAmount($transaction, null); + } + + // set budget: + $budget = $this->findBudget($data['budget_id'], $data['budget_name']); + $this->setBudget($transaction, $budget); + + // set category + $category = $this->findCategory($data['category_id'], $data['category_name']); + $this->setCategory($transaction, $category); + + return $transaction; + } + + /** + * TODO this method is duplicated + * + * @param TransactionJournal $journal + * @param string $direction + * + * @return string + * @throws FireflyException + */ + protected function accountType(TransactionJournal $journal, string $direction): string + { + $types = []; + $type = $journal->transactionType->type; + switch ($type) { + default: + throw new FireflyException(sprintf('Cannot handle type "%s" in accountType()', $type)); + case TransactionType::WITHDRAWAL: + $types['source'] = AccountType::ASSET; + $types['destination'] = AccountType::EXPENSE; + break; + case TransactionType::DEPOSIT: + $types['source'] = AccountType::REVENUE; + $types['destination'] = AccountType::ASSET; + break; + case TransactionType::TRANSFER: + $types['source'] = AccountType::ASSET; + $types['destination'] = AccountType::ASSET; + break; + } + if (!isset($types[$direction])) { + throw new FireflyException(sprintf('No type set for direction "%s" and type "%s"', $type, $direction)); + } + + return $types[$direction]; + } + + /** + * TODO this method is duplicated. + * + * @param string $expectedType + * @param int|null $accountId + * @param string|null $accountName + * + * @return Account + * @throws FireflyException + */ + protected function findAccount(string $expectedType, ?int $accountId, ?string $accountName): Account + { + $accountId = intval($accountId); + $accountName = strval($accountName); + + switch ($expectedType) { + case AccountType::ASSET: + if ($accountId > 0) { + // must be able to find it based on ID. Validator should catch invalid ID's. + return $this->accountRepository->findNull($accountId); + } + + // alternatively, return by name. Validator should catch invalid names. + return $this->accountRepository->findByName($accountName, [AccountType::ASSET]); + break; + case AccountType::EXPENSE: + if ($accountId > 0) { + // must be able to find it based on ID. Validator should catch invalid ID's. + return $this->accountRepository->findNull($accountId); + } + if (strlen($accountName) > 0) { + /** @var AccountFactory $factory */ + $factory = app(AccountFactory::class); + $factory->setUser($this->user); + + return $factory->findOrCreate($accountName, AccountType::EXPENSE); + } + + // return cash account: + return $this->accountRepository->getCashAccount(); + break; + case AccountType::REVENUE: + if ($accountId > 0) { + // must be able to find it based on ID. Validator should catch invalid ID's. + return $this->accountRepository->findNull($accountId); + } + if (strlen($accountName) > 0) { + // alternatively, return by name. + /** @var AccountFactory $factory */ + $factory = app(AccountFactory::class); + $factory->setUser($this->user); + + return $factory->findOrCreate($accountName, AccountType::REVENUE); + } + + // return cash account: + return $this->accountRepository->getCashAccount(); + + default: + throw new FireflyException(sprintf('Cannot find account of type "%s".', $expectedType)); + + } + } + + /** + * TODO method is duplicated + * + * @param int|null $budgetId + * @param null|string $budgetName + * + * @return Budget|null + */ + protected function findBudget(?int $budgetId, ?string $budgetName): ?Budget + { + /** @var BudgetFactory $factory */ + $factory = app(BudgetFactory::class); + $factory->setUser($this->user); + + return $factory->find($budgetId, $budgetName); + } + + /** + * TODO method is duplicated + * + * @param int|null $categoryId + * @param null|string $categoryName + * + * @return Category|null + */ + protected function findCategory(?int $categoryId, ?string $categoryName): ?Category + { + /** @var CategoryFactory $factory */ + $factory = app(CategoryFactory::class); + $factory->setUser($this->user); + + return $factory->findOrCreate($categoryId, $categoryName); + } + + /** + * TODO method is duplicated + * + * @param int|null $currencyId + * @param null|string $currencyCode + * + * @return TransactionCurrency|null + */ + protected function findCurrency(?int $currencyId, ?string $currencyCode): ?TransactionCurrency + { + $factory = app(TransactionCurrencyFactory::class); + + return $factory->find($currencyId, $currencyCode); + } + + /** + * TODO almost the same as in transaction factory. + * + * @param Transaction $transaction + * @param Budget|null $budget + */ + protected function setBudget(Transaction $transaction, ?Budget $budget): void + { + if (is_null($budget)) { + return; + } + $transaction->budgets()->sync([$budget->id]); + + return; + } + + /** + * TODO almost the same as in transaction factory. + * + * @param Transaction $transaction + * @param Category|null $category + */ + protected function setCategory(Transaction $transaction, ?Category $category): void + { + if (is_null($category)) { + return; + } + $transaction->categories()->sync([$category->id]); + + return; + } + + /** + * TODO method is duplicated + * + * @param Transaction $transaction + * @param string|null $amount + */ + protected function setForeignAmount(Transaction $transaction, ?string $amount): void + { + $transaction->foreign_amount = $amount; + $transaction->save(); + } + + /** + * TODO method is duplicated + * + * @param Transaction $transaction + * @param TransactionCurrency|null $currency + */ + protected function setForeignCurrency(Transaction $transaction, ?TransactionCurrency $currency): void + { + if (is_null($currency)) { + $transaction->foreign_currency_id = null; + $transaction->save(); + return; + } + $transaction->foreign_currency_id = $currency->id; + $transaction->save(); + + return; + } + + +} \ No newline at end of file diff --git a/app/Support/Twig/Extension/TransactionJournal.php b/app/Support/Twig/Extension/TransactionJournal.php index dba951d952..925b1613f0 100644 --- a/app/Support/Twig/Extension/TransactionJournal.php +++ b/app/Support/Twig/Extension/TransactionJournal.php @@ -64,7 +64,9 @@ class TransactionJournal extends Twig_Extension 'currency' => $foreign, ]; } - $totals[$foreignId]['amount'] = bcadd($transaction->foreign_amount, $totals[$foreignId]['amount']); + $totals[$foreignId]['amount'] = bcadd( + $transaction->foreign_amount, + $totals[$foreignId]['amount']); } } $array = [];