From 1dd3018cb295e4bcfdd4c7d7add10791735ab55f Mon Sep 17 00:00:00 2001 From: James Cole Date: Sat, 14 Mar 2020 10:25:12 +0100 Subject: [PATCH] Basic cron job for budgeting. --- app/Console/Commands/Tools/Cron.php | 88 +++++-- app/Jobs/CreateAutoBudgetLimits.php | 276 +++++++++++++++++++++ app/Support/Cronjobs/AbstractCronjob.php | 38 +++ app/Support/Cronjobs/AutoBudgetCronjob.php | 88 +++++++ app/Support/Cronjobs/RecurringCronjob.php | 32 --- app/Support/Navigation.php | 4 +- 6 files changed, 474 insertions(+), 52 deletions(-) create mode 100644 app/Jobs/CreateAutoBudgetLimits.php create mode 100644 app/Support/Cronjobs/AutoBudgetCronjob.php diff --git a/app/Console/Commands/Tools/Cron.php b/app/Console/Commands/Tools/Cron.php index 0c1fb62af0..9f96d58f03 100644 --- a/app/Console/Commands/Tools/Cron.php +++ b/app/Console/Commands/Tools/Cron.php @@ -28,8 +28,10 @@ use Carbon\Carbon; use Exception; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Support\Cronjobs\RecurringCronjob; +use FireflyIII\Support\Cronjobs\AutoBudgetCronjob; use Illuminate\Console\Command; use InvalidArgumentException; +use Log; /** * Class Cron @@ -56,39 +58,38 @@ class Cron extends Command /** * @return int - * @throws Exception */ public function handle(): int { $date = null; try { $date = new Carbon($this->option('date')); - } catch (InvalidArgumentException $e) { + } catch (InvalidArgumentException|Exception $e) { $this->error(sprintf('"%s" is not a valid date', $this->option('date'))); $e->getMessage(); } + $force = (bool)$this->option('force'); - - $recurring = new RecurringCronjob; - $recurring->setForce($this->option('force')); - - // set date in cron job: - if (null !== $date) { - $recurring->setDate($date); - } - + /* + * Fire recurring transaction cron job. + */ try { - $result = $recurring->fire(); + //$this->recurringCronJob($force, $date); } catch (FireflyException $e) { + Log::error($e->getMessage()); + Log::error($e->getTraceAsString()); $this->error($e->getMessage()); + } - return 0; - } - if (false === $result) { - $this->line('The recurring transaction cron job did not fire.'); - } - if (true === $result) { - $this->line('The recurring transaction cron job fired successfully.'); + /* + * Fire auto-budget cron job: + */ + try { + $this->autoBudgetCronJob($force, $date); + } catch (FireflyException $e) { + Log::error($e->getMessage()); + Log::error($e->getTraceAsString()); + $this->error($e->getMessage()); } $this->info('More feedback on the cron jobs can be found in the log files.'); @@ -96,5 +97,54 @@ class Cron extends Command return 0; } + /** + * @param bool $force + * @param Carbon|null $date + * @throws FireflyException + */ + private function autoBudgetCronJob(bool $force, ?Carbon $date) + { + $autoBudget = new AutoBudgetCronjob; + $autoBudget->setForce($force); + // set date in cron job: + if (null !== $date) { + $autoBudget->setDate($date); + } + $result = $autoBudget->fire(); + + if (false === $result) { + $this->line('The auto budget cron job did not fire.'); + } + if (true === $result) { + $this->line('The auto budget cron job fired successfully.'); + } + + } + + /** + * @param bool $force + * @param Carbon|null $date + * + * @throws FireflyException + */ + private function recurringCronJob(bool $force, ?Carbon $date): void + { + $recurring = new RecurringCronjob; + $recurring->setForce($force); + + // set date in cron job: + if (null !== $date) { + $recurring->setDate($date); + } + + $result = $recurring->fire(); + + if (false === $result) { + $this->line('The recurring transaction cron job did not fire.'); + } + if (true === $result) { + $this->line('The recurring transaction cron job fired successfully.'); + } + } } diff --git a/app/Jobs/CreateAutoBudgetLimits.php b/app/Jobs/CreateAutoBudgetLimits.php new file mode 100644 index 0000000000..8fa6e7a5f1 --- /dev/null +++ b/app/Jobs/CreateAutoBudgetLimits.php @@ -0,0 +1,276 @@ +. + */ + +namespace FireflyIII\Jobs; + +use Carbon\Carbon; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Models\AutoBudget; +use FireflyIII\Models\Budget; +use FireflyIII\Models\BudgetLimit; +use FireflyIII\Repositories\Budget\OperationsRepositoryInterface; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Collection; +use Log; + +/** + * Class CreateAutoBudgetLimits + */ +class CreateAutoBudgetLimits implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + /** @var Carbon The current date */ + private $date; + + /** + * Create a new job instance. + * + * @codeCoverageIgnore + * + * @param Carbon $date + */ + public function __construct(?Carbon $date) + { + if (null !== $date) { + $date->startOfDay(); + $this->date = $date; + } + Log::debug(sprintf('Created new CreateAutoBudgetLimits("%s")', $this->date->format('Y-m-d'))); + } + + /** + * Execute the job. + * + * @throws FireflyException + */ + public function handle(): void + { + Log::debug(sprintf('Now at start of CreateAutoBudgetLimits() job for %s.', $this->date->format('D d M Y'))); + $autoBudgets = AutoBudget::get(); + Log::debug(sprintf('Found %d auto budgets.', $autoBudgets->count())); + foreach ($autoBudgets as $autoBudget) { + $this->handleAutoBudget($autoBudget); + } + } + + /** + * @param Carbon $date + */ + public function setDate(Carbon $date): void + { + $date->startOfDay(); + $this->date = $date; + } + + /** + * @param AutoBudget $autoBudget + * @param Carbon $start + * @param Carbon $end + * @param string|null $amount + */ + private function createBudgetLimit(AutoBudget $autoBudget, Carbon $start, Carbon $end, ?string $amount = null) + { + Log::debug(sprintf('No budget limit exist. Must create one for auto-budget #%d', $autoBudget->id)); + if (null !== $amount) { + Log::debug(sprintf('Amount is overruled and will be set to %s', $amount)); + } + $budgetLimit = new BudgetLimit; + $budgetLimit->budget()->associate($autoBudget->budget); + $budgetLimit->transactionCurrency()->associate($autoBudget->transactionCurrency); + $budgetLimit->start_date = $start; + $budgetLimit->end_date = $end; + $budgetLimit->amount = $amount ?? $autoBudget->amount; + $budgetLimit->save(); + + Log::debug(sprintf('Created budget limit #%d.', $budgetLimit->id)); + } + + /** + * @param AutoBudget $autoBudget + */ + private function createRollover(AutoBudget $autoBudget): void + { + Log::debug(sprintf('Will now manage rollover for auto budget #%d', $autoBudget->id)); + // current period: + $start = app('navigation')->startOfPeriod($this->date, $autoBudget->period); + $end = app('navigation')->endOfPeriod($start, $autoBudget->period); + + // which means previous period: + $previousStart = app('navigation')->subtractPeriod($start, $autoBudget->period); + $previousEnd = app('navigation')->endOfPeriod($previousStart, $autoBudget->period); + + Log::debug( + sprintf( + 'Current period is %s-%s, so previous period is %s-%s', + $start->format('Y-m-d'), $end->format('Y-m-d'), + $previousStart->format('Y-m-d'), $previousEnd->format('Y-m-d') + ) + ); + + // has budget limit in previous period? + $budgetLimit = $this->findBudgetLimit($autoBudget->budget, $previousStart, $previousEnd); + + if (null === $budgetLimit) { + Log::debug('No budget limit exists in previous period, so create one.'); + // if not, create it and we're done. + $this->createBudgetLimit($autoBudget, $start, $end); + Log::debug(sprintf('Done with auto budget #%d', $autoBudget->id)); + + return; + } + Log::debug('Budget limit exists for previous period.'); + // if has one, calculate expenses and use that as a base. + $repository = app(OperationsRepositoryInterface::class); + $repository->setUser($autoBudget->budget->user); + $spent = $repository->sumExpenses($previousStart, $previousEnd, null, new Collection([$autoBudget->budget]), $autoBudget->transactionCurrency); + $currencyId = (int)$autoBudget->transaction_currency_id; + $spentAmount = $spent[$currencyId]['sum'] ?? '0'; + Log::debug(sprintf('Spent in previous budget period (%s-%s) is %s', $previousStart->format('Y-m-d'), $previousEnd->format('Y-m-d'), $spentAmount)); + + // previous budget limit + this period + spent + $totalAmount = bcadd(bcadd($budgetLimit->amount, $autoBudget->amount), $spentAmount); + Log::debug(sprintf('Total amount for current budget period will be %s', $totalAmount)); + + if (1 !== bccomp($totalAmount, '0')) { + Log::info(sprintf('The total amount is negative, so it will be reset to %s.', $totalAmount)); + $totalAmount = $autoBudget->amount; + } + + // create budget limit: + $this->createBudgetLimit($autoBudget, $start, $end, $totalAmount); + Log::debug(sprintf('Done with auto budget #%d', $autoBudget->id)); + } + + /** + * @param Budget $budget + * @param Carbon $start + * @param Carbon $end + * + * @return BudgetLimit|null + */ + private function findBudgetLimit(Budget $budget, Carbon $start, Carbon $end): ?BudgetLimit + { + Log::debug( + sprintf( + 'Going to find a budget limit for budget #%d ("%s") between %s and %s', $budget->id, $budget->name, $start->format('Y-m-d'), + $end->format('Y-m-d') + ) + ); + + return $budget->budgetlimits() + ->where('start_date', $start->format('Y-m-d')) + ->where('end_date', $end->format('Y-m-d'))->first(); + } + + /** + * @param AutoBudget $autoBudget + * + * @throws FireflyException + */ + private function handleAutoBudget(AutoBudget $autoBudget): void + { + if (!$this->isMagicDay($autoBudget)) { + Log::info( + sprintf( + 'Today (%s) is not a magic day for %s auto-budget #%d (part of budget #%d "%s")', + $this->date->format('Y-m-d'), $autoBudget->period, $autoBudget->id, $autoBudget->budget->id, $autoBudget->budget->name + ) + ); + Log::debug(sprintf('Done with auto budget #%d', $autoBudget->id)); + return; + } + Log::info( + sprintf( + 'Today (%s) is a magic day for %s auto-budget #%d (part of budget #%d "%s")', + $this->date->format('Y-m-d'), $autoBudget->period, $autoBudget->id, $autoBudget->budget->id, $autoBudget->budget->name + ) + ); + + // get date range for budget limit, based on range in auto-budget + $start = app('navigation')->startOfPeriod($this->date, $autoBudget->period); + $end = app('navigation')->endOfPeriod($start, $autoBudget->period); + + // find budget limit: + $budgetLimit = $this->findBudgetLimit($autoBudget->budget, $start, $end); + + if (null === $budgetLimit && AutoBudget::AUTO_BUDGET_RESET === $autoBudget->auto_budget_type) { + // that's easy: create one. + // do nothing else. + $this->createBudgetLimit($autoBudget, $start, $end); + Log::debug(sprintf('Done with auto budget #%d', $autoBudget->id)); + + return; + } + + if (null === $budgetLimit && AutoBudget::AUTO_BUDGET_ROLLOVER === $autoBudget->auto_budget_type) { + // budget limit exists already, + $this->createRollover($autoBudget); + Log::debug(sprintf('Done with auto budget #%d', $autoBudget->id)); + + return; + } + Log::debug(sprintf('Done with auto budget #%d', $autoBudget->id)); + } + + /** + * @param AutoBudget $autoBudget + * + * @return bool + * @throws FireflyException + */ + private function isMagicDay(AutoBudget $autoBudget): bool + { + switch ($autoBudget->period) { + default: + throw new FireflyException(sprintf('isMagicDay() can\'t handle period "%s"', $autoBudget->period)); + case 'daily': + // every day is magic! + return true; + case 'weekly': + // fire on Monday. + return $this->date->isMonday(); + case 'monthly': + return 1 === $this->date->day; + case 'quarterly': + $format = 'm-d'; + $value = $this->date->format($format); + + return in_array($value, ['01-01', '04-01', '07-01', '10-01'], true); + case 'half_year': + $format = 'm-d'; + $value = $this->date->format($format); + + return in_array($value, ['01-01', '07-01'], true); + break; + case 'yearly': + $format = 'm-d'; + $value = $this->date->format($format); + + return '01-01' === $value; + } + } +} \ No newline at end of file diff --git a/app/Support/Cronjobs/AbstractCronjob.php b/app/Support/Cronjobs/AbstractCronjob.php index d03280f154..5dd9c524c1 100644 --- a/app/Support/Cronjobs/AbstractCronjob.php +++ b/app/Support/Cronjobs/AbstractCronjob.php @@ -23,8 +23,11 @@ declare(strict_types=1); namespace FireflyIII\Support\Cronjobs; +use Carbon\Carbon; +use Exception; /** * Class AbstractCronjob + * * @codeCoverageIgnore */ abstract class AbstractCronjob @@ -32,6 +35,41 @@ abstract class AbstractCronjob /** @var int */ public $timeBetweenRuns = 43200; + /** @var bool */ + protected $force; + + /** @var Carbon */ + protected $date; + + /** + * AbstractCronjob constructor. + * + * @throws Exception + */ + public function __construct() + { + $this->force = false; + $this->date = new Carbon; + } + + + + /** + * @param bool $force + */ + public function setForce(bool $force): void + { + $this->force = $force; + } + + /** + * @param Carbon $date + */ + public function setDate(Carbon $date): void + { + $this->date = $date; + } + /** * @return bool */ diff --git a/app/Support/Cronjobs/AutoBudgetCronjob.php b/app/Support/Cronjobs/AutoBudgetCronjob.php new file mode 100644 index 0000000000..7dba31599c --- /dev/null +++ b/app/Support/Cronjobs/AutoBudgetCronjob.php @@ -0,0 +1,88 @@ +. + */ + +namespace FireflyIII\Support\Cronjobs; + + +use Carbon\Carbon; +use FireflyIII\Jobs\CreateAutoBudgetLimits; +use FireflyIII\Models\Configuration; +use Log; + +/** + * Class AutoBudgetCronjob + */ +class AutoBudgetCronjob extends AbstractCronjob +{ + + /** + * @inheritDoc + */ + public function fire(): bool + { + /** @var Configuration $config */ + $config = app('fireflyconfig')->get('last_ab_job', 0); + $lastTime = (int)$config->data; + $diff = time() - $lastTime; + $diffForHumans = Carbon::now()->diffForHumans(Carbon::createFromTimestamp($lastTime), true); + if (0 === $lastTime) { + Log::info('Auto budget cron-job has never fired before.'); + } + // less than half a day ago: + if ($lastTime > 0 && $diff <= 43200) { + Log::info(sprintf('It has been %s since the auto budget cron-job has fired.', $diffForHumans)); + if (false === $this->force) { + Log::info('The auto budget cron-job will not fire now.'); + + return false; + } + + // fire job regardless. + if (true === $this->force) { + Log::info('Execution of the auto budget cron-job has been FORCED.'); + } + } + + if ($lastTime > 0 && $diff > 43200) { + Log::info(sprintf('It has been %s since the auto budget cron-job has fired. It will fire now!', $diffForHumans)); + } + + $this->fireAutoBudget(); + + app('preferences')->mark(); + + return true; + } + + /** + * + */ + private function fireAutoBudget(): void + { + Log::info(sprintf('Will now fire auto budget cron job task for date "%s".', $this->date->format('Y-m-d'))); + /** @var CreateAutoBudgetLimits $job */ + $job = app(CreateAutoBudgetLimits::class); + $job->setDate($this->date); + $job->handle(); + app('fireflyconfig')->set('last_ab_job', (int)$this->date->format('U')); + Log::info('Done with auto budget cron job task.'); + } +} \ No newline at end of file diff --git a/app/Support/Cronjobs/RecurringCronjob.php b/app/Support/Cronjobs/RecurringCronjob.php index 8c851d6a92..dfd7fd413b 100644 --- a/app/Support/Cronjobs/RecurringCronjob.php +++ b/app/Support/Cronjobs/RecurringCronjob.php @@ -34,38 +34,6 @@ use Log; */ class RecurringCronjob extends AbstractCronjob { - /** @var bool */ - private $force; - - /** @var Carbon */ - private $date; - - /** - * RecurringCronjob constructor. - * @throws \Exception - */ - public function __construct() - { - $this->force = false; - $this->date = new Carbon; - } - - /** - * @param bool $force - */ - public function setForce(bool $force): void - { - $this->force = $force; - } - - /** - * @param Carbon $date - */ - public function setDate(Carbon $date): void - { - $this->date = $date; - } - /** * @return bool * @throws FireflyException diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index df0dbe1c50..581e3a4663 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -183,6 +183,7 @@ class Navigation 'quarterly' => 'addMonths', '6M' => 'addMonths', 'half-year' => 'addMonths', + 'half_year' => 'addMonths', 'year' => 'addYear', 'yearly' => 'addYear', '1Y' => 'addYear', @@ -192,10 +193,11 @@ class Navigation '3M' => 3, 'quarterly' => 3, 'half-year' => 6, + 'half_year' => 6, '6M' => 6, ]; - $subDay = ['week', 'weekly', '1W', 'month', 'monthly', '1M', '3M', 'quarter', 'quarterly', '6M', 'half-year', '1Y', 'year', 'yearly']; + $subDay = ['week', 'weekly', '1W', 'month', 'monthly', '1M', '3M', 'quarter', 'quarterly', '6M', 'half-year', 'half_year', '1Y', 'year', 'yearly']; // if the range is custom, the end of the period // is another X days (x is the difference between start)