diff --git a/app/Http/Controllers/Recurring/CreateController.php b/app/Http/Controllers/Recurring/CreateController.php new file mode 100644 index 0000000000..f7f5d06712 --- /dev/null +++ b/app/Http/Controllers/Recurring/CreateController.php @@ -0,0 +1,89 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Http\Controllers\Recurring; + + +use Carbon\Carbon; +use FireflyIII\Http\Controllers\Controller; +use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; +use FireflyIII\Repositories\Recurring\RecurringRepositoryInterface; +use Illuminate\Http\Request; + +/** + * + * Class CreateController + */ +class CreateController extends Controller +{ + /** @var BudgetRepositoryInterface */ + private $budgets; + /** @var RecurringRepositoryInterface */ + private $recurring; + + /** + * + */ + public function __construct() + { + parent::__construct(); + + // translations: + $this->middleware( + function ($request, $next) { + app('view')->share('mainTitleIcon', 'fa-paint-brush'); + 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); + + return $next($request); + } + ); + } + + /** + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function create(Request $request) + { + // todo refactor to expandedform method. + $budgets = app('expandedform')->makeSelectListWithEmpty($this->budgets->getActiveBudgets()); + $defaultCurrency = app('amount')->getDefaultCurrency(); + $tomorrow = new Carbon; + $tomorrow->addDay(); + + // flash some data: + $preFilled = [ + 'first_date' => $tomorrow->format('Y-m-d'), + 'transaction_type' => 'withdrawal', + 'active' => $request->old('active') ?? true, + 'apply_rules' => $request->old('apply_rules') ?? true, + ]; + $request->session()->flash('preFilled', $preFilled); + + return view('recurring.create', compact('tomorrow', 'preFilled', 'defaultCurrency','budgets')); + } + +} \ No newline at end of file diff --git a/app/Http/Controllers/Recurring/EditController.php b/app/Http/Controllers/Recurring/EditController.php new file mode 100644 index 0000000000..6ef2b0beb2 --- /dev/null +++ b/app/Http/Controllers/Recurring/EditController.php @@ -0,0 +1,66 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Http\Controllers\Recurring; + + +use FireflyIII\Http\Controllers\Controller; +use FireflyIII\Models\Recurrence; + +/** + * + * Class EditController + */ +class EditController extends Controller +{ + /** + * + */ + public function __construct() + { + parent::__construct(); + + // translations: + $this->middleware( + function ($request, $next) { + app('view')->share('mainTitleIcon', 'fa-paint-brush'); + app('view')->share('title', trans('firefly.recurrences')); + app('view')->share('subTitle', trans('firefly.recurrences')); + + $this->recurring = app(RecurringRepositoryInterface::class); + + return $next($request); + } + ); + } + + /** + * @param Recurrence $recurrence + */ + public function edit(Recurrence $recurrence) { + + return view('recurring.edit', compact('recurrence')); + } + + +} \ No newline at end of file diff --git a/app/Http/Controllers/Recurring/IndexController.php b/app/Http/Controllers/Recurring/IndexController.php new file mode 100644 index 0000000000..8cde8b238f --- /dev/null +++ b/app/Http/Controllers/Recurring/IndexController.php @@ -0,0 +1,146 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Http\Controllers\Recurring; + + +use Carbon\Carbon; +use FireflyIII\Http\Controllers\Controller; +use FireflyIII\Models\Recurrence; +use FireflyIII\Repositories\Recurring\RecurringRepositoryInterface; +use FireflyIII\Transformers\RecurrenceTransformer; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Response; +use Symfony\Component\HttpFoundation\ParameterBag; + +/** + * + * Class IndexController + */ +class IndexController extends Controller +{ + /** @var RecurringRepositoryInterface */ + private $recurring; + + /** + * + */ + public function __construct() + { + parent::__construct(); + + // translations: + $this->middleware( + function ($request, $next) { + app('view')->share('mainTitleIcon', 'fa-paint-brush'); + app('view')->share('title', trans('firefly.recurrences')); + + $this->recurring = app(RecurringRepositoryInterface::class); + + return $next($request); + } + ); + } + + /** + * @param Request $request + * + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + * @throws \FireflyIII\Exceptions\FireflyException + */ + public function index(Request $request) + { + $page = 0 === (int)$request->get('page') ? 1 : (int)$request->get('page'); + $pageSize = (int)app('preferences')->get('listPageSize', 50)->data; + $collection = $this->recurring->getActive(); + + // TODO: split collection into pages + + $transformer = new RecurrenceTransformer(new ParameterBag); + $recurring = []; + /** @var Recurrence $recurrence */ + foreach ($collection as $recurrence) { + $array = $transformer->transform($recurrence); + $array['first_date'] = new Carbon($array['first_date']); + $array['latest_date'] = null === $array['latest_date'] ? null : new Carbon($array['latest_date']); + $recurring[] = $array; + } + + return view('recurring.index', compact('recurring', 'page', 'pageSize')); + } + + /** + * @param Recurrence $recurrence + * + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + * @throws \FireflyIII\Exceptions\FireflyException + */ + public function show(Recurrence $recurrence) + { + $transformer = new RecurrenceTransformer(new ParameterBag); + $array = $transformer->transform($recurrence); + + // transform dates back to Carbon objects: + foreach ($array['repetitions'] as $index => $repetition) { + foreach ($repetition['occurrences'] as $item => $occurrence) { + $array['repetitions'][$index]['occurrences'][$item] = new Carbon($occurrence); + } + } + + $subTitle = trans('firefly.overview_for_recurrence', ['title' => $recurrence->title]); + + return view('recurring.show', compact('recurrence', 'subTitle', 'array')); + } + + /** + * @param Request $request + * + * @return JsonResponse + */ + public function suggest(Request $request): JsonResponse + { + $today = new Carbon; + $date = Carbon::createFromFormat('Y-m-d', $request->get('date')); + $result = []; + if ($date > $today) { + $weekly = sprintf('weekly,%s', $date->dayOfWeekIso); + $monthly = sprintf('monthly,%s', $date->day); + $dayOfWeek = trans(sprintf('config.dow_%s', $date->dayOfWeekIso)); + $ndom = sprintf('ndom,%s,%s', $date->weekOfMonth, $date->dayOfWeekIso); + $yearly = sprintf('yearly,%s', $date->format('Y-m-d')); + $yearlyDate = $date->formatLocalized(trans('config.month_and_day_no_year')); + $result = [ + 'daily' => trans('firefly.recurring_daily'), + $weekly => trans('firefly.recurring_weekly', ['weekday' => $dayOfWeek]), + $monthly => trans('firefly.recurring_monthly', ['dayOfMonth' => $date->day]), + $ndom => trans('firefly.recurring_ndom', ['weekday' => $dayOfWeek, 'dayOfMonth' => $date->weekOfMonth]), + $yearly => trans('firefly.recurring_yearly', ['date' => $yearlyDate]), + ]; + } + + + return Response::json($result); + } + +} \ No newline at end of file diff --git a/app/Models/Recurrence.php b/app/Models/Recurrence.php new file mode 100644 index 0000000000..9d321a4a25 --- /dev/null +++ b/app/Models/Recurrence.php @@ -0,0 +1,159 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Models; + + +use FireflyIII\User; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * Class Recurrence + * + * @property int $id + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * @property int $user_id + * @property int $transaction_type_id + * @property int $transaction_currency_id + * @property string $title + * @property string $description + * @property \Carbon\Carbon $first_date + * @property \Carbon\Carbon $repeat_until + * @property \Carbon\Carbon $latest_date + * @property string $repetition_type + * @property string $repetition_moment + * @property int $repetition_skip + * @property bool $active + * @property bool $apply_rules + * @property \FireflyIII\User $user + * @property \Illuminate\Support\Collection $recurrenceRepetitions + * @property \Illuminate\Support\Collection $recurrenceMeta + * @property \Illuminate\Support\Collection $recurrenceTransactions + * @property \FireflyIII\Models\TransactionType $transactionType + * + */ +class Recurrence extends Model +{ + /** + * The attributes that should be casted to native types. + * + * @var array + */ + protected $casts + = [ + + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'first_date' => 'date', + 'latest_date' => 'date', + 'active' => 'bool', + 'apply_rules' => 'bool', + ]; + protected $table = 'recurrences'; + + /** + * @param string $value + * + * @return Recurrence + * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + */ + public static function routeBinder(string $value): Recurrence + { + if (auth()->check()) { + $recurrenceId = (int)$value; + $recurrence = auth()->user()->recurrences()->find($recurrenceId); + if (null !== $recurrence) { + return $recurrence; + } + } + throw new NotFoundHttpException; + } + + /** + * @codeCoverageIgnore + * Get all of the notes. + */ + public function notes() + { + return $this->morphMany(Note::class, 'noteable'); + } + + /** + * @return HasMany + * @codeCoverageIgnore + */ + public function recurrenceMeta(): HasMany + { + return $this->hasMany(RecurrenceMeta::class); + } + + /** + * @return HasMany + * @codeCoverageIgnore + */ + public function recurrenceRepetitions(): HasMany + { + return $this->hasMany(RecurrenceRepetition::class); + } + + /** + * @return HasMany + * @codeCoverageIgnore + */ + public function recurrenceTransactions(): HasMany + { + return $this->hasMany(RecurrenceTransaction::class); + } + + /** + * @codeCoverageIgnore + * @return BelongsTo + */ + public function transactionCurrency(): BelongsTo + { + return $this->belongsTo(TransactionCurrency::class); + } + + /** + * @codeCoverageIgnore + * @return BelongsTo + */ + public function transactionType(): BelongsTo + { + return $this->belongsTo(TransactionType::class); + } + + /** + * @codeCoverageIgnore + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + +} \ No newline at end of file diff --git a/app/Models/RecurrenceMeta.php b/app/Models/RecurrenceMeta.php new file mode 100644 index 0000000000..a448e015de --- /dev/null +++ b/app/Models/RecurrenceMeta.php @@ -0,0 +1,49 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Models; + + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +/** + * Class RecurrenceMeta + * + * @property string $name + * @property string $value + */ +class RecurrenceMeta extends Model +{ + protected $table = 'recurrences_meta'; + + /** + * @return BelongsTo + * @codeCoverageIgnore + */ + public function recurrence(): BelongsTo + { + return $this->belongsTo(Recurrence::class); + } + +} \ No newline at end of file diff --git a/app/Models/RecurrenceRepetition.php b/app/Models/RecurrenceRepetition.php new file mode 100644 index 0000000000..64a28e1309 --- /dev/null +++ b/app/Models/RecurrenceRepetition.php @@ -0,0 +1,53 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Models; + + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +/** + * Class RecurrenceRepetition + * + * @property string $repetition_type + * @property string $repetition_moment + * @property int $repetition_skip + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $deleted_at + * @property \Carbon\Carbon $updated_at + * @property int $id + */ +class RecurrenceRepetition extends Model +{ + protected $table = 'recurrences_repetitions'; + + /** + * @return BelongsTo + * @codeCoverageIgnore + */ + public function recurrence(): BelongsTo + { + return $this->belongsTo(Recurrence::class); + } +} \ No newline at end of file diff --git a/app/Models/RecurrenceTransaction.php b/app/Models/RecurrenceTransaction.php new file mode 100644 index 0000000000..6446797ffb --- /dev/null +++ b/app/Models/RecurrenceTransaction.php @@ -0,0 +1,105 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Models; + + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; + +/** + * + * Class RecurrenceTransaction + * + * @property int $transaction_currency_id, + * @property int $foreign_currency_id + * @property int $source_account_id + * @property int $destination_account_id + * @property string $amount + * @property string $foreign_amount + * @property string $description + * @property \FireflyIII\Models\TransactionCurrency $transactionCurrency + * @property \FireflyIII\Models\TransactionCurrency $foreignCurrency + * @property \FireflyIII\Models\Account $sourceAccount + * @property \FireflyIII\Models\Account $destinationAccount + * @property \Illuminate\Support\Collection $recurrenceTransactionMeta + */ +class RecurrenceTransaction extends Model +{ + protected $table = 'recurrences_transactions'; + + /** + * @codeCoverageIgnore + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function destinationAccount(): BelongsTo + { + return $this->belongsTo(Account::class); + } + + /** + * @codeCoverageIgnore + * @return BelongsTo + */ + public function foreignCurrency(): BelongsTo + { + return $this->belongsTo(TransactionCurrency::class); + } + + /** + * @return BelongsTo + * @codeCoverageIgnore + */ + public function recurrence(): BelongsTo + { + return $this->belongsTo(Recurrence::class); + } + + /** + * @return HasMany + * @codeCoverageIgnore + */ + public function recurrenceTransactionMeta(): HasMany + { + return $this->hasMany(recurrenceTransactionMeta::class,'rt_id'); + } + + /** + * @codeCoverageIgnore + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function sourceAccount(): BelongsTo + { + return $this->belongsTo(Account::class); + } + + /** + * @codeCoverageIgnore + * @return BelongsTo + */ + public function transactionCurrency(): BelongsTo + { + return $this->belongsTo(TransactionCurrency::class); + } +} \ No newline at end of file diff --git a/app/Models/RecurrenceTransactionMeta.php b/app/Models/RecurrenceTransactionMeta.php new file mode 100644 index 0000000000..3c9d04a730 --- /dev/null +++ b/app/Models/RecurrenceTransactionMeta.php @@ -0,0 +1,49 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Models; + + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +/** + * Class RecurrenceTransactionMeta + * + * @property string $name + * @property string $value + */ +class RecurrenceTransactionMeta extends Model +{ + protected $table = 'rt_meta'; + + /** + * @return BelongsTo + * @codeCoverageIgnore + */ + public function recurrenceTransaction(): BelongsTo + { + return $this->belongsTo(RecurrenceTransaction::class); + } + +} \ No newline at end of file diff --git a/app/Providers/RecurringServiceProvider.php b/app/Providers/RecurringServiceProvider.php new file mode 100644 index 0000000000..d41f54e743 --- /dev/null +++ b/app/Providers/RecurringServiceProvider.php @@ -0,0 +1,63 @@ +. + */ +declare(strict_types=1); + +namespace FireflyIII\Providers; + +use FireflyIII\Repositories\Recurring\RecurringRepository; +use FireflyIII\Repositories\Recurring\RecurringRepositoryInterface; +use Illuminate\Foundation\Application; +use Illuminate\Support\ServiceProvider; + +/** + * @codeCoverageIgnore + * Class RecurringServiceProvider. + */ +class RecurringServiceProvider extends ServiceProvider +{ + /** + * Bootstrap the application services. + */ + public function boot(): void + { + } + + /** + * Register the application services. + */ + public function register(): void + { + $this->app->bind( + RecurringRepositoryInterface::class, + function (Application $app) { + /** @var RecurringRepositoryInterface $repository */ + $repository = app(RecurringRepository::class); + + if ($app->auth->check()) { + $repository->setUser(auth()->user()); + } + + return $repository; + } + ); + } + +} diff --git a/app/Repositories/Recurring/RecurringRepository.php b/app/Repositories/Recurring/RecurringRepository.php new file mode 100644 index 0000000000..79905c1019 --- /dev/null +++ b/app/Repositories/Recurring/RecurringRepository.php @@ -0,0 +1,226 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Repositories\Recurring; + +use Carbon\Carbon; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\Note; +use FireflyIII\Models\Preference; +use FireflyIII\Models\Recurrence; +use FireflyIII\Models\RecurrenceRepetition; +use FireflyIII\User; +use Illuminate\Support\Collection; + + +/** + * + * Class RecurringRepository + */ +class RecurringRepository implements RecurringRepositoryInterface +{ + /** @var User */ + private $user; + + /** + * Returns all of the user's recurring transactions. + * + * @return Collection + */ + public function getActive(): Collection + { + return $this->user->recurrences()->with(['TransactionCurrency', 'TransactionType', 'RecurrenceRepetitions', 'RecurrenceTransactions'])->where( + 'active', 1 + )->get(); + } + + /** + * Get the notes. + * + * @param Recurrence $recurrence + * + * @return string + */ + public function getNoteText(Recurrence $recurrence): string + { + /** @var Note $note */ + $note = $recurrence->notes()->first(); + if (null !== $note) { + return (string)$note->text; + } + + 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 getOccurrences(RecurrenceRepetition $repetition, Carbon $date, int $count = 5): array + { + $return = []; + $mutator = clone $date; + 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++) { + $mutator->addDay(); + $return[] = clone $mutator; + } + 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); + for ($i = 0; $i < $count; $i++) { + $return[] = clone $mutator; + $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++) { + $domCorrected = min($dayOfMonth, $mutator->daysInMonth); + $mutator->day = $domCorrected; + $return[] = clone $mutator; + $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++) { + $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; + $mutator->endOfMonth()->addDay(); + } + break; + case 'yearly': + $date = new Carbon($repetition->repetition_moment); + $date->year = $mutator->year; + if ($mutator > $date) { + $date->addYear(); + } + for ($i = 0; $i < $count; $i++) { + $obj = clone $date; + $obj->addYears($i); + $return[] = $obj; + } + break; + } + + return $return; + } + + /** + * Parse the repetition in a string that is user readable. + * + * @param RecurrenceRepetition $repetition + * + * @return string + * @throws FireflyException + */ + public function repetitionDescription(RecurrenceRepetition $repetition): string + { + /** @var Preference $pref */ + $pref = app('preferences')->getForUser($this->user, 'language', config('firefly.default_language', 'en_US')); + $language = $pref->data; + switch ($repetition->repetition_type) { + default: + throw new FireflyException(sprintf('Cannot translate recurring transaction repetition type "%s"', $repetition->repetition_type)); + break; + case 'daily': + return trans('firefly.recurring_daily', [], $language); + break; + case 'weekly': + $dayOfWeek = trans(sprintf('config.dow_%s', $repetition->repetition_moment), [], $language); + + return trans('firefly.recurring_weekly', ['weekday' => $dayOfWeek], $language); + break; + case 'monthly': + // format a date: + return trans('firefly.recurring_monthly', ['dayOfMonth' => $repetition->repetition_moment], $language); + break; + case 'ndom': + $parts = explode(',', $repetition->repetition_moment); + // first part is number of week, second is weekday. + $dayOfWeek = trans(sprintf('config.dow_%s', $parts[1]), [], $language); + + return trans('firefly.recurring_ndom', ['weekday' => $dayOfWeek, 'dayOfMonth' => $parts[0]], $language); + break; + case 'yearly': + // + $today = new Carbon; + $today->endOfYear(); + $repDate = Carbon::createFromFormat('Y-m-d', $repetition->repetition_moment); + $diffInYears = $today->diffInYears($repDate); + $repDate->addYears($diffInYears); // technically not necessary. + $string = $repDate->formatLocalized(trans('config.month_and_day_no_year')); + + return trans('firefly.recurring_yearly', ['date' => $string], $language); + break; + + } + + } + + /** + * Set user for in repository. + * + * @param User $user + */ + public function setUser(User $user): void + { + $this->user = $user; + } +} \ No newline at end of file diff --git a/app/Repositories/Recurring/RecurringRepositoryInterface.php b/app/Repositories/Recurring/RecurringRepositoryInterface.php new file mode 100644 index 0000000000..d72738b785 --- /dev/null +++ b/app/Repositories/Recurring/RecurringRepositoryInterface.php @@ -0,0 +1,84 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Repositories\Recurring; + +use Carbon\Carbon; +use FireflyIII\Models\Recurrence; +use FireflyIII\Models\RecurrenceRepetition; +use FireflyIII\User; +use Illuminate\Support\Collection; + + +/** + * Interface RecurringRepositoryInterface + * + * @package FireflyIII\Repositories\Recurring + */ +interface RecurringRepositoryInterface +{ + /** + * Returns all of the user's recurring transactions. + * + * @return Collection + */ + public function getActive(): Collection; + + /** + * Get the notes. + * + * @param Recurrence $recurrence + * + * @return string + */ + public function getNoteText(Recurrence $recurrence): string; + + /** + * Calculate the next X iterations starting on the date given in $date. + * Returns an array of Carbon objects. + * + * @param RecurrenceRepetition $repetition + * @param Carbon $date + * @param int $count + * + * @return array + */ + public function getOccurrences(RecurrenceRepetition $repetition, Carbon $date, int $count = 5): array; + + /** + * Parse the repetition in a string that is user readable. + * + * @param RecurrenceRepetition $repetition + * + * @return string + */ + public function repetitionDescription(RecurrenceRepetition $repetition): string; + + /** + * Set user for in repository. + * + * @param User $user + */ + public function setUser(User $user): void; + +} \ No newline at end of file diff --git a/app/Transformers/RecurrenceTransformer.php b/app/Transformers/RecurrenceTransformer.php new file mode 100644 index 0000000000..db984a4c85 --- /dev/null +++ b/app/Transformers/RecurrenceTransformer.php @@ -0,0 +1,256 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Transformers; + + +use Carbon\Carbon; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Factory\CategoryFactory; +use FireflyIII\Models\Recurrence; +use FireflyIII\Models\RecurrenceMeta; +use FireflyIII\Models\RecurrenceRepetition; +use FireflyIII\Models\RecurrenceTransaction; +use FireflyIII\Models\RecurrenceTransactionMeta; +use FireflyIII\Repositories\Bill\BillRepositoryInterface; +use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; +use FireflyIII\Repositories\PiggyBank\PiggyBankRepositoryInterface; +use FireflyIII\Repositories\Recurring\RecurringRepositoryInterface; +use League\Fractal\Resource\Item; +use League\Fractal\TransformerAbstract; +use Symfony\Component\HttpFoundation\ParameterBag; + +/** + * + * Class RecurringTransactionTransformer + */ +class RecurrenceTransformer extends TransformerAbstract +{ + /** @noinspection ClassOverridesFieldOfSuperClassInspection */ + /** + * List of resources possible to include. + * + * @var array + */ + protected $availableIncludes = ['user']; + /** + * List of resources to automatically include + * + * @var array + */ + protected $defaultIncludes = []; + /** @var ParameterBag */ + protected $parameters; + + /** @var RecurringRepositoryInterface */ + protected $repository; + + + public function __construct(ParameterBag $parameters) + { + $this->repository = app(RecurringRepositoryInterface::class); + $this->parameters = $parameters; + } + + /** + * Include user data in end result. + * + * @codeCoverageIgnore + * + * @param Recurrence $recurrence + * + * + * @return Item + */ + public function includeUser(Recurrence $recurrence): Item + { + return $this->item($recurrence->user, new UserTransformer($this->parameters), 'user'); + } + + /** + * Transform the piggy bank. + * + * @param Recurrence $recurrence + * + * @return array + * @throws FireflyException + */ + public function transform(Recurrence $recurrence): array + { + $this->repository->setUser($recurrence->user); + $return = [ + 'id' => (int)$recurrence->id, + 'updated_at' => $recurrence->updated_at->toAtomString(), + 'created_at' => $recurrence->created_at->toAtomString(), + 'transaction_type_id' => $recurrence->transaction_type_id, + 'transaction_type' => $recurrence->transactionType->type, + 'title' => $recurrence->title, + 'description' => $recurrence->description, + 'first_date' => $recurrence->first_date->format('Y-m-d'), + 'latest_date' => null === $recurrence->latest_date ? null : $recurrence->latest_date->format('Y-m-d'), + 'repeat_until' => null === $recurrence->repeat_until ? null : $recurrence->repeat_until->format('Y-m-d'), + 'apply_rules' => $recurrence->apply_rules, + 'active' => $recurrence->active, + 'notes' => $this->repository->getNoteText($recurrence), + 'repetitions' => [], + 'transactions' => [], + 'meta' => [], + 'links' => [ + [ + 'rel' => 'self', + 'uri' => '/recurring/' . $recurrence->id, + ], + ], + ]; + $fromDate = $recurrence->latest_date ?? $recurrence->first_date; + // date in the past? use today: + $today = new Carbon; + $fromDate = $fromDate->lte($today) ? $today : $fromDate; + + /** @var RecurrenceRepetition $repetition */ + foreach ($recurrence->recurrenceRepetitions as $repetition) { + $repetitionArray = [ + 'id' => $repetition->id, + 'updated_at' => $repetition->updated_at->toAtomString(), + 'created_at' => $repetition->created_at->toAtomString(), + 'repetition_type' => $repetition->repetition_type, + 'repetition_moment' => $repetition->repetition_moment, + 'repetition_skip' => (int)$repetition->repetition_skip, + 'description' => $this->repository->repetitionDescription($repetition), + 'occurrences' => [], + ]; + + // get the (future) occurrences for this specific type of repetition: + $occurrences = $this->repository->getOccurrences($repetition, $fromDate, 5); + /** @var Carbon $carbon */ + foreach ($occurrences as $carbon) { + $repetitionArray['occurrences'][] = $carbon->format('Y-m-d'); + } + + $return['repetitions'][] = $repetitionArray; + } + unset($repetitionArray); + + // get all transactions: + /** @var RecurrenceTransaction $transaction */ + foreach ($recurrence->recurrenceTransactions as $transaction) { + $transactionArray = [ + 'currency_id' => $transaction->transaction_currency_id, + 'currency_code' => $transaction->transactionCurrency->code, + 'currency_symbol' => $transaction->transactionCurrency->symbol, + 'currency_dp' => $transaction->transactionCurrency->decimal_places, + 'foreign_currency_id' => $transaction->foreign_currency_id, + 'source_account_id' => $transaction->source_account_id, + 'source_account_name' => $transaction->sourceAccount->name, + 'destination_account_id' => $transaction->destination_account_id, + 'destination_account_name' => $transaction->destinationAccount->name, + 'amount' => $transaction->amount, + 'foreign_amount' => $transaction->foreign_amount, + 'description' => $transaction->description, + 'meta' => [], + ]; + if (null !== $transaction->foreign_currency_id) { + $transactionArray['foreign_currency_code'] = $transaction->foreignCurrency->code; + $transactionArray['foreign_currency_symbol'] = $transaction->foreignCurrency->symbol; + $transactionArray['foreign_currency_dp'] = $transaction->foreignCurrency->decimal_places; + } + + // get meta data for each transaction: + /** @var RecurrenceTransactionMeta $transactionMeta */ + foreach ($transaction->recurrenceTransactionMeta as $transactionMeta) { + $transactionMetaArray = [ + 'name' => $transactionMeta->name, + 'value' => $transactionMeta->value, + ]; + switch ($transactionMeta->name) { + default: + throw new FireflyException(sprintf('Recurrence transformer cannot handle transaction meta-field "%s"', $transactionMeta->name)); + case 'category_name': + /** @var CategoryFactory $factory */ + $factory = app(CategoryFactory::class); + $factory->setUser($recurrence->user); + $category = $factory->findOrCreate(null, $transactionMeta->value); + if (null !== $category) { + $transactionMetaArray['category_id'] = $category->id; + $transactionMetaArray['category_name'] = $category->name; + } + break; + case 'budget_id': + /** @var BudgetRepositoryInterface $repository */ + $repository = app(BudgetRepositoryInterface::class); + $budget = $repository->findNull((int)$transactionMeta->value); + if (null !== $budget) { + $transactionMetaArray['budget_id'] = $budget->id; + $transactionMetaArray['budget_name'] = $budget->name; + } + break; + } + // store transaction meta data in transaction + $transactionArray['meta'][] = $transactionMetaArray; + } + // store transaction in recurrence array. + $return['transactions'][] = $transactionArray; + } + // get all meta data for recurrence itself + /** @var RecurrenceMeta $recurrenceMeta */ + foreach ($recurrence->recurrenceMeta as $recurrenceMeta) { + $recurrenceMetaArray = [ + 'name' => $recurrenceMeta->name, + 'value' => $recurrenceMeta->value, + ]; + switch ($recurrenceMeta->name) { + default: + throw new FireflyException(sprintf('Recurrence transformer cannot handle meta-field "%s"', $recurrenceMeta->name)); + case 'tags': + $recurrenceMetaArray['tags'] = explode(',', $recurrenceMeta->value); + break; + case 'notes': + break; + case 'bill_id': + /** @var BillRepositoryInterface $repository */ + $repository = app(BillRepositoryInterface::class); + $bill = $repository->find((int)$recurrenceMeta->value); + if (null !== $bill) { + $recurrenceMetaArray['bill_id'] = $bill->id; + $recurrenceMetaArray['bill_name'] = $bill->name; + } + break; + case 'piggy_bank_id': + /** @var PiggyBankRepositoryInterface $repository */ + $repository = app(PiggyBankRepositoryInterface::class); + $piggy = $repository->findNull((int)$recurrenceMeta->value); + if (null !== $piggy) { + $recurrenceMetaArray['piggy_bank_id'] = $piggy->id; + $recurrenceMetaArray['piggy_bank_name'] = $piggy->name; + } + break; + } + // store meta date in recurring array + $return['meta'][] = $recurrenceMetaArray; + + } + + return $return; + } + +} \ No newline at end of file diff --git a/app/User.php b/app/User.php index 2ffd076912..28525e3ab4 100644 --- a/app/User.php +++ b/app/User.php @@ -36,6 +36,7 @@ use FireflyIII\Models\ExportJob; use FireflyIII\Models\ImportJob; use FireflyIII\Models\PiggyBank; use FireflyIII\Models\Preference; +use FireflyIII\Models\Recurrence; use FireflyIII\Models\Role; use FireflyIII\Models\Rule; use FireflyIII\Models\RuleGroup; @@ -291,6 +292,17 @@ class User extends Authenticatable return $this->hasMany(Preference::class); } + /** + * @codeCoverageIgnore + * Link to recurring transactions. + * + * @return HasMany + */ + public function recurrences(): HasMany + { + return $this->hasMany(Recurrence::class); + } + /** * @codeCoverageIgnore * Link to roles. diff --git a/config/app.php b/config/app.php index 953ed046ee..5127530c6d 100644 --- a/config/app.php +++ b/config/app.php @@ -98,6 +98,7 @@ return [ FireflyIII\Providers\SearchServiceProvider::class, FireflyIII\Providers\TagServiceProvider::class, FireflyIII\Providers\AdminServiceProvider::class, + FireflyIII\Providers\RecurringServiceProvider::class, ], diff --git a/config/firefly.php b/config/firefly.php index c0fe9ed9ea..7721649b9b 100644 --- a/config/firefly.php +++ b/config/firefly.php @@ -271,6 +271,7 @@ return [ 'piggyBank' => \FireflyIII\Models\PiggyBank::class, 'tj' => \FireflyIII\Models\TransactionJournal::class, 'tag' => \FireflyIII\Models\Tag::class, + 'recurrence' => \FireflyIII\Models\Recurrence::class, 'rule' => \FireflyIII\Models\Rule::class, 'ruleGroup' => \FireflyIII\Models\RuleGroup::class, 'exportJob' => \FireflyIII\Models\ExportJob::class, diff --git a/database/migrations/2018_04_29_174524_changes_for_v474.php b/database/migrations/2018_04_29_174524_changes_for_v474.php index cdc6208f35..c9c79e6e82 100644 --- a/database/migrations/2018_04_29_174524_changes_for_v474.php +++ b/database/migrations/2018_04_29_174524_changes_for_v474.php @@ -37,7 +37,6 @@ class ChangesForV474 extends Migration */ public function down() { - // } /** diff --git a/database/migrations/2018_06_08_200526_changes_for_v475.php b/database/migrations/2018_06_08_200526_changes_for_v475.php new file mode 100644 index 0000000000..053a6e676b --- /dev/null +++ b/database/migrations/2018_06_08_200526_changes_for_v475.php @@ -0,0 +1,129 @@ +increments('id'); + $table->timestamps(); + $table->softDeletes(); + $table->integer('user_id', false, true); + $table->integer('transaction_type_id', false, true); + + $table->string('title', 1024); + $table->text('description'); + + $table->date('first_date'); + $table->date('repeat_until')->nullable(); + $table->date('latest_date')->nullable(); + + $table->boolean('apply_rules')->default(true); + $table->boolean('active')->default(true); + + // also separate: + // category, budget, tags, notes, bill, piggy bank + + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('transaction_type_id')->references('id')->on('transaction_types')->onDelete('cascade'); + } + ); + + Schema::create( + 'recurrences_transactions', function (Blueprint $table) { + $table->increments('id'); + $table->timestamps(); + $table->softDeletes(); + $table->integer('recurrence_id', false, true); + $table->integer('transaction_currency_id', false, true); + $table->integer('foreign_currency_id', false, true)->nullable(); + $table->integer('source_account_id', false, true); + $table->integer('destination_account_id', false, true); + + $table->decimal('amount', 22, 12); + $table->decimal('foreign_amount', 22, 12)->nullable(); + $table->string('description', 1024); + + + $table->foreign('recurrence_id')->references('id')->on('recurrences')->onDelete('cascade'); + $table->foreign('transaction_currency_id')->references('id')->on('transaction_currencies')->onDelete('cascade'); + $table->foreign('foreign_currency_id')->references('id')->on('transaction_currencies')->onDelete('set null'); + $table->foreign('source_account_id')->references('id')->on('accounts')->onDelete('cascade'); + $table->foreign('destination_account_id')->references('id')->on('accounts')->onDelete('cascade'); + } + ); + + + Schema::create( + 'recurrences_repetitions', function (Blueprint $table) { + $table->increments('id'); + $table->timestamps(); + $table->softDeletes(); + $table->integer('recurrence_id', false, true); + $table->string('repetition_type', 50); + $table->string('repetition_moment', 50); + $table->smallInteger('repetition_skip', false, true); + + $table->foreign('recurrence_id')->references('id')->on('recurrences')->onDelete('cascade'); + } + ); + + Schema::create( + 'recurrences_meta', function (Blueprint $table) { + $table->increments('id'); + $table->timestamps(); + $table->softDeletes(); + $table->integer('recurrence_id', false, true); + + $table->string('name', 50); + $table->text('value'); + + $table->foreign('recurrence_id')->references('id')->on('recurrences')->onDelete('cascade'); + } + ); + + Schema::create( + 'rt_meta', function (Blueprint $table) { + $table->increments('id'); + $table->timestamps(); + $table->softDeletes(); + $table->integer('rt_id', false, true); + + $table->string('name', 50); + $table->text('value'); + + $table->foreign('rt_id')->references('id')->on('recurrences_transactions')->onDelete('cascade'); + } + ); + + + } +} diff --git a/public/js/ff/recurring/create.js b/public/js/ff/recurring/create.js new file mode 100644 index 0000000000..78ff8264b6 --- /dev/null +++ b/public/js/ff/recurring/create.js @@ -0,0 +1,170 @@ +/* + * create.js + * Copyright (c) 2017 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 . + */ + +/** global: Modernizr, currencies */ + +$(document).ready(function () { + "use strict"; + if (!Modernizr.inputtypes.date) { + $('input[type="date"]').datepicker( + { + dateFormat: 'yy-mm-dd' + } + ); + } + initializeButtons(); + initializeAutoComplete(); + respondToFirstDateChange(); + $('.switch-button').on('click', switchTransactionType); + $('#ffInput_first_date').on('change', respondToFirstDateChange); + + +}); + +function respondToFirstDateChange() { + var obj = $('#ffInput_first_date'); + var select = $('#ffInput_repetition_type'); + var date = obj.val(); + select.prop('disabled', true); + $.getJSON(suggestUri, {date: date}).fail(function () { + console.error('Could not load repetition suggestions'); + alert('Could not load repetition suggestions'); + }).done(parseRepetitionSuggestions); +} + +function parseRepetitionSuggestions(data) { + + var select = $('#ffInput_repetition_type'); + select.empty(); + for (var k in data) { + if (data.hasOwnProperty(k)) { + select.append($('