diff --git a/app/Api/V1/Requests/Models/Recurrence/UpdateRequest.php b/app/Api/V1/Requests/Models/Recurrence/UpdateRequest.php index fcd7e21900..71198089f2 100644 --- a/app/Api/V1/Requests/Models/Recurrence/UpdateRequest.php +++ b/app/Api/V1/Requests/Models/Recurrence/UpdateRequest.php @@ -141,6 +141,11 @@ class UpdateRequest extends FormRequest function (Validator $validator) { //$this->validateOneRecurrenceTransaction($validator); //$this->validateOneRepetitionUpdate($validator); + + + /** @var Recurrence $recurrence */ + $recurrence = $this->route()->parameter('recurrence'); + $this->validateTransactionId($recurrence, $validator); $this->validateRecurrenceRepetition($validator); $this->validateRepetitionMoment($validator); $this->validateForeignCurrencyInformation($validator); @@ -212,4 +217,5 @@ class UpdateRequest extends FormRequest return $return; } + } diff --git a/app/Services/Internal/Support/RecurringTransactionTrait.php b/app/Services/Internal/Support/RecurringTransactionTrait.php index 7ffb5922f1..576517da3c 100644 --- a/app/Services/Internal/Support/RecurringTransactionTrait.php +++ b/app/Services/Internal/Support/RecurringTransactionTrait.php @@ -352,8 +352,6 @@ trait RecurringTransactionTrait /** * @param RecurrenceTransaction $transaction * @param int $categoryId - * - * @throws FireflyException */ private function setCategory(RecurrenceTransaction $transaction, int $categoryId): void { diff --git a/app/Services/Internal/Update/RecurrenceUpdateService.php b/app/Services/Internal/Update/RecurrenceUpdateService.php index 9ee14e9156..b8f65fce75 100644 --- a/app/Services/Internal/Update/RecurrenceUpdateService.php +++ b/app/Services/Internal/Update/RecurrenceUpdateService.php @@ -107,13 +107,22 @@ class RecurrenceUpdateService // update all transactions (and associated meta-data) if (array_key_exists('transactions', $data)) { $this->updateTransactions($recurrence, $data['transactions'] ?? []); - // $this->deleteTransactions($recurrence); - // $this->createTransactions($recurrence, $data['transactions'] ?? []); } return $recurrence; } + /** + * @param Recurrence $recurrence + * @param int $transactionId + * @return void + */ + private function deleteTransaction(Recurrence $recurrence, int $transactionId): void + { + Log::debug(sprintf('Will delete transaction #%d in recurrence #%d.', $transactionId, $recurrence->id)); + $recurrence->recurrenceTransactions()->where('id', $transactionId)->delete(); + } + /** * @param Recurrence $recurrence * @param array $data @@ -146,42 +155,6 @@ class RecurrenceUpdateService return $query->first(); } - /** - * @param Recurrence $recurrence - * @param array $data - * - * @return RecurrenceTransaction|null - */ - private function matchTransaction(Recurrence $recurrence, array $data): ?RecurrenceTransaction - { - Log::debug('Now in matchTransaction()'); - $originalCount = $recurrence->recurrenceTransactions()->count(); - if (1 === $originalCount) { - Log::debug('Return the first one.'); - /** @var RecurrenceTransaction|null */ - return $recurrence->recurrenceTransactions()->first(); - } - // find it based on data - $fields = [ - 'id' => 'id', - 'currency_id' => 'transaction_currency_id', - 'foreign_currency_id' => 'foreign_currency_id', - 'source_id' => 'source_id', - 'destination_id' => 'destination_id', - 'amount' => 'amount', - 'foreign_amount' => 'foreign_amount', - 'description' => 'description', - ]; - $query = $recurrence->recurrenceTransactions(); - foreach ($fields as $field => $column) { - if (array_key_exists($field, $data)) { - $query->where($column, $data[$field]); - } - } - /** @var RecurrenceTransaction|null */ - return $query->first(); - } - /** * @param Recurrence $recurrence * @param string $text @@ -202,6 +175,87 @@ class RecurrenceUpdateService $dbNote?->delete(); } + /** + * @param Recurrence $recurrence + * @param array $combination + * @return void + */ + private function updateCombination(Recurrence $recurrence, array $combination): void + { + $original = $combination['original']; + $submitted = $combination['submitted']; + /** @var RecurrenceTransaction $transaction */ + $transaction = $recurrence->recurrenceTransactions()->find($original['id']); + Log::debug(sprintf('Now in updateCombination(#%d)', $original['id'])); + + $currencyFactory = app(TransactionCurrencyFactory::class); + + // loop all and try to match them: + $currency = null; + $foreignCurrency = null; + if (array_key_exists('currency_id', $submitted) || array_key_exists('currency_code', $submitted)) { + $currency = $currencyFactory->find($submitted['currency_id'] ?? null, $currency['currency_code'] ?? null); + } + if (null === $currency) { + unset($submitted['currency_id'], $submitted['currency_code']); + } + if (null !== $currency) { + $submitted['currency_id'] = (int)$currency->id; + } + if (array_key_exists('foreign_currency_id', $submitted) || array_key_exists('foreign_currency_code', $submitted)) { + $foreignCurrency = $currencyFactory->find($submitted['foreign_currency_id'] ?? null, $currency['foreign_currency_code'] ?? null); + } + if (null === $foreignCurrency) { + unset($submitted['foreign_currency_id'], $currency['foreign_currency_code']); + } + if (null !== $foreignCurrency) { + $submitted['foreign_currency_id'] = (int)$foreignCurrency->id; + } + + // update fields that are part of the recurring transaction itself. + $fields = [ + 'source_id' => 'source_id', + 'destination_id' => 'destination_id', + 'amount' => 'amount', + 'foreign_amount' => 'foreign_amount', + 'description' => 'description', + 'currency_id' => 'transaction_currency_id', + 'foreign_currency_id' => 'foreign_currency_id', + ]; + foreach ($fields as $field => $column) { + if (array_key_exists($field, $submitted)) { + $transaction->$column = $submitted[$field]; + $transaction->save(); + } + } + // update meta data + if (array_key_exists('budget_id', $submitted)) { + $this->setBudget($transaction, (int)$submitted['budget_id']); + } + if (array_key_exists('bill_id', $submitted)) { + $this->setBill($transaction, (int)$submitted['bill_id']); + } + // reset category if name is set but empty: + // can be removed when v1 is retired. + if (array_key_exists('category_name', $submitted) && '' === (string)$submitted['category_name']) { + Log::debug('Category name is submitted but is empty. Set category to be empty.'); + $submitted['category_name'] = null; + $submitted['category_id'] = 0; + } + + if (array_key_exists('category_id', $submitted)) { + Log::debug(sprintf('Category ID is submitted, set category to be %d.', (int)$submitted['category_id'])); + $this->setCategory($transaction, (int)$submitted['category_id']); + } + + if (array_key_exists('tags', $submitted) && is_array($submitted['tags'])) { + $this->updateTags($transaction, $submitted['tags']); + } + if (array_key_exists('piggy_bank_id', $submitted)) { + $this->updatePiggyBank($transaction, (int)$submitted['piggy_bank_id']); + } + } + /** * * @param Recurrence $recurrence @@ -213,7 +267,7 @@ class RecurrenceUpdateService { $originalCount = $recurrence->recurrenceRepetitions()->count(); if (0 === count($repetitions)) { - // wont drop repetition, rather avoid. + // won't drop repetition, rather avoid. return; } // user added or removed repetitions, delete all and recreate: @@ -251,98 +305,60 @@ class RecurrenceUpdateService * * @param Recurrence $recurrence * @param array $transactions - * - * @throws FireflyException - * @throws JsonException */ private function updateTransactions(Recurrence $recurrence, array $transactions): void { Log::debug('Now in updateTransactions()'); $originalCount = $recurrence->recurrenceTransactions()->count(); + Log::debug(sprintf('Original count is %d', $originalCount)); if (0 === count($transactions)) { // won't drop transactions, rather avoid. + Log::warning('No transactions to update, too scared to continue!'); return; } - // user added or removed repetitions, delete all and recreate: - if ($originalCount !== count($transactions)) { - Log::debug('Delete existing transactions and create new ones.'); - $this->deleteTransactions($recurrence); - $this->createTransactions($recurrence, $transactions); - - return; - } - $currencyFactory = app(TransactionCurrencyFactory::class); - // loop all and try to match them: - Log::debug(sprintf('Count is equal (%d), update transactions.', $originalCount)); - foreach ($transactions as $current) { - $match = $this->matchTransaction($recurrence, $current); - if (null === $match) { - throw new FireflyException('Cannot match recurring transaction to existing transaction. Not sure what to do. Break.'); - } - // complex loop to find currency: - $currency = null; - $foreignCurrency = null; - if (array_key_exists('currency_id', $current) || array_key_exists('currency_code', $current)) { - $currency = $currencyFactory->find($current['currency_id'] ?? null, $currency['currency_code'] ?? null); - } - if (null === $currency) { - unset($current['currency_id'], $current['currency_code']); - } - if (null !== $currency) { - $current['currency_id'] = (int)$currency->id; - } - if (array_key_exists('foreign_currency_id', $current) || array_key_exists('foreign_currency_code', $current)) { - $foreignCurrency = $currencyFactory->find($current['foreign_currency_id'] ?? null, $currency['foreign_currency_code'] ?? null); - } - if (null === $foreignCurrency) { - unset($current['foreign_currency_id'], $currency['foreign_currency_code']); - } - if (null !== $foreignCurrency) { - $current['foreign_currency_id'] = (int)$foreignCurrency->id; - } - - // update fields that are part of the recurring transaction itself. - $fields = [ - 'source_id' => 'source_id', - 'destination_id' => 'destination_id', - 'amount' => 'amount', - 'foreign_amount' => 'foreign_amount', - 'description' => 'description', - 'currency_id' => 'transaction_currency_id', - 'foreign_currency_id' => 'foreign_currency_id', - ]; - foreach ($fields as $field => $column) { - if (array_key_exists($field, $current)) { - $match->$column = $current[$field]; - $match->save(); + $combinations = []; + $originalTransactions = $recurrence->recurrenceTransactions()->get()->toArray(); + /** + * First, make sure to loop all existing transactions and match them to a counterpart in the submitted transactions array. + */ + foreach ($originalTransactions as $i => $originalTransaction) { + foreach ($transactions as $ii => $submittedTransaction) { + if (array_key_exists('id', $submittedTransaction) && (int)$originalTransaction['id'] === (int)$submittedTransaction['id']) { + Log::debug(sprintf('Match original transaction #%d with an entry in the submitted array.', $originalTransaction['id'])); + $combinations[] = [ + 'original' => $originalTransaction, + 'submitted' => $submittedTransaction, + ]; + unset($originalTransactions[$i]); + unset($transactions[$ii]); } } - // update meta data - if (array_key_exists('budget_id', $current)) { - $this->setBudget($match, (int)$current['budget_id']); - } - if (array_key_exists('bill_id', $current)) { - $this->setBill($match, (int)$current['bill_id']); - } - // reset category if name is set but empty: - // can be removed when v1 is retired. - if (array_key_exists('category_name', $current) && '' === (string)$current['category_name']) { - Log::debug('Category name is submitted but is empty. Set category to be empty.'); - $current['category_name'] = null; - $current['category_id'] = 0; - } - - if (array_key_exists('category_id', $current)) { - Log::debug(sprintf('Category ID is submitted, set category to be %d.', (int)$current['category_id'])); - $this->setCategory($match, (int)$current['category_id']); - } - - if (array_key_exists('tags', $current) && is_array($current['tags'])) { - $this->updateTags($match, $current['tags']); - } - if (array_key_exists('piggy_bank_id', $current)) { - $this->updatePiggyBank($match, (int)$current['piggy_bank_id']); + } + /** + * If one left of both we can match those as well and presto. + */ + if (1 === count($originalTransactions) && 1 === count($transactions)) { + $first = array_shift($originalTransactions); + Log::debug(sprintf('One left of each, link them (ID is #%d)', $first['id'])); + $combinations[] = [ + 'original' => $first, + 'submitted' => array_shift($transactions), + ]; + unset($first); + } + // if they are both empty, we can safely loop all combinations and update them. + if (0 === count($originalTransactions) && 0 === count($transactions)) { + foreach ($combinations as $combination) { + $this->updateCombination($recurrence, $combination); } } + // anything left in the original transactions array can be deleted. + foreach ($originalTransactions as $original) { + Log::debug(sprintf('Original transaction #%d is unmatched, delete it!', $original['id'])); + $this->deleteTransaction($recurrence, (int)$original['id']); + } + // anything left is new. + $this->createTransactions($recurrence, $transactions); } } +} diff --git a/app/Support/Request/GetRecurrenceData.php b/app/Support/Request/GetRecurrenceData.php index 6fc9a09e46..417f927b0e 100644 --- a/app/Support/Request/GetRecurrenceData.php +++ b/app/Support/Request/GetRecurrenceData.php @@ -37,6 +37,10 @@ trait GetRecurrenceData { $return = []; + if (array_key_exists('id', $transaction)) { + $return['id'] = (string)$transaction['id']; + } + // amount + currency if (array_key_exists('amount', $transaction)) { $return['amount'] = $transaction['amount']; diff --git a/app/Transformers/RecurrenceTransformer.php b/app/Transformers/RecurrenceTransformer.php index 3bb6d87a87..b5f6069ded 100644 --- a/app/Transformers/RecurrenceTransformer.php +++ b/app/Transformers/RecurrenceTransformer.php @@ -271,6 +271,7 @@ class RecurrenceTransformer extends AbstractTransformer $foreignAmount = app('steam')->bcround($transaction->foreign_amount, $foreignCurrencyDp); } $transactionArray = [ + 'id' => (string)$transaction->id, 'currency_id' => (string)$transaction->transaction_currency_id, 'currency_code' => $transaction->transactionCurrency->code, 'currency_symbol' => $transaction->transactionCurrency->symbol, diff --git a/app/Validation/RecurrenceValidation.php b/app/Validation/RecurrenceValidation.php index 45696fcd65..08e7af8982 100644 --- a/app/Validation/RecurrenceValidation.php +++ b/app/Validation/RecurrenceValidation.php @@ -290,6 +290,105 @@ trait RecurrenceValidation } } + /** + * @param Validator $validator + * @return void + */ + protected function validateTransactionId(Recurrence $recurrence, Validator $validator): void + { + Log::debug('Now in validateTransactionId'); + $transactions = $this->getTransactionData(); + $submittedTrCount = count($transactions); + + //$recurrence = $validator->get + if (null === $transactions) { + Log::warning('[a] User submitted no transactions.'); + $validator->errors()->add('transactions', (string)trans('validation.at_least_one_transaction')); + return; + } + if (0 === $submittedTrCount) { + Log::warning('[b] User submitted no transactions.'); + $validator->errors()->add('transactions', (string)trans('validation.at_least_one_transaction')); + return; + } + $originalTrCount = $recurrence->recurrenceTransactions()->count(); + if (1 === $submittedTrCount && 1 === $originalTrCount) { + $first = $transactions[0]; // can safely assume index 0. + if (!array_key_exists('id', $first)) { + Log::debug('Single count and no ID, done.'); + return; // home safe! + } + $id = $first['id']; + if ('' === (string)$id) { + Log::debug('Single count and empty ID, done.'); + return; // home safe! + } + $integer = (int)$id; + $secondCount = $recurrence->recurrenceTransactions()->where('recurrences_transactions.id', $integer)->count(); + Log::debug(sprintf('Result of ID count: %d', $secondCount)); + if (0 === $secondCount) { + $validator->errors()->add('transactions.0.id', (string)trans('validation.id_does_not_match', ['id' => $integer])); + } + Log::debug('Single ID validation done.'); + return; + } + + Log::debug('Multi ID validation.'); + $idsMandatory = false; + if ($submittedTrCount < $originalTrCount) { + Log::debug(sprintf('User submits %d transaction, recurrence has %d transactions. All entries must have ID.', $submittedTrCount, $originalTrCount)); + $idsMandatory = true; + } + /** + * Loop all transactions submitted by the user. + * If the user has submitted fewer transactions than the original recurrence has, all submitted entries must have an ID. + * Any ID's missing will be deleted later on. + * + * If the user submits more or the same number of transactions (n), the following rules apply: + * + * 1. Any 1 transaction does not need to have an ID. Since the other n-1 can be matched, the last one can be assumed. + * 2. If the user submits more transactions than already present, count the number of existing transactions. At least those must be matched. After that, submit as many as you like. + * 3. If the user submits the same number of transactions as already present, all but one must have an ID. + */ + $unmatchedIds = 0; + + foreach ($transactions as $index => $transaction) { + Log::debug(sprintf('Now at %d/%d', $index + 1, $submittedTrCount)); + if (!is_array($transaction)) { + Log::warning('Not an array. Give error.'); + $validator->errors()->add(sprintf('transactions.%d.id', $index), (string)trans('validation.at_least_one_transaction')); + return; + } + if (!array_key_exists('id', $transaction) && $idsMandatory) { + Log::warning('ID is mandatory but array has no ID.'); + $validator->errors()->add(sprintf('transactions.%d.id', $index), (string)trans('validation.need_id_to_match')); + return; + } + if (array_key_exists('id', $transaction)) { // don't matter if $idsMandatory + Log::debug('Array has ID.'); + $idCount = $recurrence->recurrenceTransactions()->where('recurrences_transactions.id', (int)$transaction['id'])->count(); + if (0 === $idCount) { + Log::debug('ID does not exist or no match. Count another unmatched ID.'); + $unmatchedIds++; + } + } + if (!array_key_exists('id', $transaction) && !$idsMandatory) { + Log::debug('Array has no ID but was not mandatory at this point.'); + $unmatchedIds++; + } + } + // if too many don't match, but you haven't submitted more than already present: + $maxUnmatched = max(1, $submittedTrCount - $originalTrCount); + Log::debug(sprintf('Submitted: %d. Original: %d. User can submit %d unmatched transactions.', $submittedTrCount, $originalTrCount, $maxUnmatched)); + if ($unmatchedIds > $maxUnmatched) { + Log::warning(sprintf('Too many unmatched transactions (%d).', $unmatchedIds)); + $validator->errors()->add('transactions.0.id', (string)trans('validation.too_many_unmatched')); + return; + } + Log::debug('Done with ID validation.'); + } + + /** * If the repetition type is weekly, the moment should be a day between 1-7 (inclusive). * diff --git a/resources/lang/en_US/validation.php b/resources/lang/en_US/validation.php index d2b91ef4a4..7d2bba33da 100644 --- a/resources/lang/en_US/validation.php +++ b/resources/lang/en_US/validation.php @@ -51,6 +51,10 @@ return [ 'invalid_selection' => 'Your selection is invalid.', 'belongs_user' => 'This value is invalid for this field.', 'at_least_one_transaction' => 'Need at least one transaction.', + 'recurring_transaction_id' => 'Need at least one transaction.', + 'need_id_to_match' => 'You need to submit this entry with an ID for the API to be able to match it.', + 'too_many_unmatched' => 'Too many submitted transactions cannot be matched to their respective database entries. Make sure existing entries have a valid ID.', + 'id_does_not_match' => 'Submitted ID #:id does not match expected ID. Make sure it matches or omit the field.', 'at_least_one_repetition' => 'Need at least one repetition.', 'require_repeat_until' => 'Require either a number of repetitions, or an end date (repeat_until). Not both.', 'require_currency_info' => 'The content of this field is invalid without currency information.',