From 1cf91c78f820cebd7d4c85fd927404884eeb5a56 Mon Sep 17 00:00:00 2001 From: James Cole Date: Sat, 16 Jun 2018 21:47:51 +0200 Subject: [PATCH] Lots of new code for recurring transactions. #1469 --- app/Factory/RecurrenceFactory.php | 124 ++++++++++++ app/Factory/TransactionJournalFactory.php | 24 +-- .../Recurring/CreateController.php | 26 ++- .../Controllers/Recurring/IndexController.php | 48 ++++- .../Transaction/SingleController.php | 8 +- app/Http/Requests/RecurrenceFormRequest.php | 189 ++++++++++++++++++ app/Http/Requests/Request.php | 10 + app/Models/Recurrence.php | 7 +- app/Models/RecurrenceTransaction.php | 7 +- .../Recurring/RecurringRepository.php | 185 +++++++++++++++-- .../RecurringRepositoryInterface.php | 25 ++- app/Rules/ValidRecurrenceRepetitionType.php | 72 +++++++ .../Internal/Support/TransactionTypeTrait.php | 57 ++++++ public/js/ff/recurring/create.js | 35 ++-- resources/lang/en_US/firefly.php | 5 +- resources/lang/en_US/validation.php | 1 + resources/views/recurring/create.twig | 5 +- resources/views/recurring/index.twig | 11 +- resources/views/recurring/show.twig | 9 +- 19 files changed, 769 insertions(+), 79 deletions(-) create mode 100644 app/Factory/RecurrenceFactory.php create mode 100644 app/Http/Requests/RecurrenceFormRequest.php create mode 100644 app/Rules/ValidRecurrenceRepetitionType.php create mode 100644 app/Services/Internal/Support/TransactionTypeTrait.php diff --git a/app/Factory/RecurrenceFactory.php b/app/Factory/RecurrenceFactory.php new file mode 100644 index 0000000000..cb34751236 --- /dev/null +++ b/app/Factory/RecurrenceFactory.php @@ -0,0 +1,124 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Factory; + + +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\AccountType; +use FireflyIII\Models\Recurrence; +use FireflyIII\Models\RecurrenceTransaction; +use FireflyIII\Models\TransactionType; +use FireflyIII\Services\Internal\Support\TransactionServiceTrait; +use FireflyIII\Services\Internal\Support\TransactionTypeTrait; +use FireflyIII\User; + +/** + * Class RecurrenceFactory + */ +class RecurrenceFactory +{ + use TransactionTypeTrait, TransactionServiceTrait; + + /** @var User */ + private $user; + + /** + * @param array $data + * + * @throws FireflyException + * @return Recurrence + */ + public function create(array $data): Recurrence + { + echo '
';
+        print_r($data);
+        echo '
'; + $type = $this->findTransactionType(ucfirst($data['recurrence']['type'])); + $recurrence = new Recurrence( + [ + 'user_id' => $this->user->id, + 'transaction_type_id' => $type->id, + 'title' => $data['recurrence']['title'], + 'description' => $data['recurrence']['description'], + 'first_date' => $data['recurrence']['first_date']->format('Y-m-d'), + 'repeat_until' => $data['recurrence']['repeat_until'], + 'latest_date' => null, + 'repetitions' => $data['recurrence']['repetitions'], + 'apply_rules' => $data['recurrence']['apply_rules'], + 'active' => $data['recurrence']['active'], + ] + ); + $recurrence->save(); + var_dump($recurrence->toArray()); + + // create transactions + foreach ($data['transactions'] as $trArray) { + $source = null; + $destination = null; + // search source account, depends on type + switch ($type->type) { + default: + throw new FireflyException(sprintf('Cannot create "%s".', $type->type)); + case TransactionType::WITHDRAWAL: + $source = $this->findAccount(AccountType::ASSET, $trArray['source_account_id'], null); + $destination = $this->findAccount(AccountType::EXPENSE, null, $trArray['destination_account_name']); + break; + } + + // search destination account + + $transaction = new RecurrenceTransaction( + [ + 'recurrence_id' => $recurrence->id, + 'transaction_currency_id' => $trArray['transaction_currency_id'], + 'foreign_currency_id' => '' === (string)$trArray['foreign_amount'] ? null : $trArray['foreign_currency_id'], + 'source_account_id' => $source->id, + 'destination_account_id' => $destination->id, + 'amount' => $trArray['amount'], + 'foreign_amount' => '' === (string)$trArray['foreign_amount'] ? null : (string)$trArray['foreign_amount'], + 'description' => $trArray['description'], + ] + ); + $transaction->save(); + var_dump($transaction->toArray()); + } + + // create meta data: + if(\count($data['meta']['tags']) > 0) { + // todo store tags + } + + exit; + + } + + /** + * @param User $user + */ + public function setUser(User $user): void + { + $this->user = $user; + } + +} \ No newline at end of file diff --git a/app/Factory/TransactionJournalFactory.php b/app/Factory/TransactionJournalFactory.php index d7c169e2e0..afdb26eb21 100644 --- a/app/Factory/TransactionJournalFactory.php +++ b/app/Factory/TransactionJournalFactory.php @@ -28,6 +28,7 @@ use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\TransactionJournal; use FireflyIII\Models\TransactionType; use FireflyIII\Services\Internal\Support\JournalServiceTrait; +use FireflyIII\Services\Internal\Support\TransactionTypeTrait; use FireflyIII\User; use Log; @@ -36,7 +37,7 @@ use Log; */ class TransactionJournalFactory { - use JournalServiceTrait; + use JournalServiceTrait, TransactionTypeTrait; /** @var User */ private $user; @@ -137,25 +138,4 @@ class TransactionJournalFactory } } - /** - * Get the transaction type. Since this is mandatory, will throw an exception when nothing comes up. Will always - * use TransactionType repository. - * - * @param string $type - * - * @return TransactionType - * @throws FireflyException - */ - protected function findTransactionType(string $type): TransactionType - { - $factory = app(TransactionTypeFactory::class); - $transactionType = $factory->find($type); - if (null === $transactionType) { - Log::error(sprintf('Could not find transaction type for "%s"', $type)); // @codeCoverageIgnore - throw new FireflyException(sprintf('Could not find transaction type for "%s"', $type)); // @codeCoverageIgnore - } - - return $transactionType; - } - } diff --git a/app/Http/Controllers/Recurring/CreateController.php b/app/Http/Controllers/Recurring/CreateController.php index 52e12bf7c7..c2c477b883 100644 --- a/app/Http/Controllers/Recurring/CreateController.php +++ b/app/Http/Controllers/Recurring/CreateController.php @@ -26,7 +26,9 @@ namespace FireflyIII\Http\Controllers\Recurring; use Carbon\Carbon; use FireflyIII\Http\Controllers\Controller; +use FireflyIII\Http\Requests\RecurrenceFormRequest; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; +use FireflyIII\Repositories\PiggyBank\PiggyBankRepositoryInterface; use FireflyIII\Repositories\Recurring\RecurringRepositoryInterface; use Illuminate\Http\Request; @@ -38,6 +40,8 @@ class CreateController extends Controller { /** @var BudgetRepositoryInterface */ private $budgets; + /** @var PiggyBankRepositoryInterface */ + private $piggyBanks; /** @var RecurringRepositoryInterface */ private $recurring; @@ -55,8 +59,9 @@ class CreateController extends Controller app('view')->share('title', trans('firefly.recurrences')); app('view')->share('subTitle', trans('firefly.create_new_recurrence')); - $this->recurring = app(RecurringRepositoryInterface::class); - $this->budgets = app(BudgetRepositoryInterface::class); + $this->recurring = app(RecurringRepositoryInterface::class); + $this->budgets = app(BudgetRepositoryInterface::class); + $this->piggyBanks = app(PiggyBankRepositoryInterface::class); return $next($request); } @@ -64,6 +69,8 @@ class CreateController extends Controller } /** + * @param Request $request + * * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View */ public function create(Request $request) @@ -71,6 +78,8 @@ class CreateController extends Controller // todo refactor to expandedform method. $budgets = app('expandedform')->makeSelectListWithEmpty($this->budgets->getActiveBudgets()); $defaultCurrency = app('amount')->getDefaultCurrency(); + $piggyBanks = $this->piggyBanks->getPiggyBanksWithAmount(); + $piggies = app('expandedform')->makeSelectListWithEmpty($piggyBanks); $tomorrow = new Carbon; $tomorrow->addDay(); @@ -90,7 +99,18 @@ class CreateController extends Controller ]; $request->session()->flash('preFilled', $preFilled); - return view('recurring.create', compact('tomorrow', 'preFilled','typesOfRepetitions', 'defaultCurrency', 'budgets')); + return view('recurring.create', compact('tomorrow', 'preFilled', 'piggies', 'typesOfRepetitions', 'defaultCurrency', 'budgets')); + } + + /** + * @param RecurrenceFormRequest $request + */ + public function store(RecurrenceFormRequest $request) + { + $data = $request->getAll(); + $this->recurring->store($data); + var_dump($data); + exit; } } \ No newline at end of file diff --git a/app/Http/Controllers/Recurring/IndexController.php b/app/Http/Controllers/Recurring/IndexController.php index ed25962773..810000d2fc 100644 --- a/app/Http/Controllers/Recurring/IndexController.php +++ b/app/Http/Controllers/Recurring/IndexController.php @@ -76,9 +76,20 @@ class IndexController extends Controller $return = []; $start = Carbon::createFromFormat('Y-m-d', $request->get('start')); $end = Carbon::createFromFormat('Y-m-d', $request->get('end')); + $firstDate = Carbon::createFromFormat('Y-m-d', $request->get('first_date')); + $endDate = '' !== (string)$request->get('end_date') ? Carbon::createFromFormat('Y-m-d', $request->get('end_date')) : null; $endsAt = (string)$request->get('ends'); $repetitionType = explode(',', $request->get('type'))[0]; + $repetitions = (int)$request->get('reps'); $repetitionMoment = ''; + $start->startOfDay(); + + // if $firstDate is beyond $end, simply return an empty array. + if ($firstDate->gt($end)) { + return Response::json([]); + } + // if $firstDate is beyond start, use that one: + $actualStart = clone $firstDate; switch ($repetitionType) { default: @@ -90,32 +101,51 @@ class IndexController extends Controller $repetitionMoment = explode(',', $request->get('type'))[1] ?? '1'; break; case 'ndom': - $repetitionMoment = explode(',', $request->get('type'))[1] ?? '1,1'; + $repetitionMoment = str_ireplace('ndom,', '', $request->get('type')); break; case 'yearly': $repetitionMoment = explode(',', $request->get('type'))[1] ?? '2018-01-01'; break; } - $repetition = new RecurrenceRepetition; $repetition->repetition_type = $repetitionType; $repetition->repetition_moment = $repetitionMoment; $repetition->repetition_skip = (int)$request->get('skip'); - var_dump($repository->getXOccurrences($repetition, $start, 5)); - exit; - - - // calculate events in range, depending on type: + $actualEnd = clone $end; switch ($endsAt) { default: - throw new FireflyException(sprintf('Cannot generate events for "%s"', $endsAt)); + throw new FireflyException(sprintf('Cannot generate events for type that ends at "%s".', $endsAt)); case 'forever': + // simply generate up until $end. No change from default behavior. + $occurrences = $repository->getOccurrencesInRange($repetition, $actualStart, $actualEnd); + break; + case 'until_date': + $actualEnd = $endDate ?? clone $end; + $occurrences = $repository->getOccurrencesInRange($repetition, $actualStart, $actualEnd); + break; + case 'times': + $occurrences = $repository->getXOccurrences($repetition, $actualStart, $repetitions); break; - } + /** @var Carbon $current */ + foreach ($occurrences as $current) { + if ($current->gte($start)) { + $event = [ + 'id' => $repetitionType . $firstDate->format('Ymd'), + 'title' => 'X', + 'allDay' => true, + 'start' => $current->format('Y-m-d'), + 'end' => $current->format('Y-m-d'), + 'editable' => false, + 'rendering' => 'background', + ]; + $return[] = $event; + } + } + return Response::json($return); } diff --git a/app/Http/Controllers/Transaction/SingleController.php b/app/Http/Controllers/Transaction/SingleController.php index 0f65bf03ff..af415c0ee8 100644 --- a/app/Http/Controllers/Transaction/SingleController.php +++ b/app/Http/Controllers/Transaction/SingleController.php @@ -37,6 +37,7 @@ use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; use FireflyIII\Repositories\Journal\JournalRepositoryInterface; use FireflyIII\Repositories\PiggyBank\PiggyBankRepositoryInterface; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Log; use Preferences; @@ -218,7 +219,7 @@ class SingleController extends Controller * * @internal param JournalRepositoryInterface $repository */ - public function destroy(TransactionJournal $transactionJournal) + public function destroy(TransactionJournal $transactionJournal): RedirectResponse { // @codeCoverageIgnoreStart if ($this->isOpeningBalance($transactionJournal)) { @@ -329,9 +330,10 @@ class SingleController extends Controller * @param JournalFormRequest $request * @param JournalRepositoryInterface $repository * - * @return \Illuminate\Http\RedirectResponse + * @return RedirectResponse + * @throws \FireflyIII\Exceptions\FireflyException */ - public function store(JournalFormRequest $request, JournalRepositoryInterface $repository) + public function store(JournalFormRequest $request, JournalRepositoryInterface $repository): RedirectResponse { $doSplit = 1 === (int)$request->get('split_journal'); $createAnother = 1 === (int)$request->get('create_another'); diff --git a/app/Http/Requests/RecurrenceFormRequest.php b/app/Http/Requests/RecurrenceFormRequest.php new file mode 100644 index 0000000000..f87a32295d --- /dev/null +++ b/app/Http/Requests/RecurrenceFormRequest.php @@ -0,0 +1,189 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Http\Requests; + +use Carbon\Carbon; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\TransactionType; +use FireflyIII\Rules\ValidRecurrenceRepetitionType; + +/** + * Class RecurrenceFormRequest + */ +class RecurrenceFormRequest extends Request +{ + + /** + * @return bool + */ + public function authorize(): bool + { + // Only allow logged in users + return auth()->check(); + } + + /** + * @return array + * @throws FireflyException + */ + public function getAll(): array + { + $data = $this->all(); + $return = [ + 'recurrence' => [ + 'type' => $this->string('transaction_type'), + 'title' => $this->string('title'), + 'description' => $this->string('recurring_description'), + 'first_date' => $this->date('first_date'), + 'repeat_until' => $this->date('repeat_until'), + 'repetitions' => $this->integer('repetitions'), + 'apply_rules' => $this->boolean('apply_rules'), + 'active' => $this->boolean('active'), + ], + 'transactions' => [ + [ + 'transaction_currency_id' => $this->integer('transaction_currency_id'), + 'type' => $this->string('transaction_type'), + 'description' => $this->string('transaction_description'), + 'amount' => $this->string('amount'), + 'foreign_amount' => null, + 'foreign_currency_id' => null, + 'budget_id' => $this->integer('budget_id'), + 'category_name' => $this->string('category'), + + ], + ], + 'meta' => [ + // tags and piggy bank ID. + 'tags' => explode(',', $this->string('tags')), + 'piggy_bank_id' => $this->integer('piggy_bank_id'), + ], + 'repetitions' => [ + [ + 'skip' => $this->integer('skip'), + ], + ], + + ]; + + // fill in foreign currency data + if (null !== $this->float('foreign_amount')) { + $return['transactions'][0]['foreign_amount'] = $this->string('foreign_amount'); + $return['transactions'][0]['foreign_currency_id'] = $this->integer('foreign_currency_id'); + } + + // fill in source and destination account data + switch ($this->string('transaction_type')) { + default: + throw new FireflyException(sprintf('Cannot handle transaction type "%s"', $this->string('transaction_type'))); + case 'withdrawal': + $return['transactions'][0]['source_account_id'] = $this->integer('source_account_id'); + $return['transactions'][0]['destination_account_name'] = $this->string('destination_account_name'); + break; + } + + return $return; + } + + /** + * @return array + * @throws FireflyException + */ + public function rules(): array + { + $today = new Carbon; + $tomorrow = clone $today; + $tomorrow->addDay(); + $rules = [ + // mandatory info for recurrence. + //'title' => 'required|between:1,255|uniqueObjectForUser:recurrences,title', + 'title' => 'required|between:1,255', + 'first_date' => 'required|date|after:' . $today->format('Y-m-d'), + 'repetition_type' => ['required', new ValidRecurrenceRepetitionType, 'between:1,20'], + 'skip' => 'required|numeric|between:0,31', + + // optional for recurrence: + 'recurring_description' => 'between:0,65000', + 'active' => 'numeric|between:0,1', + 'apply_rules' => 'numeric|between:0,1', + + // mandatory for transaction: + 'transaction_description' => 'required|between:1,255', + 'transaction_type' => 'required|in:withdrawal,deposit,transfer', + 'transaction_currency_id' => 'required|exists:transaction_currencies,id', + 'amount' => 'numeric|required|more:0', + // mandatory account info: + 'source_account_id' => 'numeric|belongsToUser:accounts,id|nullable', + 'source_account_name' => 'between:1,255|nullable', + 'destination_account_id' => 'numeric|belongsToUser:accounts,id|nullable', + 'destination_account_name' => 'between:1,255|nullable', + + // foreign amount data: + 'foreign_currency_id' => 'exists:transaction_currencies,id', + 'foreign_amount' => 'nullable|more:0', + + // optional fields: + 'budget_id' => 'mustExist:budgets,id|belongsToUser:budgets,id|nullable', + 'category' => 'between:1,255|nullable', + 'tags' => 'between:1,255|nullable', + ]; + + // if ends after X repetitions, set another rule + if ($this->string('repetition_end') === 'times') { + $rules['repetitions'] = 'required|numeric|between:0,254'; + } + // if foreign amount, currency must be different. + if ($this->float('foreign_amount') !== 0.0) { + $rules['foreign_currency_id'] = 'exists:transaction_currencies,id|different:transaction_currency_id'; + } + + // if ends at date X, set another rule. + if ($this->string('repetition_end') === 'until_date') { + $rules['repeat_until'] = 'required|date|after:' . $tomorrow->format('Y-m-d'); + } + + // switchc on type to expand rules for source and destination accounts: + switch ($this->string('transaction_type')) { + case strtolower(TransactionType::WITHDRAWAL): + $rules['source_account_id'] = 'required|exists:accounts,id|belongsToUser:accounts'; + $rules['destination_account_name'] = 'between:1,255|nullable'; + break; + case strtolower(TransactionType::DEPOSIT): + $rules['source_account_name'] = 'between:1,255|nullable'; + $rules['destination_account_id'] = 'required|exists:accounts,id|belongsToUser:accounts'; + break; + case strtolower(TransactionType::TRANSFER): + // this may not work: + $rules['source_account_id'] = 'required|exists:accounts,id|belongsToUser:accounts|different:destination_account_id'; + $rules['destination_account_id'] = 'required|exists:accounts,id|belongsToUser:accounts|different:source_account_id'; + + break; + default: + throw new FireflyException(sprintf('Cannot handle transaction type of type "%s"', $this->string('transaction_type'))); // @codeCoverageIgnore + } + + + return $rules; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Request.php b/app/Http/Requests/Request.php index 1146b31017..c293b6237e 100644 --- a/app/Http/Requests/Request.php +++ b/app/Http/Requests/Request.php @@ -47,6 +47,16 @@ class Request extends FormRequest return 1 === (int)$this->input($field); } + /** + * @param string $field + * + * @return float + */ + public function float(string $field): float + { + return (float)$this->get($field); + } + /** * @param string $field * diff --git a/app/Models/Recurrence.php b/app/Models/Recurrence.php index 9d321a4a25..b3ce26d0f3 100644 --- a/app/Models/Recurrence.php +++ b/app/Models/Recurrence.php @@ -64,8 +64,7 @@ class Recurrence extends Model * @var array */ protected $casts - = [ - + = [ 'created_at' => 'datetime', 'updated_at' => 'datetime', 'first_date' => 'date', @@ -73,6 +72,10 @@ class Recurrence extends Model 'active' => 'bool', 'apply_rules' => 'bool', ]; + /** @var array */ + protected $fillable + = ['user_id', 'transaction_type_id', 'title', 'description', 'first_date', 'repeat_until', 'latest_date', 'repetitions', 'apply_rules', 'active']; + /** @var string */ protected $table = 'recurrences'; /** diff --git a/app/Models/RecurrenceTransaction.php b/app/Models/RecurrenceTransaction.php index 482f16151a..7d729c8fdc 100644 --- a/app/Models/RecurrenceTransaction.php +++ b/app/Models/RecurrenceTransaction.php @@ -47,6 +47,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany; */ class RecurrenceTransaction extends Model { + /** @var array */ + protected $fillable + = ['recurrence_id', 'transaction_currency_id', 'foreign_currency_id', 'source_account_id', 'destination_account_id', 'amount', 'foreign_amount', + 'description']; + /** @var string */ protected $table = 'recurrences_transactions'; /** @@ -82,7 +87,7 @@ class RecurrenceTransaction extends Model */ public function recurrenceTransactionMeta(): HasMany { - return $this->hasMany(RecurrenceTransactionMeta::class,'rt_id'); + return $this->hasMany(RecurrenceTransactionMeta::class, 'rt_id'); } /** diff --git a/app/Repositories/Recurring/RecurringRepository.php b/app/Repositories/Recurring/RecurringRepository.php index 2bf58c2066..57cce34211 100644 --- a/app/Repositories/Recurring/RecurringRepository.php +++ b/app/Repositories/Recurring/RecurringRepository.php @@ -25,6 +25,7 @@ namespace FireflyIII\Repositories\Recurring; use Carbon\Carbon; use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Factory\RecurrenceFactory; use FireflyIII\Models\Note; use FireflyIII\Models\Preference; use FireflyIII\Models\Recurrence; @@ -73,28 +74,35 @@ class RecurringRepository implements RecurringRepositoryInterface } /** - * Calculate the next X iterations starting on the date given in $date. + * Generate events in the date range. * * @param RecurrenceRepetition $repetition - * @param Carbon $date - * @param int $count + * @param Carbon $start + * @param Carbon $end + * + * @throws FireflyException * * @return array - * @throws FireflyException */ - public function getXOccurrences(RecurrenceRepetition $repetition, Carbon $date, int $count = 5): array + public function getOccurrencesInRange(RecurrenceRepetition $repetition, Carbon $start, Carbon $end): array { $return = []; - $mutator = clone $date; + $mutator = clone $start; + $mutator->startOfDay(); + $skipMod = $repetition->repetition_skip + 1; + $attempts = 0; switch ($repetition->repetition_type) { default: throw new FireflyException( sprintf('Cannot calculate occurrences for recurring transaction repetition type "%s"', $repetition->repetition_type) ); case 'daily': - for ($i = 0; $i < $count; $i++) { + while ($mutator <= $end) { + if ($attempts % $skipMod === 0) { + $return[] = clone $mutator; + } $mutator->addDay(); - $return[] = clone $mutator; + $attempts++; } break; case 'weekly': @@ -110,35 +118,38 @@ class RecurringRepository implements RecurringRepositoryInterface // today is friday (5), expected is monday (1), subtract four days. $dayDifference = $dayOfWeek - $mutator->dayOfWeekIso; $mutator->addDays($dayDifference); - for ($i = 0; $i < $count; $i++) { - $return[] = clone $mutator; + while ($mutator <= $end) { + if ($attempts % $skipMod === 0) { + $return[] = clone $mutator; + } + $attempts++; $mutator->addWeek(); } break; case 'monthly': - $mutator->addDay(); // always assume today has passed. $dayOfMonth = (int)$repetition->repetition_moment; if ($mutator->day > $dayOfMonth) { // day has passed already, add a month. $mutator->addMonth(); } - for ($i = 0; $i < $count; $i++) { + while ($mutator < $end) { $domCorrected = min($dayOfMonth, $mutator->daysInMonth); $mutator->day = $domCorrected; - $return[] = clone $mutator; + if ($attempts % $skipMod === 0) { + $return[] = clone $mutator; + } + $attempts++; $mutator->endOfMonth()->addDay(); } break; case 'ndom': - $mutator->addDay(); // always assume today has passed. $mutator->startOfMonth(); // this feels a bit like a cop out but why reinvent the wheel? - $string = '%s %s of %s %s'; $counters = [1 => 'first', 2 => 'second', 3 => 'third', 4 => 'fourth', 5 => 'fifth',]; $daysOfWeek = [1 => 'Monday', 2 => 'Tuesday', 3 => 'Wednesday', 4 => 'Thursday', 5 => 'Friday', 6 => 'Saturday', 7 => 'Sunday',]; $parts = explode(',', $repetition->repetition_moment); - for ($i = 0; $i < $count; $i++) { + while ($mutator <= $end) { $string = sprintf('%s %s of %s %s', $counters[$parts[0]], $daysOfWeek[$parts[1]], $mutator->format('F'), $mutator->format('Y')); $newCarbon = new Carbon($string); $return[] = clone $newCarbon; @@ -150,11 +161,131 @@ class RecurringRepository implements RecurringRepositoryInterface $date->year = $mutator->year; if ($mutator > $date) { $date->addYear(); + } - for ($i = 0; $i < $count; $i++) { - $obj = clone $date; - $obj->addYears($i); - $return[] = $obj; + + // is $date between $start and $end? + $obj = clone $date; + $count = 0; + while ($obj <= $end && $obj >= $mutator && $count < 10) { + + $return[] = clone $obj; + $obj->addYears(1); + $count++; + } + break; + } + + return $return; + } + + /** + * Calculate the next X iterations starting on the date given in $date. + * + * @param RecurrenceRepetition $repetition + * @param Carbon $date + * @param int $count + * + * @return array + * @throws FireflyException + */ + public function getXOccurrences(RecurrenceRepetition $repetition, Carbon $date, int $count): array + { + $return = []; + $mutator = clone $date; + $skipMod = $repetition->repetition_skip + 1; + $total = 0; + $attempts = 0; + switch ($repetition->repetition_type) { + default: + throw new FireflyException( + sprintf('Cannot calculate occurrences for recurring transaction repetition type "%s"', $repetition->repetition_type) + ); + case 'daily': + while ($total < $count) { + $mutator->addDay(); + if ($attempts % $skipMod === 0) { + $return[] = clone $mutator; + $total++; + } + $attempts++; + } + break; + case 'weekly': + // monday = 1 + // sunday = 7 + $mutator->addDay(); // always assume today has passed. + $dayOfWeek = (int)$repetition->repetition_moment; + if ($mutator->dayOfWeekIso > $dayOfWeek) { + // day has already passed this week, add one week: + $mutator->addWeek(); + } + // today is wednesday (3), expected is friday (5): add two days. + // today is friday (5), expected is monday (1), subtract four days. + $dayDifference = $dayOfWeek - $mutator->dayOfWeekIso; + $mutator->addDays($dayDifference); + + while ($total < $count) { + if ($attempts % $skipMod === 0) { + $return[] = clone $mutator; + $total++; + } + $attempts++; + $mutator->addWeek(); + } + break; + case 'monthly': + $mutator->addDay(); // always assume today has passed. + $dayOfMonth = (int)$repetition->repetition_moment; + if ($mutator->day > $dayOfMonth) { + // day has passed already, add a month. + $mutator->addMonth(); + } + + while ($total < $count) { + $domCorrected = min($dayOfMonth, $mutator->daysInMonth); + $mutator->day = $domCorrected; + if ($attempts % $skipMod === 0) { + $return[] = clone $mutator; + $total++; + } + $attempts++; + $mutator->endOfMonth()->addDay(); + } + break; + case 'ndom': + $mutator->addDay(); // always assume today has passed. + $mutator->startOfMonth(); + // this feels a bit like a cop out but why reinvent the wheel? + $counters = [1 => 'first', 2 => 'second', 3 => 'third', 4 => 'fourth', 5 => 'fifth',]; + $daysOfWeek = [1 => 'Monday', 2 => 'Tuesday', 3 => 'Wednesday', 4 => 'Thursday', 5 => 'Friday', 6 => 'Saturday', 7 => 'Sunday',]; + $parts = explode(',', $repetition->repetition_moment); + + while ($total < $count) { + $string = sprintf('%s %s of %s %s', $counters[$parts[0]], $daysOfWeek[$parts[1]], $mutator->format('F'), $mutator->format('Y')); + $newCarbon = new Carbon($string); + if ($attempts % $skipMod === 0) { + $return[] = clone $newCarbon; + $total++; + } + $attempts++; + $mutator->endOfMonth()->addDay(); + } + break; + case 'yearly': + $date = new Carbon($repetition->repetition_moment); + $date->year = $mutator->year; + if ($mutator > $date) { + $date->addYear(); + } + $obj = clone $date; + while ($total < $count) { + if ($attempts % $skipMod === 0) { + $return[] = clone $obj; + $total++; + } + $obj->addYears(1); + $attempts++; } break; } @@ -223,4 +354,18 @@ class RecurringRepository implements RecurringRepositoryInterface { $this->user = $user; } + + /** + * @param array $data + * + * @throws FireflyException + * @return Recurrence + */ + public function store(array $data): Recurrence + { + $factory = new RecurrenceFactory; + $factory->setUser($this->user); + + return $factory->create($data); + } } \ No newline at end of file diff --git a/app/Repositories/Recurring/RecurringRepositoryInterface.php b/app/Repositories/Recurring/RecurringRepositoryInterface.php index f0e7646827..1770309800 100644 --- a/app/Repositories/Recurring/RecurringRepositoryInterface.php +++ b/app/Repositories/Recurring/RecurringRepositoryInterface.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace FireflyIII\Repositories\Recurring; use Carbon\Carbon; +use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\Recurrence; use FireflyIII\Models\RecurrenceRepetition; use FireflyIII\User; @@ -53,6 +54,19 @@ interface RecurringRepositoryInterface */ public function getNoteText(Recurrence $recurrence): string; + /** + * Generate events in the date range. + * + * @param RecurrenceRepetition $repetition + * @param Carbon $start + * @param Carbon $end + * + * @throws FireflyException + * + * @return array + */ + public function getOccurrencesInRange(RecurrenceRepetition $repetition, Carbon $start, Carbon $end): array; + /** * Calculate the next X iterations starting on the date given in $date. * Returns an array of Carbon objects. @@ -61,9 +75,10 @@ interface RecurringRepositoryInterface * @param Carbon $date * @param int $count * + * @throws FireflyException * @return array */ - public function getXOccurrences(RecurrenceRepetition $repetition, Carbon $date, int $count = 5): array; + public function getXOccurrences(RecurrenceRepetition $repetition, Carbon $date, int $count): array; /** * Parse the repetition in a string that is user readable. @@ -81,4 +96,12 @@ interface RecurringRepositoryInterface */ public function setUser(User $user): void; + /** + * @param array $data + * + * @throws FireflyException + * @return Recurrence + */ + public function store(array $data): Recurrence; + } \ No newline at end of file diff --git a/app/Rules/ValidRecurrenceRepetitionType.php b/app/Rules/ValidRecurrenceRepetitionType.php new file mode 100644 index 0000000000..c8aa3eedbd --- /dev/null +++ b/app/Rules/ValidRecurrenceRepetitionType.php @@ -0,0 +1,72 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Rules; + +use Illuminate\Contracts\Validation\Rule; + +/** + * Class ValidRecurrenceRepetitionType + */ +class ValidRecurrenceRepetitionType implements Rule +{ + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): string + { + return trans('validation.valid_recurrence_rep_type'); + } + + /** + * Determine if the validation rule passes. + * + * @param string $attribute + * @param mixed $value + * + * @return bool + */ + public function passes($attribute, $value): bool + { + $value = (string)$value; + if ($value === 'daily') { + return true; + } + //monthly,17 + //ndom,3,7 + if (\in_array(substr($value, 0, 6), ['yearly', 'weekly'])) { + return true; + } + if (0 === strpos($value, 'monthly')) { + return true; + } + if (0 === strpos($value, 'ndom')) { + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/app/Services/Internal/Support/TransactionTypeTrait.php b/app/Services/Internal/Support/TransactionTypeTrait.php new file mode 100644 index 0000000000..204110c66b --- /dev/null +++ b/app/Services/Internal/Support/TransactionTypeTrait.php @@ -0,0 +1,57 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Services\Internal\Support; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Factory\TransactionTypeFactory; +use FireflyIII\Models\TransactionType; +use Log; + +/** + * Trait TransactionTypeTrait + * + * @package FireflyIII\Services\Internal\Support + */ +trait TransactionTypeTrait +{ + /** + * Get the transaction type. Since this is mandatory, will throw an exception when nothing comes up. Will always + * use TransactionType repository. + * + * @param string $type + * + * @return TransactionType + * @throws FireflyException + */ + protected function findTransactionType(string $type): TransactionType + { + $factory = app(TransactionTypeFactory::class); + $transactionType = $factory->find($type); + if (null === $transactionType) { + Log::error(sprintf('Could not find transaction type for "%s"', $type)); // @codeCoverageIgnore + throw new FireflyException(sprintf('Could not find transaction type for "%s"', $type)); // @codeCoverageIgnore + } + + return $transactionType; + } +} \ No newline at end of file diff --git a/public/js/ff/recurring/create.js b/public/js/ff/recurring/create.js index 083f42f9aa..a2b8140c65 100644 --- a/public/js/ff/recurring/create.js +++ b/public/js/ff/recurring/create.js @@ -20,6 +20,8 @@ /** global: Modernizr, currencies */ +var calendar; + $(document).ready(function () { "use strict"; if (!Modernizr.inputtypes.date) { @@ -37,6 +39,19 @@ $(document).ready(function () { $('#ffInput_repetition_end').on('change', respondToRepetitionEnd); $('#ffInput_first_date').on('change', respondToFirstDateChange); + // create calendar on load: + calendar = $('#recurring_calendar').fullCalendar( + { + defaultDate: '2018-06-13', + editable: false, + height: 400, + width: 200, + contentHeight: 400, + aspectRatio: 1.25, + eventLimit: true, + eventSources: [], + }); + $('#calendar-link').on('click', showRepCalendar); }); @@ -49,22 +64,17 @@ function showRepCalendar() { var newEventsUri = eventsUri + '?type=' + $('#ffInput_repetition_type').val(); newEventsUri += '&skip=' + $('#ffInput_skip').val(); newEventsUri += '&ends=' + $('#ffInput_repetition_end').val(); - newEventsUri += '&endDate=' + $('#ffInput_repeat_until').val(); + newEventsUri += '&end_date=' + $('#ffInput_repeat_until').val(); newEventsUri += '&reps=' + $('#ffInput_repetitions').val(); + newEventsUri += '&first_date=' + $('#ffInput_first_date').val(); + // remove all event sources from calendar: + calendar.fullCalendar('removeEventSources'); - $('#recurring_calendar').fullCalendar( - { - defaultDate: '2018-06-13', - editable: false, - height: 400, - width: 200, - contentHeight: 300, - aspectRatio: 1.25, - eventLimit: true, // allow "more" link when too many events - events: newEventsUri - }); + // add a new one: + calendar.fullCalendar('addEventSource', newEventsUri); $('#calendarModal').modal('show'); + return false; } @@ -169,6 +179,7 @@ function initializeButtons() { console.log('Value is ' + btn.data('value')); if (btn.data('value') === transactionType) { btn.addClass('btn-info disabled').removeClass('btn-default'); + $('input[name="transaction_type"]').val(transactionType); } else { btn.removeClass('btn-info disabled').addClass('btn-default'); } diff --git a/resources/lang/en_US/firefly.php b/resources/lang/en_US/firefly.php index e839af9f50..7c6f46707f 100644 --- a/resources/lang/en_US/firefly.php +++ b/resources/lang/en_US/firefly.php @@ -1239,6 +1239,7 @@ return [ 'repeat_forever' => 'Repeat forever', 'repeat_until_date' => 'Repeat until date', 'repeat_times' => 'Repeat a number of times', - - + 'recurring_skips_one' => 'Every other', + 'recurring_skips_more' => 'Skips :count occurrences', + 'store_new_recurrence' => 'Store recurring transaction', ]; diff --git a/resources/lang/en_US/validation.php b/resources/lang/en_US/validation.php index 9ca9ec0f61..d8764ef5a9 100644 --- a/resources/lang/en_US/validation.php +++ b/resources/lang/en_US/validation.php @@ -112,6 +112,7 @@ return [ 'amount_zero' => 'The total amount cannot be zero', 'unique_piggy_bank_for_user' => 'The name of the piggy bank must be unique.', 'secure_password' => 'This is not a secure password. Please try again. For more information, visit http://bit.ly/FF3-password-security', + 'valid_recurrence_rep_type' => 'Invalid repetition type for recurring transactions', 'attributes' => [ 'email' => 'email address', 'description' => 'description', diff --git a/resources/views/recurring/create.twig b/resources/views/recurring/create.twig index b03247764a..23425ecc5b 100644 --- a/resources/views/recurring/create.twig +++ b/resources/views/recurring/create.twig @@ -16,7 +16,7 @@

{{ 'mandatory_for_recurring'|_ }}

- {{ ExpandedForm.text('name') }} + {{ ExpandedForm.text('title') }} {{ ExpandedForm.date('first_date',null, {helpText: trans('firefly.help_first_date')}) }} {{ ExpandedForm.select('repetition_type', [], null, {helpText: trans('firefly.change_date_other_options')}) }} {{ ExpandedForm.number('skip', 0) }} @@ -78,6 +78,7 @@
+ {# end of three buttons#} {{ ExpandedForm.text('transaction_description') }} @@ -125,7 +126,7 @@ {{ ExpandedForm.text('tags') }} {# RELATE THIS TRANSFER TO A PIGGY BANK #} - {{ ExpandedForm.select('piggy_bank_id', [], '0') }} + {{ ExpandedForm.select('piggy_bank_id', piggies, 0) }} diff --git a/resources/views/recurring/index.twig b/resources/views/recurring/index.twig index 63f12e8605..c6214daa1a 100644 --- a/resources/views/recurring/index.twig +++ b/resources/views/recurring/index.twig @@ -83,7 +83,16 @@ diff --git a/resources/views/recurring/show.twig b/resources/views/recurring/show.twig index 492a15fffe..77df4511ab 100644 --- a/resources/views/recurring/show.twig +++ b/resources/views/recurring/show.twig @@ -42,7 +42,14 @@