Lots of new code for recurring transactions. #1469

This commit is contained in:
James Cole
2018-06-16 21:47:51 +02:00
parent 968abd26e8
commit 1cf91c78f8
19 changed files with 769 additions and 79 deletions

View File

@@ -0,0 +1,124 @@
<?php
/**
* RecurrenceFactory.php
* Copyright (c) 2018 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 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 '<pre>';
print_r($data);
echo '</pre>';
$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;
}
}

View File

@@ -28,6 +28,7 @@ use FireflyIII\Exceptions\FireflyException;
use FireflyIII\Models\TransactionJournal; use FireflyIII\Models\TransactionJournal;
use FireflyIII\Models\TransactionType; use FireflyIII\Models\TransactionType;
use FireflyIII\Services\Internal\Support\JournalServiceTrait; use FireflyIII\Services\Internal\Support\JournalServiceTrait;
use FireflyIII\Services\Internal\Support\TransactionTypeTrait;
use FireflyIII\User; use FireflyIII\User;
use Log; use Log;
@@ -36,7 +37,7 @@ use Log;
*/ */
class TransactionJournalFactory class TransactionJournalFactory
{ {
use JournalServiceTrait; use JournalServiceTrait, TransactionTypeTrait;
/** @var User */ /** @var User */
private $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;
}
} }

View File

@@ -26,7 +26,9 @@ namespace FireflyIII\Http\Controllers\Recurring;
use Carbon\Carbon; use Carbon\Carbon;
use FireflyIII\Http\Controllers\Controller; use FireflyIII\Http\Controllers\Controller;
use FireflyIII\Http\Requests\RecurrenceFormRequest;
use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface;
use FireflyIII\Repositories\PiggyBank\PiggyBankRepositoryInterface;
use FireflyIII\Repositories\Recurring\RecurringRepositoryInterface; use FireflyIII\Repositories\Recurring\RecurringRepositoryInterface;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -38,6 +40,8 @@ class CreateController extends Controller
{ {
/** @var BudgetRepositoryInterface */ /** @var BudgetRepositoryInterface */
private $budgets; private $budgets;
/** @var PiggyBankRepositoryInterface */
private $piggyBanks;
/** @var RecurringRepositoryInterface */ /** @var RecurringRepositoryInterface */
private $recurring; private $recurring;
@@ -57,6 +61,7 @@ class CreateController extends Controller
$this->recurring = app(RecurringRepositoryInterface::class); $this->recurring = app(RecurringRepositoryInterface::class);
$this->budgets = app(BudgetRepositoryInterface::class); $this->budgets = app(BudgetRepositoryInterface::class);
$this->piggyBanks = app(PiggyBankRepositoryInterface::class);
return $next($request); return $next($request);
} }
@@ -64,6 +69,8 @@ class CreateController extends Controller
} }
/** /**
* @param Request $request
*
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/ */
public function create(Request $request) public function create(Request $request)
@@ -71,6 +78,8 @@ class CreateController extends Controller
// todo refactor to expandedform method. // todo refactor to expandedform method.
$budgets = app('expandedform')->makeSelectListWithEmpty($this->budgets->getActiveBudgets()); $budgets = app('expandedform')->makeSelectListWithEmpty($this->budgets->getActiveBudgets());
$defaultCurrency = app('amount')->getDefaultCurrency(); $defaultCurrency = app('amount')->getDefaultCurrency();
$piggyBanks = $this->piggyBanks->getPiggyBanksWithAmount();
$piggies = app('expandedform')->makeSelectListWithEmpty($piggyBanks);
$tomorrow = new Carbon; $tomorrow = new Carbon;
$tomorrow->addDay(); $tomorrow->addDay();
@@ -90,7 +99,18 @@ class CreateController extends Controller
]; ];
$request->session()->flash('preFilled', $preFilled); $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;
} }
} }

View File

@@ -76,9 +76,20 @@ class IndexController extends Controller
$return = []; $return = [];
$start = Carbon::createFromFormat('Y-m-d', $request->get('start')); $start = Carbon::createFromFormat('Y-m-d', $request->get('start'));
$end = Carbon::createFromFormat('Y-m-d', $request->get('end')); $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'); $endsAt = (string)$request->get('ends');
$repetitionType = explode(',', $request->get('type'))[0]; $repetitionType = explode(',', $request->get('type'))[0];
$repetitions = (int)$request->get('reps');
$repetitionMoment = ''; $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) { switch ($repetitionType) {
default: default:
@@ -90,32 +101,51 @@ class IndexController extends Controller
$repetitionMoment = explode(',', $request->get('type'))[1] ?? '1'; $repetitionMoment = explode(',', $request->get('type'))[1] ?? '1';
break; break;
case 'ndom': case 'ndom':
$repetitionMoment = explode(',', $request->get('type'))[1] ?? '1,1'; $repetitionMoment = str_ireplace('ndom,', '', $request->get('type'));
break; break;
case 'yearly': case 'yearly':
$repetitionMoment = explode(',', $request->get('type'))[1] ?? '2018-01-01'; $repetitionMoment = explode(',', $request->get('type'))[1] ?? '2018-01-01';
break; break;
} }
$repetition = new RecurrenceRepetition; $repetition = new RecurrenceRepetition;
$repetition->repetition_type = $repetitionType; $repetition->repetition_type = $repetitionType;
$repetition->repetition_moment = $repetitionMoment; $repetition->repetition_moment = $repetitionMoment;
$repetition->repetition_skip = (int)$request->get('skip'); $repetition->repetition_skip = (int)$request->get('skip');
var_dump($repository->getXOccurrences($repetition, $start, 5)); $actualEnd = clone $end;
exit;
// calculate events in range, depending on type:
switch ($endsAt) { switch ($endsAt) {
default: 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': 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; 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); return Response::json($return);
} }

View File

@@ -37,6 +37,7 @@ use FireflyIII\Repositories\Budget\BudgetRepositoryInterface;
use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface; use FireflyIII\Repositories\Currency\CurrencyRepositoryInterface;
use FireflyIII\Repositories\Journal\JournalRepositoryInterface; use FireflyIII\Repositories\Journal\JournalRepositoryInterface;
use FireflyIII\Repositories\PiggyBank\PiggyBankRepositoryInterface; use FireflyIII\Repositories\PiggyBank\PiggyBankRepositoryInterface;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Log; use Log;
use Preferences; use Preferences;
@@ -218,7 +219,7 @@ class SingleController extends Controller
* *
* @internal param JournalRepositoryInterface $repository * @internal param JournalRepositoryInterface $repository
*/ */
public function destroy(TransactionJournal $transactionJournal) public function destroy(TransactionJournal $transactionJournal): RedirectResponse
{ {
// @codeCoverageIgnoreStart // @codeCoverageIgnoreStart
if ($this->isOpeningBalance($transactionJournal)) { if ($this->isOpeningBalance($transactionJournal)) {
@@ -329,9 +330,10 @@ class SingleController extends Controller
* @param JournalFormRequest $request * @param JournalFormRequest $request
* @param JournalRepositoryInterface $repository * @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'); $doSplit = 1 === (int)$request->get('split_journal');
$createAnother = 1 === (int)$request->get('create_another'); $createAnother = 1 === (int)$request->get('create_another');

View File

@@ -0,0 +1,189 @@
<?php
/**
* RecurrenceFormRequest.php
* Copyright (c) 2018 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\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;
}
}

View File

@@ -47,6 +47,16 @@ class Request extends FormRequest
return 1 === (int)$this->input($field); 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 * @param string $field
* *

View File

@@ -65,7 +65,6 @@ class Recurrence extends Model
*/ */
protected $casts protected $casts
= [ = [
'created_at' => 'datetime', 'created_at' => 'datetime',
'updated_at' => 'datetime', 'updated_at' => 'datetime',
'first_date' => 'date', 'first_date' => 'date',
@@ -73,6 +72,10 @@ class Recurrence extends Model
'active' => 'bool', 'active' => 'bool',
'apply_rules' => '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'; protected $table = 'recurrences';
/** /**

View File

@@ -47,6 +47,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
*/ */
class RecurrenceTransaction extends Model 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'; protected $table = 'recurrences_transactions';
/** /**

View File

@@ -25,6 +25,7 @@ namespace FireflyIII\Repositories\Recurring;
use Carbon\Carbon; use Carbon\Carbon;
use FireflyIII\Exceptions\FireflyException; use FireflyIII\Exceptions\FireflyException;
use FireflyIII\Factory\RecurrenceFactory;
use FireflyIII\Models\Note; use FireflyIII\Models\Note;
use FireflyIII\Models\Preference; use FireflyIII\Models\Preference;
use FireflyIII\Models\Recurrence; use FireflyIII\Models\Recurrence;
@@ -73,29 +74,36 @@ 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 RecurrenceRepetition $repetition
* @param Carbon $date * @param Carbon $start
* @param int $count * @param Carbon $end
*
* @throws FireflyException
* *
* @return array * @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 = []; $return = [];
$mutator = clone $date; $mutator = clone $start;
$mutator->startOfDay();
$skipMod = $repetition->repetition_skip + 1;
$attempts = 0;
switch ($repetition->repetition_type) { switch ($repetition->repetition_type) {
default: default:
throw new FireflyException( throw new FireflyException(
sprintf('Cannot calculate occurrences for recurring transaction repetition type "%s"', $repetition->repetition_type) sprintf('Cannot calculate occurrences for recurring transaction repetition type "%s"', $repetition->repetition_type)
); );
case 'daily': case 'daily':
for ($i = 0; $i < $count; $i++) { while ($mutator <= $end) {
$mutator->addDay(); if ($attempts % $skipMod === 0) {
$return[] = clone $mutator; $return[] = clone $mutator;
} }
$mutator->addDay();
$attempts++;
}
break; break;
case 'weekly': case 'weekly':
// monday = 1 // monday = 1
@@ -110,35 +118,38 @@ class RecurringRepository implements RecurringRepositoryInterface
// today is friday (5), expected is monday (1), subtract four days. // today is friday (5), expected is monday (1), subtract four days.
$dayDifference = $dayOfWeek - $mutator->dayOfWeekIso; $dayDifference = $dayOfWeek - $mutator->dayOfWeekIso;
$mutator->addDays($dayDifference); $mutator->addDays($dayDifference);
for ($i = 0; $i < $count; $i++) { while ($mutator <= $end) {
if ($attempts % $skipMod === 0) {
$return[] = clone $mutator; $return[] = clone $mutator;
}
$attempts++;
$mutator->addWeek(); $mutator->addWeek();
} }
break; break;
case 'monthly': case 'monthly':
$mutator->addDay(); // always assume today has passed.
$dayOfMonth = (int)$repetition->repetition_moment; $dayOfMonth = (int)$repetition->repetition_moment;
if ($mutator->day > $dayOfMonth) { if ($mutator->day > $dayOfMonth) {
// day has passed already, add a month. // day has passed already, add a month.
$mutator->addMonth(); $mutator->addMonth();
} }
for ($i = 0; $i < $count; $i++) { while ($mutator < $end) {
$domCorrected = min($dayOfMonth, $mutator->daysInMonth); $domCorrected = min($dayOfMonth, $mutator->daysInMonth);
$mutator->day = $domCorrected; $mutator->day = $domCorrected;
if ($attempts % $skipMod === 0) {
$return[] = clone $mutator; $return[] = clone $mutator;
}
$attempts++;
$mutator->endOfMonth()->addDay(); $mutator->endOfMonth()->addDay();
} }
break; break;
case 'ndom': case 'ndom':
$mutator->addDay(); // always assume today has passed.
$mutator->startOfMonth(); $mutator->startOfMonth();
// this feels a bit like a cop out but why reinvent the wheel? // 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',]; $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',]; $daysOfWeek = [1 => 'Monday', 2 => 'Tuesday', 3 => 'Wednesday', 4 => 'Thursday', 5 => 'Friday', 6 => 'Saturday', 7 => 'Sunday',];
$parts = explode(',', $repetition->repetition_moment); $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')); $string = sprintf('%s %s of %s %s', $counters[$parts[0]], $daysOfWeek[$parts[1]], $mutator->format('F'), $mutator->format('Y'));
$newCarbon = new Carbon($string); $newCarbon = new Carbon($string);
$return[] = clone $newCarbon; $return[] = clone $newCarbon;
@@ -150,11 +161,131 @@ class RecurringRepository implements RecurringRepositoryInterface
$date->year = $mutator->year; $date->year = $mutator->year;
if ($mutator > $date) { if ($mutator > $date) {
$date->addYear(); $date->addYear();
} }
for ($i = 0; $i < $count; $i++) {
// is $date between $start and $end?
$obj = clone $date; $obj = clone $date;
$obj->addYears($i); $count = 0;
$return[] = $obj; 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; break;
} }
@@ -223,4 +354,18 @@ class RecurringRepository implements RecurringRepositoryInterface
{ {
$this->user = $user; $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);
}
} }

View File

@@ -24,6 +24,7 @@ declare(strict_types=1);
namespace FireflyIII\Repositories\Recurring; namespace FireflyIII\Repositories\Recurring;
use Carbon\Carbon; use Carbon\Carbon;
use FireflyIII\Exceptions\FireflyException;
use FireflyIII\Models\Recurrence; use FireflyIII\Models\Recurrence;
use FireflyIII\Models\RecurrenceRepetition; use FireflyIII\Models\RecurrenceRepetition;
use FireflyIII\User; use FireflyIII\User;
@@ -53,6 +54,19 @@ interface RecurringRepositoryInterface
*/ */
public function getNoteText(Recurrence $recurrence): string; 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. * Calculate the next X iterations starting on the date given in $date.
* Returns an array of Carbon objects. * Returns an array of Carbon objects.
@@ -61,9 +75,10 @@ interface RecurringRepositoryInterface
* @param Carbon $date * @param Carbon $date
* @param int $count * @param int $count
* *
* @throws FireflyException
* @return array * @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. * Parse the repetition in a string that is user readable.
@@ -81,4 +96,12 @@ interface RecurringRepositoryInterface
*/ */
public function setUser(User $user): void; public function setUser(User $user): void;
/**
* @param array $data
*
* @throws FireflyException
* @return Recurrence
*/
public function store(array $data): Recurrence;
} }

View File

@@ -0,0 +1,72 @@
<?php
/**
* ValidRecurrenceRepetitionType.php
* Copyright (c) 2018 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\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;
}
}

View File

@@ -0,0 +1,57 @@
<?php
/**
* TransactionTypeTrait.php
* Copyright (c) 2018 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\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;
}
}

View File

@@ -20,6 +20,8 @@
/** global: Modernizr, currencies */ /** global: Modernizr, currencies */
var calendar;
$(document).ready(function () { $(document).ready(function () {
"use strict"; "use strict";
if (!Modernizr.inputtypes.date) { if (!Modernizr.inputtypes.date) {
@@ -37,6 +39,19 @@ $(document).ready(function () {
$('#ffInput_repetition_end').on('change', respondToRepetitionEnd); $('#ffInput_repetition_end').on('change', respondToRepetitionEnd);
$('#ffInput_first_date').on('change', respondToFirstDateChange); $('#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); $('#calendar-link').on('click', showRepCalendar);
}); });
@@ -49,22 +64,17 @@ function showRepCalendar() {
var newEventsUri = eventsUri + '?type=' + $('#ffInput_repetition_type').val(); var newEventsUri = eventsUri + '?type=' + $('#ffInput_repetition_type').val();
newEventsUri += '&skip=' + $('#ffInput_skip').val(); newEventsUri += '&skip=' + $('#ffInput_skip').val();
newEventsUri += '&ends=' + $('#ffInput_repetition_end').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 += '&reps=' + $('#ffInput_repetitions').val();
newEventsUri += '&first_date=' + $('#ffInput_first_date').val();
// remove all event sources from calendar:
calendar.fullCalendar('removeEventSources');
$('#recurring_calendar').fullCalendar( // add a new one:
{ calendar.fullCalendar('addEventSource', newEventsUri);
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
});
$('#calendarModal').modal('show'); $('#calendarModal').modal('show');
return false; return false;
} }
@@ -169,6 +179,7 @@ function initializeButtons() {
console.log('Value is ' + btn.data('value')); console.log('Value is ' + btn.data('value'));
if (btn.data('value') === transactionType) { if (btn.data('value') === transactionType) {
btn.addClass('btn-info disabled').removeClass('btn-default'); btn.addClass('btn-info disabled').removeClass('btn-default');
$('input[name="transaction_type"]').val(transactionType);
} else { } else {
btn.removeClass('btn-info disabled').addClass('btn-default'); btn.removeClass('btn-info disabled').addClass('btn-default');
} }

View File

@@ -1239,6 +1239,7 @@ return [
'repeat_forever' => 'Repeat forever', 'repeat_forever' => 'Repeat forever',
'repeat_until_date' => 'Repeat until date', 'repeat_until_date' => 'Repeat until date',
'repeat_times' => 'Repeat a number of times', 'repeat_times' => 'Repeat a number of times',
'recurring_skips_one' => 'Every other',
'recurring_skips_more' => 'Skips :count occurrences',
'store_new_recurrence' => 'Store recurring transaction',
]; ];

View File

@@ -112,6 +112,7 @@ return [
'amount_zero' => 'The total amount cannot be zero', 'amount_zero' => 'The total amount cannot be zero',
'unique_piggy_bank_for_user' => 'The name of the piggy bank must be unique.', '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', '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' => [ 'attributes' => [
'email' => 'email address', 'email' => 'email address',
'description' => 'description', 'description' => 'description',

View File

@@ -16,7 +16,7 @@
<h3 class="box-title">{{ 'mandatory_for_recurring'|_ }}</h3> <h3 class="box-title">{{ 'mandatory_for_recurring'|_ }}</h3>
</div> </div>
<div class="box-body"> <div class="box-body">
{{ ExpandedForm.text('name') }} {{ ExpandedForm.text('title') }}
{{ ExpandedForm.date('first_date',null, {helpText: trans('firefly.help_first_date')}) }} {{ ExpandedForm.date('first_date',null, {helpText: trans('firefly.help_first_date')}) }}
{{ ExpandedForm.select('repetition_type', [], null, {helpText: trans('firefly.change_date_other_options')}) }} {{ ExpandedForm.select('repetition_type', [], null, {helpText: trans('firefly.change_date_other_options')}) }}
{{ ExpandedForm.number('skip', 0) }} {{ ExpandedForm.number('skip', 0) }}
@@ -78,6 +78,7 @@
</div> </div>
</div> </div>
</div> </div>
<input type="hidden" name="transaction_type" value="">
{# end of three buttons#} {# end of three buttons#}
{{ ExpandedForm.text('transaction_description') }} {{ ExpandedForm.text('transaction_description') }}
@@ -125,7 +126,7 @@
{{ ExpandedForm.text('tags') }} {{ ExpandedForm.text('tags') }}
{# RELATE THIS TRANSFER TO A PIGGY BANK #} {# RELATE THIS TRANSFER TO A PIGGY BANK #}
{{ ExpandedForm.select('piggy_bank_id', [], '0') }} {{ ExpandedForm.select('piggy_bank_id', piggies, 0) }}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -83,7 +83,16 @@
<td> <td>
<ul> <ul>
{% for rep in rt.repetitions %} {% for rep in rt.repetitions %}
<li>{{ rep.description }}</li> <li>{{ rep.description }}
{% if rep.repetition_skip == 1 %}
({{ trans('firefly.recurring_skips_one')|lower }})
{% endif %}
{% if rep.repetition_skip > 1 %}
({{ trans('firefly.recurring_skips_more', {count: rep.repetition_skip})|lower }})
{% endif %}
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</td> </td>

View File

@@ -42,7 +42,14 @@
<ul> <ul>
{% for rep in array.repetitions %} {% for rep in array.repetitions %}
<li>{{ rep.description }} <li>
{{ rep.description }}
{% if rep.repetition_skip == 1 %}
({{ trans('firefly.recurring_skips_one')|lower }})
{% endif %}
{% if rep.repetition_skip > 1 %}
({{ trans('firefly.recurring_skips_more', {count: rep.repetition_skip})|lower }})
{% endif %}
<ul> <ul>
{% for occ in rep.occurrences %} {% for occ in rep.occurrences %}
<li>{{ occ.formatLocalized(trans('config.month_and_date_day')) }}</li> <li>{{ occ.formatLocalized(trans('config.month_and_date_day')) }}</li>