diff --git a/app/Console/Commands/Tools/Cron.php b/app/Console/Commands/Tools/Cron.php index 720ac0e43c..f838791e51 100644 --- a/app/Console/Commands/Tools/Cron.php +++ b/app/Console/Commands/Tools/Cron.php @@ -28,6 +28,7 @@ use Carbon\Carbon; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Support\Cronjobs\AutoBudgetCronjob; use FireflyIII\Support\Cronjobs\RecurringCronjob; +use FireflyIII\Support\Cronjobs\BillWarningCronjob; use Illuminate\Console\Command; use InvalidArgumentException; use Log; @@ -66,7 +67,7 @@ class Cron extends Command } catch (InvalidArgumentException $e) { $this->error(sprintf('"%s" is not a valid date', $this->option('date'))); } - $force = (bool)$this->option('force'); + $force = (bool) $this->option('force'); /* * Fire recurring transaction cron job. @@ -90,6 +91,17 @@ class Cron extends Command $this->error($e->getMessage()); } + /* + * Fire bill warning cron job + */ + try { + $this->billWarningCronJob($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.'); return 0; @@ -150,4 +162,32 @@ class Cron extends Command } } + + /** + * @param bool $force + * @param Carbon|null $date + * + */ + private function billWarningCronJob(bool $force, ?Carbon $date): void + { + $autoBudget = new BillWarningCronjob; + $autoBudget->setForce($force); + // set date in cron job: + if (null !== $date) { + $autoBudget->setDate($date); + } + + $autoBudget->fire(); + + if ($autoBudget->jobErrored) { + $this->error(sprintf('Error in "bill warnings" cron: %s', $autoBudget->message)); + } + if ($autoBudget->jobFired) { + $this->error(sprintf('"Send bill warnings" cron fired: %s', $autoBudget->message)); + } + if ($autoBudget->jobSucceeded) { + $this->error(sprintf('"Send bill warnings" cron ran with success: %s', $autoBudget->message)); + } + + } } diff --git a/app/Events/WarnUserAboutBill.php b/app/Events/WarnUserAboutBill.php new file mode 100644 index 0000000000..d2819ffad3 --- /dev/null +++ b/app/Events/WarnUserAboutBill.php @@ -0,0 +1,54 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Events; + +use FireflyIII\Models\Bill; +use Illuminate\Queue\SerializesModels; + +/** + * Class WarnUserAboutBill. + * + * @codeCoverageIgnore + */ +class WarnUserAboutBill extends Event +{ + use SerializesModels; + + public Bill $bill; + public string $field; + public int $diff; + + /** + * @param Bill $bill + * @param string $field + * @param int $diff + */ + public function __construct(Bill $bill, string $field, int $diff) + { + $this->bill = $bill; + $this->field = $field; + $this->diff = $diff; + } +} diff --git a/app/Factory/BillFactory.php b/app/Factory/BillFactory.php index 8bde75a9ea..bf604b45c1 100644 --- a/app/Factory/BillFactory.php +++ b/app/Factory/BillFactory.php @@ -51,7 +51,7 @@ class BillFactory { Log::debug(sprintf('Now in %s', __METHOD__), $data); $factory = app(TransactionCurrencyFactory::class); - $currency = $factory->find((int)($data['currency_id'] ?? null), (string)($data['currency_code'] ?? null)) ?? + $currency = $factory->find((int) ($data['currency_id'] ?? null), (string) ($data['currency_code'] ?? null)) ?? app('amount')->getDefaultCurrencyByUser($this->user); try { @@ -82,7 +82,7 @@ class BillFactory } if (array_key_exists('notes', $data)) { - $this->updateNote($bill, (string)$data['notes']); + $this->updateNote($bill, (string) $data['notes']); } $objectGroupTitle = $data['object_group_title'] ?? ''; if ('' !== $objectGroupTitle) { @@ -93,7 +93,7 @@ class BillFactory } } // try also with ID: - $objectGroupId = (int)($data['object_group_id'] ?? 0); + $objectGroupId = (int) ($data['object_group_id'] ?? 0); if (0 !== $objectGroupId) { $objectGroup = $this->findObjectGroupById($objectGroupId); if (null !== $objectGroup) { @@ -113,8 +113,8 @@ class BillFactory */ public function find(?int $billId, ?string $billName): ?Bill { - $billId = (int)$billId; - $billName = (string)$billName; + $billId = (int) $billId; + $billName = (string) $billName; $bill = null; // first find by ID: if ($billId > 0) { diff --git a/app/Handlers/Events/BillEventHandler.php b/app/Handlers/Events/BillEventHandler.php new file mode 100644 index 0000000000..d1bee3b26d --- /dev/null +++ b/app/Handlers/Events/BillEventHandler.php @@ -0,0 +1,41 @@ +bill; + $field = $event->field; + $diff = $event->diff; + $user = $bill->user; + $address = $user->email; + $ipAddress = request()?->ip(); + + // see if user has alternative email address: + $pref = app('preferences')->getForUser($user, 'remote_guard_alt_email'); + if (null !== $pref) { + $address = $pref->data; + } + + // send message: + Mail::to($address)->send(new BillWarningMail($bill, $field, $diff, $ipAddress)); + + + Log::debug('warnAboutBill'); + } + +} \ No newline at end of file diff --git a/app/Http/Controllers/Bill/CreateController.php b/app/Http/Controllers/Bill/CreateController.php index 5eaa4c127b..0d19198c05 100644 --- a/app/Http/Controllers/Bill/CreateController.php +++ b/app/Http/Controllers/Bill/CreateController.php @@ -102,6 +102,7 @@ class CreateController extends Controller public function store(BillStoreRequest $request): RedirectResponse { $billData = $request->getBillData(); + $billData['active'] = true; try { $bill = $this->repository->store($billData); diff --git a/app/Http/Controllers/Bill/EditController.php b/app/Http/Controllers/Bill/EditController.php index aa9a47b16a..14a7d62a3b 100644 --- a/app/Http/Controllers/Bill/EditController.php +++ b/app/Http/Controllers/Bill/EditController.php @@ -53,7 +53,7 @@ class EditController extends Controller $this->middleware( function ($request, $next) { - app('view')->share('title', (string)trans('firefly.bills')); + app('view')->share('title', (string) trans('firefly.bills')); app('view')->share('mainTitleIcon', 'fa-calendar-o'); $this->attachments = app(AttachmentHelperInterface::class); $this->repository = app(BillRepositoryInterface::class); @@ -78,10 +78,10 @@ class EditController extends Controller $billPeriods = config('firefly.bill_periods'); foreach ($billPeriods as $current) { - $periods[$current] = (string)trans('firefly.' . $current); + $periods[$current] = (string) trans('firefly.' . $current); } - $subTitle = (string)trans('firefly.edit_bill', ['name' => $bill->name]); + $subTitle = (string) trans('firefly.edit_bill', ['name' => $bill->name]); // put previous url in session if not redirect from store (not "return_to_edit"). if (true !== session('bills.edit.fromUpdate')) { @@ -89,8 +89,8 @@ class EditController extends Controller } $currency = app('amount')->getDefaultCurrency(); - $bill->amount_min = round((float)$bill->amount_min, $currency->decimal_places); - $bill->amount_max = round((float)$bill->amount_max, $currency->decimal_places); + $bill->amount_min = round((float) $bill->amount_min, $currency->decimal_places); + $bill->amount_max = round((float) $bill->amount_max, $currency->decimal_places); $rules = $this->repository->getRulesForBill($bill); $defaultCurrency = app('amount')->getDefaultCurrency(); @@ -98,9 +98,11 @@ class EditController extends Controller $hasOldInput = null !== $request->old('_token'); $preFilled = [ + 'bill_end_date' => $bill->end_date, + 'extension_date' => $bill->extension_date, 'notes' => $this->repository->getNoteText($bill), 'transaction_currency_id' => $bill->transaction_currency_id, - 'active' => $hasOldInput ? (bool)$request->old('active') : $bill->active, + 'active' => $hasOldInput ? (bool) $request->old('active') : $bill->active, 'object_group' => $bill->objectGroups->first() ? $bill->objectGroups->first()->title : '', ]; @@ -123,7 +125,7 @@ class EditController extends Controller $billData = $request->getBillData(); $bill = $this->repository->update($bill, $billData); - $request->session()->flash('success', (string)trans('firefly.updated_bill', ['name' => $bill->name])); + $request->session()->flash('success', (string) trans('firefly.updated_bill', ['name' => $bill->name])); app('preferences')->mark(); /** @var array $files */ @@ -132,7 +134,7 @@ class EditController extends Controller $this->attachments->saveAttachmentsForModel($bill, $files); } if (null !== $files && auth()->user()->hasRole('demo')) { - session()->flash('info', (string)trans('firefly.no_att_demo_user')); + session()->flash('info', (string) trans('firefly.no_att_demo_user')); } // flash messages @@ -141,7 +143,7 @@ class EditController extends Controller } $redirect = redirect($this->getPreviousUri('bills.edit.uri')); - if (1 === (int)$request->get('return_to_edit')) { + if (1 === (int) $request->get('return_to_edit')) { $request->session()->put('bills.edit.fromUpdate', true); diff --git a/app/Http/Controllers/Bill/IndexController.php b/app/Http/Controllers/Bill/IndexController.php index f5cb3bf78e..399938a81d 100644 --- a/app/Http/Controllers/Bill/IndexController.php +++ b/app/Http/Controllers/Bill/IndexController.php @@ -136,8 +136,9 @@ class IndexController extends Controller // summarise per currency / per group. $sums = $this->getSums($bills); $totals = $this->getTotals($sums); + $today = now()->startOfDay(); - return view('bills.index', compact('bills', 'sums', 'total', 'totals')); + return view('bills.index', compact('bills', 'sums', 'total', 'totals','today')); } /** diff --git a/app/Http/Requests/BillStoreRequest.php b/app/Http/Requests/BillStoreRequest.php index aec73c412a..ec1d0f1077 100644 --- a/app/Http/Requests/BillStoreRequest.php +++ b/app/Http/Requests/BillStoreRequest.php @@ -47,6 +47,8 @@ class BillStoreRequest extends FormRequest 'currency_code' => '', 'amount_max' => $this->string('amount_max'), 'date' => $this->getCarbonDate('date'), + 'end_date' => $this->getCarbonDate('bill_end_date'), + 'extension_date' => $this->getCarbonDate('extension_date'), 'repeat_freq' => $this->string('repeat_freq'), 'skip' => $this->integer('skip'), 'notes' => $this->stringWithNewlines('notes'), @@ -68,6 +70,8 @@ class BillStoreRequest extends FormRequest 'amount_max' => 'required|numeric|gt:0|max:1000000000', 'transaction_currency_id' => 'required|exists:transaction_currencies,id', 'date' => 'required|date', + 'bill_end_date' => 'nullable|date', + 'extension_date' => 'nullable|date', 'repeat_freq' => sprintf('required|in:%s', join(',', config('firefly.bill_periods'))), 'skip' => 'required|integer|gte:0|lte:31', 'active' => 'boolean', diff --git a/app/Http/Requests/BillUpdateRequest.php b/app/Http/Requests/BillUpdateRequest.php index 259fe629d3..359dfc7bf0 100644 --- a/app/Http/Requests/BillUpdateRequest.php +++ b/app/Http/Requests/BillUpdateRequest.php @@ -48,6 +48,8 @@ class BillUpdateRequest extends FormRequest 'currency_code' => '', 'amount_max' => $this->string('amount_max'), 'date' => $this->getCarbonDate('date'), + 'end_date' => $this->getCarbonDate('bill_end_date'), + 'extension_date' => $this->getCarbonDate('extension_date'), 'repeat_freq' => $this->string('repeat_freq'), 'skip' => $this->integer('skip'), 'notes' => $this->stringWithNewlines('notes'), @@ -72,6 +74,8 @@ class BillUpdateRequest extends FormRequest 'amount_max' => 'required|numeric|gt:0|max:1000000000', 'transaction_currency_id' => 'required|exists:transaction_currencies,id', 'date' => 'required|date', + 'bill_end_date' => 'nullable|date', + 'extension_date' => 'nullable|date', 'repeat_freq' => sprintf('required|in:%s', join(',', config('firefly.bill_periods'))), 'skip' => 'required|integer|gte:0|lte:31', 'active' => 'boolean', diff --git a/app/Jobs/CreateAutoBudgetLimits.php b/app/Jobs/CreateAutoBudgetLimits.php index d66fd58c08..2f30b0fd53 100644 --- a/app/Jobs/CreateAutoBudgetLimits.php +++ b/app/Jobs/CreateAutoBudgetLimits.php @@ -130,7 +130,7 @@ class CreateAutoBudgetLimits implements ShouldQueue // find budget limit: $budgetLimit = $this->findBudgetLimit($autoBudget->budget, $start, $end); - if (null === $budgetLimit && AutoBudget::AUTO_BUDGET_RESET === (int)$autoBudget->auto_budget_type) { + if (null === $budgetLimit && AutoBudget::AUTO_BUDGET_RESET === (int) $autoBudget->auto_budget_type) { // that's easy: create one. // do nothing else. $this->createBudgetLimit($autoBudget, $start, $end); @@ -139,7 +139,7 @@ class CreateAutoBudgetLimits implements ShouldQueue return; } - if (null === $budgetLimit && AutoBudget::AUTO_BUDGET_ROLLOVER === (int)$autoBudget->auto_budget_type) { + if (null === $budgetLimit && AutoBudget::AUTO_BUDGET_ROLLOVER === (int) $autoBudget->auto_budget_type) { // budget limit exists already, $this->createRollover($autoBudget); Log::debug(sprintf('Done with auto budget #%d', $autoBudget->id)); @@ -277,7 +277,7 @@ class CreateAutoBudgetLimits implements ShouldQueue $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; + $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)); diff --git a/app/Jobs/CreateRecurringTransactions.php b/app/Jobs/CreateRecurringTransactions.php index add524c435..c278908e6c 100644 --- a/app/Jobs/CreateRecurringTransactions.php +++ b/app/Jobs/CreateRecurringTransactions.php @@ -430,7 +430,7 @@ class CreateRecurringTransactions implements ShouldQueue 'type' => strtolower($recurrence->transactionType->type), 'date' => $date, 'user' => $recurrence->user_id, - 'currency_id' => (int)$transaction->transaction_currency_id, + 'currency_id' => (int) $transaction->transaction_currency_id, 'currency_code' => null, 'description' => $transactions->first()->description, 'amount' => $transaction->amount, @@ -447,9 +447,9 @@ class CreateRecurringTransactions implements ShouldQueue 'foreign_amount' => $transaction->foreign_amount, 'reconciled' => false, 'identifier' => $index, - 'recurrence_id' => (int)$recurrence->id, + 'recurrence_id' => (int) $recurrence->id, 'order' => $index, - 'notes' => (string)trans('firefly.created_from_recurrence', ['id' => $recurrence->id, 'title' => $recurrence->title]), + 'notes' => (string) trans('firefly.created_from_recurrence', ['id' => $recurrence->id, 'title' => $recurrence->title]), 'tags' => $this->repository->getTags($transaction), 'piggy_bank_id' => $this->repository->getPiggyBank($transaction), 'piggy_bank_name' => null, diff --git a/app/Jobs/SendWebhookMessage.php b/app/Jobs/SendWebhookMessage.php index bcc9808f3f..26923829da 100644 --- a/app/Jobs/SendWebhookMessage.php +++ b/app/Jobs/SendWebhookMessage.php @@ -24,7 +24,6 @@ declare(strict_types=1); namespace FireflyIII\Jobs; -use Log; use FireflyIII\Models\WebhookMessage; use FireflyIII\Services\Webhook\WebhookSenderInterface; use Illuminate\Bus\Queueable; @@ -32,6 +31,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Log; /** * Class SendWebhookMessage diff --git a/app/Jobs/WarnAboutBills.php b/app/Jobs/WarnAboutBills.php new file mode 100644 index 0000000000..64f91c22f9 --- /dev/null +++ b/app/Jobs/WarnAboutBills.php @@ -0,0 +1,153 @@ +startOfDay(); + $this->date = $newDate; + } + if (null === $date) { + $newDate = new Carbon; + $newDate->startOfDay(); + $this->date = $newDate; + } + $this->force = false; + + Log::debug(sprintf('Created new WarnAboutBills("%s")', $this->date->format('Y-m-d'))); + } + + /** + * Execute the job. + */ + public function handle(): void + { + Log::debug(sprintf('Now at start of WarnAboutBills() job for %s.', $this->date->format('D d M Y'))); + $bills = Bill::all(); + /** @var Bill $bill */ + foreach ($bills as $bill) { + Log::debug(sprintf('Now checking bill #%d ("%s")', $bill->id, $bill->name)); + if ($this->hasDateFields($bill)) { + if ($this->needsWarning($bill, 'end_date')) { + $this->sendWarning($bill, 'end_date'); + } + if ($this->needsWarning($bill, 'extension_date')) { + $this->sendWarning($bill, 'extension_date'); + } + } + } + Log::debug('Done with handle()'); + + // clear cache: + app('preferences')->mark(); + } + + /** + * @param Bill $bill + * @return bool + */ + private function hasDateFields(Bill $bill): bool + { + if (false === $bill->active) { + Log::debug('Bill is not active.'); + return false; + } + if (null === $bill->end_date && null === $bill->extension_date) { + Log::debug('Bill has no date fields.'); + return false; + } + return true; + } + + /** + * @param Bill $bill + * @param string $field + * @return bool + */ + private function needsWarning(Bill $bill, string $field): bool + { + if (null === $bill->$field) { + return false; + } + $diff = $this->getDiff($bill, $field); + $list = config('firefly.bill_reminder_periods'); + Log::debug(sprintf('Difference in days for field "%s" ("%s") is %d day(s)', $field, $bill->$field->format('Y-m-d'), $diff)); + if (in_array($diff, $list, true)) { + return true; + } + return false; + } + + /** + * @param Bill $bill + * @param string $field + * @return int + */ + private function getDiff(Bill $bill, string $field): int + { + $today = clone $this->date; + $carbon = clone $bill->$field; + return $today->diffInDays($carbon, false); + } + + /** + * @param Bill $bill + * @param string $field + * @return void + */ + private function sendWarning(Bill $bill, string $field): void + { + $diff = $this->getDiff($bill, $field); + Log::debug('Will now send warning!'); + event(new WarnUserAboutBill($bill, $field, $diff)); + } + + /** + * @param Carbon $date + */ + public function setDate(Carbon $date): void + { + $newDate = clone $date; + $newDate->startOfDay(); + $this->date = $newDate; + } + + /** + * @param bool $force + */ + public function setForce(bool $force): void + { + $this->force = $force; + } +} \ No newline at end of file diff --git a/app/Mail/AdminTestMail.php b/app/Mail/AdminTestMail.php index b9fe1bc168..007ae979e8 100644 --- a/app/Mail/AdminTestMail.php +++ b/app/Mail/AdminTestMail.php @@ -37,10 +37,8 @@ class AdminTestMail extends Mailable { use Queueable, SerializesModels; - /** @var string Email address of admin */ - public $email; - /** @var string IP address of admin */ - public $ipAddress; + public string $email; + public string $ipAddress; /** * ConfirmEmailChangeMail constructor. diff --git a/app/Mail/BillWarningMail.php b/app/Mail/BillWarningMail.php new file mode 100644 index 0000000000..9568411b43 --- /dev/null +++ b/app/Mail/BillWarningMail.php @@ -0,0 +1,52 @@ +bill = $bill; + $this->field = $field; + $this->diff = $diff; + $this->ipAddress = $ipAddress; + } + + /** + * Build the message. + * + * @return $this + */ + public function build(): self + { + $subject = (string) trans(sprintf('email.bill_warning_subject_%s', $this->field), ['diff' => $this->diff, 'name' => $this->bill->name]); + if (0 === $this->diff) { + $subject = (string) trans(sprintf('email.bill_warning_subject_now_%s', $this->field), ['diff' => $this->diff, 'name' => $this->bill->name]); + } + + return $this + ->view('emails.bill-warning-html') + ->text('emails.bill-warning-text') + ->subject($subject); + } +} diff --git a/app/Mail/ConfirmEmailChangeMail.php b/app/Mail/ConfirmEmailChangeMail.php index d557fbe989..946e5068d3 100644 --- a/app/Mail/ConfirmEmailChangeMail.php +++ b/app/Mail/ConfirmEmailChangeMail.php @@ -37,14 +37,10 @@ class ConfirmEmailChangeMail extends Mailable { use Queueable, SerializesModels; - /** @var string IP address of user */ - public $ipAddress; - /** @var string New email address */ - public $newEmail; - /** @var string Old email address */ - public $oldEmail; - /** @var string Confirmation link */ - public $uri; + public string $ipAddress; + public string $newEmail; + public string $oldEmail; + public string $uri; /** * ConfirmEmailChangeMail constructor. @@ -70,6 +66,6 @@ class ConfirmEmailChangeMail extends Mailable public function build(): self { return $this->view('emails.confirm-email-change-html')->text('emails.confirm-email-change-text') - ->subject((string)trans('email.email_change_subject')); + ->subject((string) trans('email.email_change_subject')); } } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 788850eddd..7a549c66aa 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -37,6 +37,7 @@ use FireflyIII\Events\StoredTransactionGroup; use FireflyIII\Events\UpdatedAccount; use FireflyIII\Events\UpdatedTransactionGroup; use FireflyIII\Events\UserChangedEmail; +use FireflyIII\Events\WarnUserAboutBill; use FireflyIII\Mail\OAuthTokenCreatedMail; use FireflyIII\Models\PiggyBank; use FireflyIII\Models\PiggyBankRepetition; @@ -134,6 +135,11 @@ class EventServiceProvider extends ServiceProvider UpdatedAccount::class => [ 'FireflyIII\Handlers\Events\UpdatedAccountEventHandler@recalculateCredit', ], + + // bill related events: + WarnUserAboutBill::class => [ + 'FireflyIII\Handlers\Events\BillEventHandler@warnAboutBill', + ], ]; /** diff --git a/app/Services/Internal/Update/BillUpdateService.php b/app/Services/Internal/Update/BillUpdateService.php index 49430f51be..a25e723937 100644 --- a/app/Services/Internal/Update/BillUpdateService.php +++ b/app/Services/Internal/Update/BillUpdateService.php @@ -166,6 +166,12 @@ class BillUpdateService if (array_key_exists('active', $data)) { $bill->active = $data['active']; } + if(array_key_exists('end_date', $data)) { + $bill->end_date = $data['end_date']; + } + if(array_key_exists('extension_date', $data)) { + $bill->extension_date = $data['extension_date']; + } $bill->match = 'EMPTY'; $bill->automatch = true; diff --git a/app/Support/Cronjobs/BillWarningCronjob.php b/app/Support/Cronjobs/BillWarningCronjob.php new file mode 100644 index 0000000000..f91fa5f1a3 --- /dev/null +++ b/app/Support/Cronjobs/BillWarningCronjob.php @@ -0,0 +1,102 @@ +. + */ + +declare(strict_types=1); + +namespace FireflyIII\Support\Cronjobs; + +use Carbon\Carbon; +use FireflyIII\Exceptions\FireflyException; +use FireflyIII\Jobs\WarnAboutBills; +use FireflyIII\Models\Configuration; +use Log; + +/** + * Class BillWarningCronjob + */ +class BillWarningCronjob extends AbstractCronjob +{ + /** + * @throws FireflyException + */ + public function fire(): void + { + Log::debug(sprintf('Now in %s', __METHOD__)); + /** @var Configuration $config */ + $config = app('fireflyconfig')->get('last_bw_job', 0); + $lastTime = (int)$config->data; + $diff = time() - $lastTime; + $diffForHumans = Carbon::now()->diffForHumans(Carbon::createFromTimestamp($lastTime), null, true); + + if (0 === $lastTime) { + Log::info('The bill warning 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 bill warning cron-job has fired.', $diffForHumans)); + if (false === $this->force) { + Log::info('The cron-job will not fire now.'); + $this->message = sprintf('It has been %s since the bill warning cron-job has fired. It will not fire now.', $diffForHumans); + $this->jobFired = false; + $this->jobErrored = false; + $this->jobSucceeded = false; + + return; + } + + // fire job regardless. + if (true === $this->force) { + Log::info('Execution of the bill warning cron-job has been FORCED.'); + } + } + + if ($lastTime > 0 && $diff > 43200) { + Log::info(sprintf('It has been %s since the bill warning cron-job has fired. It will fire now!', $diffForHumans)); + } + + $this->fireWarnings(); + + app('preferences')->mark(); + } + + /** + * + */ + private function fireWarnings(): void + { + Log::info(sprintf('Will now fire bill warning job task for date "%s".', $this->date->format('Y-m-d H:i:s'))); + /** @var WarnAboutBills $job */ + $job = app(WarnAboutBills::class); + $job->setDate($this->date); + $job->setForce($this->force); + $job->handle(); + + // get stuff from job: + $this->jobFired = true; + $this->jobErrored = false; + $this->jobSucceeded = true; + $this->message = 'Bill warning cron job fired successfully.'; + + app('fireflyconfig')->set('last_bw_job', (int)$this->date->format('U')); + Log::info(sprintf('Marked the last time this job has run as "%s" (%d)', $this->date->format('Y-m-d H:i:s'), (int)$this->date->format('U'))); + Log::info('Done with bill warning cron job task.'); + } +} diff --git a/app/Support/Twig/General.php b/app/Support/Twig/General.php index d5bb131df4..c6631a9961 100644 --- a/app/Support/Twig/General.php +++ b/app/Support/Twig/General.php @@ -68,6 +68,7 @@ class General extends AbstractExtension $this->getMetaField(), $this->hasRole(), $this->getRootSearchOperator(), + $this->carbonize() ]; } @@ -395,6 +396,19 @@ class General extends AbstractExtension ); } + /** + * @return TwigFunction + */ + protected function carbonize(): TwigFunction + { + return new TwigFunction( + 'carbonize', + static function (string $date): Carbon { + return new Carbon($date); + } + ); + } + /** * Will return true if the user is of role X. * diff --git a/config/firefly.php b/config/firefly.php index 90c3503bf4..41739d66e3 100644 --- a/config/firefly.php +++ b/config/firefly.php @@ -221,6 +221,7 @@ return [ TransactionJournal::class, Recurrence::class, ], + 'bill_reminder_periods' => [90, 30, 14, 7, 0], 'valid_view_ranges' => ['1D', '1W', '1M', '3M', '6M', '1Y',], 'allowedMimes' => [ /* plain files */ @@ -485,7 +486,7 @@ return [ 'convert_transfer', ], - 'test-triggers' => [ + 'test-triggers' => [ 'limit' => 10, 'range' => 200, ], @@ -708,7 +709,7 @@ return [ 'import_hash', 'import_hash_v2', 'external_id', 'original_source', // recurring transactions - 'recurrence_total', 'recurrence_count' + 'recurrence_total', 'recurrence_count', ], 'webhooks' => [ 'max_attempts' => env('WEBHOOK_MAX_ATTEMPTS', 3), diff --git a/resources/lang/en_US/email.php b/resources/lang/en_US/email.php index e282644bcf..33123ee252 100644 --- a/resources/lang/en_US/email.php +++ b/resources/lang/en_US/email.php @@ -24,82 +24,97 @@ declare(strict_types=1); return [ // common items - 'greeting' => 'Hi there,', - 'closing' => 'Beep boop,', - 'signature' => 'The Firefly III Mail Robot', - 'footer_ps' => 'PS: This message was sent because a request from IP :ipAddress triggered it.', + 'greeting' => 'Hi there,', + 'closing' => 'Beep boop,', + 'signature' => 'The Firefly III Mail Robot', + 'footer_ps' => 'PS: This message was sent because a request from IP :ipAddress triggered it.', // admin test - 'admin_test_subject' => 'A test message from your Firefly III installation', - 'admin_test_body' => 'This is a test message from your Firefly III instance. It was sent to :email.', + 'admin_test_subject' => 'A test message from your Firefly III installation', + 'admin_test_body' => 'This is a test message from your Firefly III instance. It was sent to :email.', // new IP - 'login_from_new_ip' => 'New login on Firefly III', - 'new_ip_body' => 'Firefly III detected a new login on your account from an unknown IP address. If you never logged in from the IP address below, or it has been more than six months ago, Firefly III will warn you.', - 'new_ip_warning' => 'If you recognize this IP address or the login, you can ignore this message. If you didn\'t login, of if you have no idea what this is about, verify your password security, change it, and log out all other sessions. To do this, go to your profile page. Of course you have 2FA enabled already, right? Stay safe!', - 'ip_address' => 'IP address', - 'host_name' => 'Host', - 'date_time' => 'Date + time', + 'login_from_new_ip' => 'New login on Firefly III', + 'new_ip_body' => 'Firefly III detected a new login on your account from an unknown IP address. If you never logged in from the IP address below, or it has been more than six months ago, Firefly III will warn you.', + 'new_ip_warning' => 'If you recognize this IP address or the login, you can ignore this message. If you didn\'t login, of if you have no idea what this is about, verify your password security, change it, and log out all other sessions. To do this, go to your profile page. Of course you have 2FA enabled already, right? Stay safe!', + 'ip_address' => 'IP address', + 'host_name' => 'Host', + 'date_time' => 'Date + time', // access token created - 'access_token_created_subject' => 'A new access token was created', - 'access_token_created_body' => 'Somebody (hopefully you) just created a new Firefly III API Access Token for your user account.', - 'access_token_created_explanation' => 'With this token, they can access all of your financial records through the Firefly III API.', - 'access_token_created_revoke' => 'If this wasn\'t you, please revoke this token as soon as possible at :url.', + 'access_token_created_subject' => 'A new access token was created', + 'access_token_created_body' => 'Somebody (hopefully you) just created a new Firefly III API Access Token for your user account.', + 'access_token_created_explanation' => 'With this token, they can access all of your financial records through the Firefly III API.', + 'access_token_created_revoke' => 'If this wasn\'t you, please revoke this token as soon as possible at :url.', // registered - 'registered_subject' => 'Welcome to Firefly III!', - 'registered_welcome' => 'Welcome to Firefly III. Your registration has made it, and this email is here to confirm it. Yay!', - 'registered_pw' => 'If you have forgotten your password already, please reset it using the password reset tool.', - 'registered_help' => 'There is a help-icon in the top right corner of each page. If you need help, click it!', - 'registered_doc_html' => 'If you haven\'t already, please read the grand theory.', - 'registered_doc_text' => 'If you haven\'t already, please read the first use guide and the full description.', - 'registered_closing' => 'Enjoy!', - 'registered_firefly_iii_link' => 'Firefly III:', - 'registered_pw_reset_link' => 'Password reset:', - 'registered_doc_link' => 'Documentation:', + 'registered_subject' => 'Welcome to Firefly III!', + 'registered_welcome' => 'Welcome to Firefly III. Your registration has made it, and this email is here to confirm it. Yay!', + 'registered_pw' => 'If you have forgotten your password already, please reset it using the password reset tool.', + 'registered_help' => 'There is a help-icon in the top right corner of each page. If you need help, click it!', + 'registered_doc_html' => 'If you haven\'t already, please read the grand theory.', + 'registered_doc_text' => 'If you haven\'t already, please read the first use guide and the full description.', + 'registered_closing' => 'Enjoy!', + 'registered_firefly_iii_link' => 'Firefly III:', + 'registered_pw_reset_link' => 'Password reset:', + 'registered_doc_link' => 'Documentation:', // email change - 'email_change_subject' => 'Your Firefly III email address has changed', - 'email_change_body_to_new' => 'You or somebody with access to your Firefly III account has changed your email address. If you did not expect this message, please ignore and delete it.', - 'email_change_body_to_old' => 'You or somebody with access to your Firefly III account has changed your email address. If you did not expect this to happen, you must follow the "undo"-link below to protect your account!', - 'email_change_ignore' => 'If you initiated this change, you may safely ignore this message.', - 'email_change_old' => 'The old email address was: :email', - 'email_change_old_strong' => 'The old email address was: :email', - 'email_change_new' => 'The new email address is: :email', - 'email_change_new_strong' => 'The new email address is: :email', - 'email_change_instructions' => 'You cannot use Firefly III until you confirm this change. Please follow the link below to do so.', - 'email_change_undo_link' => 'To undo the change, follow this link:', + 'email_change_subject' => 'Your Firefly III email address has changed', + 'email_change_body_to_new' => 'You or somebody with access to your Firefly III account has changed your email address. If you did not expect this message, please ignore and delete it.', + 'email_change_body_to_old' => 'You or somebody with access to your Firefly III account has changed your email address. If you did not expect this to happen, you must follow the "undo"-link below to protect your account!', + 'email_change_ignore' => 'If you initiated this change, you may safely ignore this message.', + 'email_change_old' => 'The old email address was: :email', + 'email_change_old_strong' => 'The old email address was: :email', + 'email_change_new' => 'The new email address is: :email', + 'email_change_new_strong' => 'The new email address is: :email', + 'email_change_instructions' => 'You cannot use Firefly III until you confirm this change. Please follow the link below to do so.', + 'email_change_undo_link' => 'To undo the change, follow this link:', // OAuth token created - 'oauth_created_subject' => 'A new OAuth client has been created', - 'oauth_created_body' => 'Somebody (hopefully you) just created a new Firefly III API OAuth Client for your user account. It\'s labeled ":name" and has callback URL :url.', - 'oauth_created_explanation' => 'With this client, they can access all of your financial records through the Firefly III API.', - 'oauth_created_undo' => 'If this wasn\'t you, please revoke this client as soon as possible at :url.', + 'oauth_created_subject' => 'A new OAuth client has been created', + 'oauth_created_body' => 'Somebody (hopefully you) just created a new Firefly III API OAuth Client for your user account. It\'s labeled ":name" and has callback URL :url.', + 'oauth_created_explanation' => 'With this client, they can access all of your financial records through the Firefly III API.', + 'oauth_created_undo' => 'If this wasn\'t you, please revoke this client as soon as possible at :url.', // reset password - 'reset_pw_subject' => 'Your password reset request', - 'reset_pw_instructions' => 'Somebody tried to reset your password. If it was you, please follow the link below to do so.', - 'reset_pw_warning' => 'PLEASE verify that the link actually goes to the Firefly III you expect it to go!', + 'reset_pw_subject' => 'Your password reset request', + 'reset_pw_instructions' => 'Somebody tried to reset your password. If it was you, please follow the link below to do so.', + 'reset_pw_warning' => 'PLEASE verify that the link actually goes to the Firefly III you expect it to go!', // error - 'error_subject' => 'Caught an error in Firefly III', - 'error_intro' => 'Firefly III v:version ran into an error: :errorMessage.', - 'error_type' => 'The error was of type ":class".', - 'error_timestamp' => 'The error occurred on/at: :time.', - 'error_location' => 'This error occurred in file ":file" on line :line with code :code.', - 'error_user' => 'The error was encountered by user #:id, :email.', - 'error_no_user' => 'There was no user logged in for this error or no user was detected.', - 'error_ip' => 'The IP address related to this error is: :ip', - 'error_url' => 'URL is: :url', - 'error_user_agent' => 'User agent: :userAgent', - 'error_stacktrace' => 'The full stacktrace is below. If you think this is a bug in Firefly III, you can forward this message to james@firefly-iii.org. This can help fix the bug you just encountered.', - 'error_github_html' => 'If you prefer, you can also open a new issue on GitHub.', - 'error_github_text' => 'If you prefer, you can also open a new issue on https://github.com/firefly-iii/firefly-iii/issues.', - 'error_stacktrace_below' => 'The full stacktrace is below:', - 'error_headers' => 'The following headers may also be relevant:', + 'error_subject' => 'Caught an error in Firefly III', + 'error_intro' => 'Firefly III v:version ran into an error: :errorMessage.', + 'error_type' => 'The error was of type ":class".', + 'error_timestamp' => 'The error occurred on/at: :time.', + 'error_location' => 'This error occurred in file ":file" on line :line with code :code.', + 'error_user' => 'The error was encountered by user #:id, :email.', + 'error_no_user' => 'There was no user logged in for this error or no user was detected.', + 'error_ip' => 'The IP address related to this error is: :ip', + 'error_url' => 'URL is: :url', + 'error_user_agent' => 'User agent: :userAgent', + 'error_stacktrace' => 'The full stacktrace is below. If you think this is a bug in Firefly III, you can forward this message to james@firefly-iii.org. This can help fix the bug you just encountered.', + 'error_github_html' => 'If you prefer, you can also open a new issue on GitHub.', + 'error_github_text' => 'If you prefer, you can also open a new issue on https://github.com/firefly-iii/firefly-iii/issues.', + 'error_stacktrace_below' => 'The full stacktrace is below:', + 'error_headers' => 'The following headers may also be relevant:', // report new journals - 'new_journals_subject' => 'Firefly III has created a new transaction|Firefly III has created :count new transactions', - 'new_journals_header' => 'Firefly III has created a transaction for you. You can find it in your Firefly III installation:|Firefly III has created :count transactions for you. You can find them in your Firefly III installation:', + 'new_journals_subject' => 'Firefly III has created a new transaction|Firefly III has created :count new transactions', + 'new_journals_header' => 'Firefly III has created a transaction for you. You can find it in your Firefly III installation:|Firefly III has created :count transactions for you. You can find them in your Firefly III installation:', + + // bill warning + 'bill_warning_subject_end_date' => 'Your bill ":name" is due to end in :diff days', + 'bill_warning_subject_now_end_date' => 'Your bill ":name" is due to end TODAY', + 'bill_warning_subject_extension_date' => 'Your bill ":name" is due to be extended or cancelled in :diff days', + 'bill_warning_subject_now_extension_date' => 'Your bill ":name" is due to be extended or cancelled TODAY', + 'bill_warning_end_date_text' => 'Your bill ":name" is due to end on :date. This moment will pass in about :diff days.', + 'bill_warning_extension_date_text' => 'Your bill ":name" is due to be extended or cancelled on :date. This moment will pass in about :diff days.', + 'bill_warning_end_date_text_zero' => 'Your bill ":name" is due to end on :date. This moment will pass TODAY!', + 'bill_warning_extension_date_text_zero' => 'Your bill ":name" is due to be extended or cancelled on :date. This moment will pass TODAY!', + 'bill_warning_please_action' => 'Please take the appropriate action.', + 'bill_warning_end_date_html' => 'Your bill ":name" is due to end on :date. This moment will pass in about :diff days.', + 'bill_warning_extension_date_html' => 'Your bill ":name" is due to be extended or cancelled on :date. This moment will pass in about :diff days.', + 'bill_warning_end_date_html_zero' => 'Your bill ":name" is due to end on :date. This moment will pass TODAY!', + 'bill_warning_extension_date_html_zero' => 'Your bill ":name" is due to be extended or cancelled on :date. This moment will pass TODAY!', ]; diff --git a/resources/lang/en_US/firefly.php b/resources/lang/en_US/firefly.php index a11b2d4fbe..e93b08359f 100644 --- a/resources/lang/en_US/firefly.php +++ b/resources/lang/en_US/firefly.php @@ -1309,6 +1309,10 @@ return [ 'running_again_loss' => 'Previously linked transactions to this bill may lose their connection, if they (no longer) match the rule(s).', 'bill_related_rules' => 'Rules related to this bill', 'repeats' => 'Repeats', + 'bill_end_date_help' => 'Optional field. The bill is expected to end on this date.', + 'bill_extension_date_help' => 'Optional field. The bill must be extended (or cancelled) on or before this date.', + 'bill_end_index_line' => 'This bill ends on :date', + 'bill_extension_index_line' => 'This bill must be extended or cancelled on :date', 'connected_journals' => 'Connected transactions', 'auto_match_on' => 'Automatically matched by Firefly III', 'auto_match_off' => 'Not automatically matched by Firefly III', diff --git a/resources/lang/en_US/form.php b/resources/lang/en_US/form.php index bc24610318..04a6bcb90e 100644 --- a/resources/lang/en_US/form.php +++ b/resources/lang/en_US/form.php @@ -181,6 +181,7 @@ return [ 'login_name' => 'Login', 'is_owner' => 'Is admin?', 'url' => 'URL', + 'bill_end_date' => 'End date', // import 'apply_rules' => 'Apply rules', diff --git a/resources/views/bills/create.twig b/resources/views/bills/create.twig index 5c9eef120d..f309a07592 100644 --- a/resources/views/bills/create.twig +++ b/resources/views/bills/create.twig @@ -22,6 +22,7 @@ {{ ExpandedForm.amountNoCurrency('amount_max') }} {{ ExpandedForm.date('date',phpdate('Y-m-d')) }} {{ ExpandedForm.select('repeat_freq',periods,'monthly') }} + {{ ExpandedForm.integer('skip',0) }} @@ -32,9 +33,12 @@

{{ 'optionalFields'|_ }}

+ {{ ExpandedForm.date('bill_end_date',null, {'helpText': trans('firefly.bill_end_date_help')}) }} + {{ ExpandedForm.date('extension_date',null,{'helpText': trans('firefly.bill_extension_date_help')} ) }} + + {{ ExpandedForm.textarea('notes',null,{helpText: trans('firefly.field_supports_markdown')}) }} {{ ExpandedForm.file('attachments[]', {'multiple': 'multiple','helpText': trans('firefly.upload_max_file_size', {'size': uploadSize|filesize}) }) }} - {{ ExpandedForm.integer('skip',0) }} {{ ExpandedForm.objectGroup() }}
diff --git a/resources/views/bills/edit.twig b/resources/views/bills/edit.twig index 25a3f2c069..0f953f5800 100644 --- a/resources/views/bills/edit.twig +++ b/resources/views/bills/edit.twig @@ -27,6 +27,8 @@ {{ ExpandedForm.amountNoCurrency('amount_max') }} {{ ExpandedForm.date('date',bill.date.format('Y-m-d')) }} {{ ExpandedForm.select('repeat_freq',periods) }} + {{ ExpandedForm.integer('skip') }} + @@ -38,9 +40,11 @@

{{ 'optionalFields'|_ }}

+ {{ ExpandedForm.date('bill_end_date',null, {'helpText': trans('firefly.bill_end_date_help')}) }} + {{ ExpandedForm.date('extension_date',null,{'helpText': trans('firefly.bill_extension_date_help')} ) }} + {{ ExpandedForm.textarea('notes',null,{helpText: trans('firefly.field_supports_markdown')}) }} {{ ExpandedForm.file('attachments[]', {'multiple': 'multiple','helpText': trans('firefly.upload_max_file_size', {'size': uploadSize|filesize}) }) }} - {{ ExpandedForm.integer('skip') }} {{ ExpandedForm.objectGroup() }} {# only correct way to do active checkbox #} {{ ExpandedForm.checkbox('active', 1) }} diff --git a/resources/views/emails/bill-warning-html.twig b/resources/views/emails/bill-warning-html.twig new file mode 100644 index 0000000000..9d01d8d07e --- /dev/null +++ b/resources/views/emails/bill-warning-html.twig @@ -0,0 +1,25 @@ +{% include 'emails.header-html' %} + +

+ + {% if field == 'end_date' and diff != 0 %} + {{ trans('email.bill_warning_end_date_html', {name: bill.name|escape, date: bill.end_date.isoFormat(trans('config.month_and_day_js')), diff: diff})|raw }} + {% endif %} + {% if field == 'extension_date' and diff != 0 %} + {{ trans('email.bill_warning_extension_date_html', {name: bill.name|escape, date: bill.extension_date.isoFormat(trans('config.month_and_day_js')), diff: diff})|raw }} + {% endif %} + {% if field == 'end_date' and diff == 0 %} + {{ trans('email.bill_warning_end_date_html_zero', {name: bill.name|escape, date: bill.end_date.isoFormat(trans('config.month_and_day_js'))})|raw }} + {% endif %} + {% if field == 'extension_date' and diff == 0 %} + {{ trans('email.bill_warning_extension_date_html_zero', {name: bill.name|escape, date: bill.end_date.isoFormat(trans('config.month_and_day_js'))})|raw }} + {% endif %} + +

+ +

+ Please take the appropriate action. +

+ + +{% include 'emails.footer-html' %} \ No newline at end of file diff --git a/resources/views/emails/bill-warning-text.twig b/resources/views/emails/bill-warning-text.twig new file mode 100644 index 0000000000..c76b64fb96 --- /dev/null +++ b/resources/views/emails/bill-warning-text.twig @@ -0,0 +1,17 @@ +{% include 'emails.header-text' %} +{% if field == 'end_date' and diff != 0 %} +{{ trans('email.bill_warning_end_date_text', {name: bill.name, date: bill.end_date.isoFormat(trans('config.month_and_day_js')), diff: diff})|raw }} +{% endif %} +{% if field == 'extension_date' and diff != 0 %} +{{ trans('email.bill_warning_extension_date_text', {name: bill.name|escape, date: bill.extension_date.isoFormat(trans('config.month_and_day_js')), diff: diff})|raw }} +{% endif %} +{% if field == 'end_date' and diff == 0 %} +{{ trans('email.bill_warning_end_date_text_zero', {name: bill.name|escape, date: bill.end_date.isoFormat(trans('config.month_and_day_js'))})|raw }} +{% endif %} +{% if field == 'extension_date' and diff == 0 %} +{{ trans('email.bill_warning_extension_date_text_zero', {name: bill.name|escape, date: bill.end_date.isoFormat(trans('config.month_and_day_js'))})|raw }} +{% endif %} + +{{ trans('email.bill_warning_please_action') }} + +{% include 'emails.footer-text' %} \ No newline at end of file diff --git a/resources/views/list/bills.twig b/resources/views/list/bills.twig index 47ac1bcfe4..d930f54681 100644 --- a/resources/views/list/bills.twig +++ b/resources/views/list/bills.twig @@ -16,20 +16,25 @@ {% for objectGroupOrder, objectGroup in bills %} {% if objectGroup.bills|length > 0 %} - +     {{ objectGroup.object_group_title }} {% for entry in objectGroup.bills %} - + -
@@ -126,6 +131,26 @@ {% if entry.skip > 0 %} {{ 'skips_over'|_ }} {{ entry.skip }} {% endif %} + {% if entry.end_date %} +
+ {% if carbonize(entry.end_date).lte(today) %} + + {{ trans('firefly.bill_end_index_line', {date: carbonize(entry.end_date).isoFormat(monthAndDayFormat) }) }} + + {% else %} + {{ trans('firefly.bill_end_index_line', {date: carbonize(entry.end_date).isoFormat(monthAndDayFormat) }) }} + {% endif %} + {% endif %} + {% if entry.extension_date %} +
+ {% if carbonize(entry.extension_date).lte(today) %} + + {{ trans('firefly.bill_extension_index_line', {date: carbonize(entry.extension_date).isoFormat(monthAndDayFormat) }) }} + + {% else %} + {{ trans('firefly.bill_extension_index_line', {date: carbonize(entry.extension_date).isoFormat(monthAndDayFormat) }) }} + {% endif %} + {% endif %} {% endfor %} @@ -150,7 +175,8 @@     - {{ ('per_period_sum_'~sum.period)|_ }} ({{ sum.currency_name }}) ({{ 'active_bills_only'|_ }}) + {{ ('per_period_sum_'~sum.period)|_ }} ({{ sum.currency_name }}) + ({{ 'active_bills_only'|_ }}) {{ formatAmountBySymbol(sum.per_period, sum.currency_symbol, sum.currency_decimal_places) }} @@ -165,43 +191,44 @@ {% endif %} {% endfor %} {% if totals|length > 0 %} - - - - - {% for sum in totals %} - {% if '0' != sum.avg %} - -   -   - - {{ 'sum'|_ }} ({{ sum.currency_name }}) ({{ 'active_exp_bills_only_total'|_ }}) - - - {{ formatAmountBySymbol(sum.avg, sum.currency_symbol, sum.currency_decimal_places) }} - -   -   -   - - {% endif %} - {% if '0' != sum.per_period %} - -   -   - - {{ ('per_period_sum_'~sum.period)|_ }} ({{ sum.currency_name }}) ({{ 'active_bills_only_total'|_ }}) - - - {{ formatAmountBySymbol(sum.per_period, sum.currency_symbol, sum.currency_decimal_places) }} - -   -   -   - - {% endif %} - {% endfor %} - + + + + + {% for sum in totals %} + {% if '0' != sum.avg %} + +   +   + + {{ 'sum'|_ }} ({{ sum.currency_name }}) ({{ 'active_exp_bills_only_total'|_ }}) + + + {{ formatAmountBySymbol(sum.avg, sum.currency_symbol, sum.currency_decimal_places) }} + +   +   +   + + {% endif %} + {% if '0' != sum.per_period %} + +   +   + + {{ ('per_period_sum_'~sum.period)|_ }} ({{ sum.currency_name }}) + ({{ 'active_bills_only_total'|_ }}) + + + {{ formatAmountBySymbol(sum.per_period, sum.currency_symbol, sum.currency_decimal_places) }} + +   +   +   + + {% endif %} + {% endfor %} + {% endif %}