diff --git a/_ide_helper.php b/_ide_helper.php index c9f7adea..d5cc42e4 100644 --- a/_ide_helper.php +++ b/_ide_helper.php @@ -3,7 +3,7 @@ /** * A helper file for Laravel, to provide autocomplete information to your IDE - * Generated for Laravel 7.17.2 on 2020-07-05 03:21:41. + * Generated for Laravel 7.21.0 on 2020-08-06 04:21:14. * * This file should not be included in your code, only analyzed by your IDE! * @@ -868,6 +868,18 @@ public static function getLocale() return $instance->getLocale(); } + /** + * Get the current application fallback locale. + * + * @return string + * @static + */ + public static function getFallbackLocale() + { + /** @var \Illuminate\Foundation\Application $instance */ + return $instance->getFallbackLocale(); + } + /** * Set the current application locale. * @@ -881,6 +893,19 @@ public static function setLocale($locale) $instance->setLocale($locale); } + /** + * Set the current application fallback locale. + * + * @param string $fallbackLocale + * @return void + * @static + */ + public static function setFallbackLocale($fallbackLocale) + { + /** @var \Illuminate\Foundation\Application $instance */ + $instance->setFallbackLocale($fallbackLocale); + } + /** * Determine if application locale is the given locale. * @@ -5481,7 +5506,7 @@ public static function assertDispatched($event, $callback = null) } /** - * Assert if a event was dispatched a number of times. + * Assert if an event was dispatched a number of times. * * @param string $event * @param int $times @@ -6978,7 +7003,7 @@ public static function extend($driver, $callback) /** * Unset the given channel instance. * - * @param string|null $name + * @param string|null $driver * @return \Illuminate\Log\LogManager * @static */ @@ -18497,6 +18522,8 @@ public static function forPageAfterId($perPage = 15, $lastId = 0, $column = 'id' /** * Remove all existing orders and optionally add a new order. * + * @param string|null $column + * @param string $direction * @return \Illuminate\Database\Query\Builder * @static */ diff --git a/app/Budget.php b/app/Budget.php new file mode 100644 index 00000000..0d94667e --- /dev/null +++ b/app/Budget.php @@ -0,0 +1,198 @@ + 'datetime', + 'breached_at' => 'datetime', + ]; + + public static function booted() + { + static::creating(function ($budget) { + if (empty($budget->user_id) && auth()->check()) { + $budget->user_id = auth()->id(); + } + }); + } + + public function scopeTotalSpends(Builder $query) + { + $query->addSelect([ + 'budgets.*', + \DB::raw("CASE + WHEN frequency='YEARLY' THEN @diff:=(YEAR(now()) - YEAR(started_at)) + WHEN frequency='MONTHLY' THEN @diff:=PERIOD_DIFF(DATE_FORMAT(now(), \"%Y%m\"), DATE_FORMAT(started_at, \"%Y%m\")) + WHEN frequency='DAILY' THEN @diff:=DATEDIFF(now(), started_at) + WHEN frequency='WEEKLY' THEN @diff:=ROUND(DATEDIFF(now(), started_at)/7, 0) + END as diff"), + \DB::raw("CASE + WHEN frequency='YEARLY' THEN @periodStart:=if(DATE_ADD(started_at, INTERVAL @diff YEAR) < now(), DATE_ADD(started_at, INTERVAL @diff YEAR), DATE_ADD(started_at, INTERVAL @diff-1 YEAR)) + WHEN frequency='MONTHLY' THEN @periodStart:=if(DATE_ADD(started_at, INTERVAL @diff MONTH) < now(), DATE_ADD(started_at, INTERVAL @diff MONTH), DATE_ADD(started_at, INTERVAL @diff-1 MONTH)) + WHEN frequency='DAILY' THEN @periodStart:=if(DATE_ADD(started_at, INTERVAL @diff DAY) < now(), DATE_ADD(started_at, INTERVAL @diff DAY), DATE_ADD(started_at, INTERVAL @diff-1 DAY)) + WHEN frequency='WEEKLY' THEN @periodStart:=if(DATE_ADD(started_at, INTERVAL @diff WEEK) < now(), DATE_ADD(started_at, INTERVAL @diff WEEK), DATE_ADD(started_at, INTERVAL @diff-1 WEEK)) + END as period_started_at"), + \DB::raw("CASE + WHEN frequency='YEARLY' THEN if(DATE_ADD(started_at, INTERVAL @diff YEAR) < now(), DATE_ADD(started_at, INTERVAL @diff+1 YEAR), DATE_ADD(started_at, INTERVAL @diff YEAR)) + WHEN frequency='MONTHLY' THEN if(DATE_ADD(started_at, INTERVAL @diff MONTH) < now(), DATE_ADD(started_at, INTERVAL @diff+1 MONTH), DATE_ADD(started_at, INTERVAL @diff MONTH)) + WHEN frequency='DAILY' THEN if(DATE_ADD(started_at, INTERVAL @diff DAY) < now(), DATE_ADD(started_at, INTERVAL @diff+1 DAY), DATE_ADD(started_at, INTERVAL @diff DAY)) + WHEN frequency='WEEKLY' THEN if(DATE_ADD(started_at, INTERVAL @diff WEEK) < now(), DATE_ADD(started_at, INTERVAL @diff+1 WEEK), DATE_ADD(started_at, INTERVAL @diff WEEK)) + END as next_period"), + \DB::raw('@userId:=user_id as user_id'), + \DB::raw('( + select sum(amount) + from transactions + cross join taggables on taggables.tag_id in ( + select distinct taggables.tag_id + from taggables + cross join tags tag + on taggables.taggable_type = \'App\\\\Budget\' + cross join budgets b + on b.id = taggables.taggable_id + where tag.user_id = b.user_id + and b.user_id = cast(@userId as UNSIGNED) + ) + and taggables.taggable_id = transactions.id + and taggables.taggable_type = \'App\\\\Models\\\\Transaction\' + and transactions.date >= date_format(@periodStart, "%Y-%m-%d") + and transactions.account_id in ( + select account_id + from accounts + where access_token_id in ( + select id + from access_tokens + where access_tokens.user_id = cast(@userId as UNSIGNED) + ) + ) + ) as total_spend') + ]); + } + + public function getValidationCreateRules (): array + { + return [ + 'name' => 'required', + 'amount' => 'required', + 'frequency' => 'required', + 'interval' => 'required', + 'started_at' => 'required', + 'count' => 'required', + ]; + } + + public function getValidationUpdateRules (): array + { + return [ + 'name' => 'string', + 'amount' => 'numeric', + 'frequency' => Rule::in([ + 'MONTHLY', + 'YEARLY', + 'DAILY', + 'WEEKLY', + ]), + 'interval' => 'numeric', + 'started_at' => 'date', + 'count' => 'numeric', + ]; + } + + public function getAbstractAllowedFilters (): array + { + return [ + AllowedFilter::scope('totalSpends'), + ]; + } + + public function getAbstractAllowedRelationships (): array + { + return ['tags']; + } + + public function getAbstractAllowedSorts (): array + { + return []; + } + + public function getAbstractAllowedFields (): array + { + return []; + } + + public function getAbstractSearchableFields (): array + { + return []; + } + + public function getRule(): RRule + { + return new RRule(array_merge([ + 'FREQ' => $this->frequency, + 'INTERVAL' => $this->interval, + 'DTSTART' => $this->started_at, + ], $this->count ? [ + 'COUNT' => $this->count, + ] : [])); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Console/Commands/SyncTokens.php b/app/Console/Commands/CheckBudgetBreachCommand.php similarity index 62% rename from app/Console/Commands/SyncTokens.php rename to app/Console/Commands/CheckBudgetBreachCommand.php index 268eaf4f..01246f1d 100644 --- a/app/Console/Commands/SyncTokens.php +++ b/app/Console/Commands/CheckBudgetBreachCommand.php @@ -2,23 +2,24 @@ namespace App\Console\Commands; +use App\Jobs\CheckBudgetsForBreachesOfAmount; use Illuminate\Console\Command; -class SyncTokens extends Command +class CheckBudgetBreachCommand extends Command { /** * The name and signature of the console command. * * @var string */ - protected $signature = 'sync:tokens'; + protected $signature = 'check:budget-breach'; /** * The console command description. * * @var string */ - protected $description = 'Sync the accounts from the stored plaid access tokens.'; + protected $description = 'Check the budgets to see if they\'re over budget'; /** * Create a new command instance. @@ -30,13 +31,8 @@ public function __construct() parent::__construct(); } - /** - * Execute the console command. - * - * @return mixed - */ public function handle() { - // + CheckBudgetsForBreachesOfAmount::dispatch(); } } diff --git a/app/Console/Commands/GenerateChannelsAndAlertsFile.php b/app/Console/Commands/GenerateChannelsAndAlertsFile.php index a85ea754..fb4854c5 100644 --- a/app/Console/Commands/GenerateChannelsAndAlertsFile.php +++ b/app/Console/Commands/GenerateChannelsAndAlertsFile.php @@ -4,6 +4,7 @@ use App\AccountKpi; use App\Condition; +use App\Events\BudgetBreachedEstablishedAmount; use App\Events\TransactionCreated; use App\Events\TransactionGroupedEvent; use App\Events\TransactionUpdated; @@ -76,6 +77,10 @@ public function handle() 'type' => TransactionGroupedEvent::class, 'name' => 'When a transaction is added to a group (this gives you access to the `tag` variable in your title, body and payload.)' ], + [ + 'type' => BudgetBreachedEstablishedAmount::class, + 'name' => 'When a budget\'s total spend amount for a period exceeds the set amount.' + ] ]); $this->writeToDisk('js/condition-parameters.js', [ @@ -109,21 +114,6 @@ public function handle() ], ]); - $this->writeToDisk('js/alert-events.js', [ - [ - 'type' => TransactionUpdated::class, - 'name' => 'When a transaction is updated (moving from pending to not pending, updating amounts, etc...)' - ], - [ - 'type' => TransactionCreated::class, - 'name' => 'When a transaction is initially created (only fired once per transaction)', - ], - [ - 'type' => TransactionGroupedEvent::class, - 'name' => 'When a transaction is added to a group (this gives you access to the `tag` variable in your title, body and payload.)' - ], - ]); - $this->writeToDisk('js/condition-comparator.js', array_map(function ($comparator) { return [ 'value' => $comparator, diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 07dd49ea..219dc5d9 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -22,8 +22,12 @@ class Kernel extends ConsoleKernel */ protected function schedule(Schedule $schedule) { - $schedule->command('sync:plaid 7')->hourly(); + $schedule->command('plaid:sync-institutions')->monthly(); + $schedule->command('plaid:sync-categories')->monthly(); $schedule->command('generate:account-kpis')->dailyAt('23:55'); + $schedule->command('sync:plaid 1')->hourlyAt(0); + // Small job offset so we don't flood the queue. Not really ever going to be a problem... but meh :shrug: + $schedule->command('check:budget-breach')->hourlyAt(10); } /** diff --git a/app/Events/BudgetBreachedEstablishedAmount.php b/app/Events/BudgetBreachedEstablishedAmount.php new file mode 100644 index 00000000..f3f08150 --- /dev/null +++ b/app/Events/BudgetBreachedEstablishedAmount.php @@ -0,0 +1,25 @@ +budget = $budget; + } + + public function getBudget(): Budget + { + return $this->budget; + } +} diff --git a/app/Http/Controllers/Api/AbstractResourceController.php b/app/Http/Controllers/Api/AbstractResourceController.php index 3f850695..ab245c2a 100644 --- a/app/Http/Controllers/Api/AbstractResourceController.php +++ b/app/Http/Controllers/Api/AbstractResourceController.php @@ -5,7 +5,9 @@ use App\FailedJob; use App\Http\Controllers\Controller; use App\Models\Transaction; +use App\Tag; use Exception; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Validation\ValidatesRequests; use Kregel\LaravelAbstract\AbstractEloquentModel; @@ -43,6 +45,10 @@ public function index(IndexRequest $request, AbstractEloquentModel $model) $query->where('user_id', auth()->id()); } + if (get_class($model) === Tag::class) { + $query->withSum('transactions.amount'); + } + return $this->json($action->execute($query)); } @@ -73,9 +79,15 @@ public function show(ViewRequest $request, AbstractEloquentModel $model, Abstrac return $this->json($result); } + /** + * @param UpdateRequest $request + * @param AbstractEloquentModel $model + * @param AbstractEloquentModel|Model $abstractEloquentModel + * @return \Illuminate\Http\JsonResponse|object + */ public function update(UpdateRequest $request, AbstractEloquentModel $model, AbstractEloquentModel $abstractEloquentModel) { - $abstractEloquentModel->update($request->all()); + $abstractEloquentModel->update($request->validated()); return $this->json($abstractEloquentModel->refresh()); } diff --git a/app/Http/Controllers/Api/BudgetController.php b/app/Http/Controllers/Api/BudgetController.php new file mode 100644 index 00000000..ab5050f6 --- /dev/null +++ b/app/Http/Controllers/Api/BudgetController.php @@ -0,0 +1,87 @@ +get('action', 'paginate:14')); + $model = new Budget; + + $query = QueryBuilder::for(Budget::class) + ->allowedFields($model->getAbstractAllowedFields()) + ->allowedFilters(array_merge($model->getAbstractAllowedFilters(), [ + Filter::scope('q') + ])) + ->allowedIncludes($model->getAbstractAllowedRelationships()) + ->allowedSorts($model->getAbstractAllowedSorts()) + ->where('user_id', auth()->id()); + + return $this->json($action->execute($query)); + } + + public function store(StoreRequest $request) + { + /** @var AbstractEloquentModel $resource */ + $resource = new Budget; + $resource->fill($request->validated() + [ + 'user_id' => auth()->id(), + ]); + $resource->save(); + return $this->json($resource->refresh()); + } + + public function show(ViewRequest $request, Budget $model) + { + $result = QueryBuilder::for(Budget::class) + ->allowedFields($model->getAbstractAllowedFields()) + ->allowedFilters($model->getAbstractAllowedFilters()) + ->allowedIncludes($model->getAbstractAllowedRelationships()) + ->allowedSorts($model->getAbstractAllowedSorts()) + ->where('user_id', auth()->id()) + ->find($model->id); + + if (empty($result)) { + return $this->json([ + 'message' => 'No resource found by that id.' + ], 404); + } + + return $this->json($result); + } + + public function update(UpdateRequest $request, Budget $budget) + { + $budget->update($request->all()); + + return $this->json($budget->refresh()); + } + + public function destroy(DestroyRequest $request, Budget $budget) + { + $budget->delete(); + + return $this->json('', 204); + } + + public function tags(Request $request, Budget $budget) + { + $budget->tags()->sync($request->json()->all()); + return $this->json($budget->refresh()); + } +} diff --git a/app/Http/Controllers/Api/TagController.php b/app/Http/Controllers/Api/TagController.php index 9fac2024..34d1dbc5 100644 --- a/app/Http/Controllers/Api/TagController.php +++ b/app/Http/Controllers/Api/TagController.php @@ -8,6 +8,7 @@ use App\Http\Requests\Tag\ConditionalUpdateRequest; use App\Http\Requests\Tag\DestroyRequest; use App\Jobs\SyncTagsWithTransactionsInDatabase; +use App\Models\Transaction; use App\Tag; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; diff --git a/app/Http/Requests/Budget/DestroyRequest.php b/app/Http/Requests/Budget/DestroyRequest.php new file mode 100644 index 00000000..d09e55aa --- /dev/null +++ b/app/Http/Requests/Budget/DestroyRequest.php @@ -0,0 +1,30 @@ + 'required|max:255', + 'title' => 'string|max:180|nullable', + 'body' => 'string|max:260|nullable', + 'channels' => 'required|array', + 'payload' => 'json|nullable', + 'events' => 'array', + 'webhook_url' => [ + new RequiredIf( + in_array(SlackMessage::class, request()->get('channels', null)) + || in_array(DiscordMessage::class, request()->get('channels', null)) + ) + ] + ]; + } +} diff --git a/app/Http/Requests/Budget/UpdateRequest.php b/app/Http/Requests/Budget/UpdateRequest.php new file mode 100644 index 00000000..474edfd6 --- /dev/null +++ b/app/Http/Requests/Budget/UpdateRequest.php @@ -0,0 +1,30 @@ +paginate(50, [], 'page', $page++); + + /** @var Budget $budget */ + foreach ($budgets->items() as $budget) { + // The last time the budget started it's current period. + $startOfTheLastBudgetPeriod = Carbon::parse($budget->getRule()->getOccurrencesBefore(now(), true ,1)[0]); + // 80 minutes should give the system time to catch in-consistent runs. + // The cron should run every hour, so things will only trigger once. + if (!empty($budget->breached_at) && $startOfTheLastBudgetPeriod->diffInMinutes($budget->breached_at) > 80) { + // If the budget has already breached, and it did so several hours ago we don't want to spam users... + return; + } + + if ($budget->amount < $budget->total_spend && empty($budget->breached_at)) { + event(new BudgetBreachedEstablishedAmount($budget)); + $budget->update([ + 'breached_at' => now() + ]); + } + } + } while ($budgets->hasMorePages()); + } +} diff --git a/app/Listeners/TriggerAlertForBreachedBudget.php b/app/Listeners/TriggerAlertForBreachedBudget.php new file mode 100644 index 00000000..1bfe93e8 --- /dev/null +++ b/app/Listeners/TriggerAlertForBreachedBudget.php @@ -0,0 +1,37 @@ +getBudget(); + + $budget = Budget::totalSpends()->with('user')->find($budget->id); + + $user = $budget->user; + + /** @var Collection $alertsToTrigger */ + $alertsToTrigger = $user->alerts() + ->whereJsonContains('events', BudgetBreachedEstablishedAmount::class) + ->get(); + + $alertsToTrigger->map->createBudgetBreachNotification($budget); + } +} diff --git a/app/Listeners/TriggerAlertIfConditionsPassListener.php b/app/Listeners/TriggerAlertIfConditionsPassListenerForTransaction.php similarity index 97% rename from app/Listeners/TriggerAlertIfConditionsPassListener.php rename to app/Listeners/TriggerAlertIfConditionsPassListenerForTransaction.php index be4d3cd1..76fdae53 100644 --- a/app/Listeners/TriggerAlertIfConditionsPassListener.php +++ b/app/Listeners/TriggerAlertIfConditionsPassListenerForTransaction.php @@ -11,7 +11,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Support\Collection; -class TriggerAlertIfConditionsPassListener implements ShouldQueue +class TriggerAlertIfConditionsPassListenerForTransaction implements ShouldQueue { use InteractsWithQueue; diff --git a/app/Mail/BudgetBreachEstablishedAmountMail.php b/app/Mail/BudgetBreachEstablishedAmountMail.php new file mode 100644 index 00000000..8427fc06 --- /dev/null +++ b/app/Mail/BudgetBreachEstablishedAmountMail.php @@ -0,0 +1,38 @@ +alertLog = $alertLog; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $this->alertLog->load([ + 'budget', + ]); + + return $this->markdown('mail.budget-breach', [ + 'alertLog' => $this->alertLog, + 'budget' => $this->alertLog->budget, + ]); + } +} diff --git a/app/Models/Alert.php b/app/Models/Alert.php index 26016770..0e05a460 100644 --- a/app/Models/Alert.php +++ b/app/Models/Alert.php @@ -2,9 +2,11 @@ namespace App\Models; +use App\Budget; use App\Contracts\ConditionableContract; use App\Models\Traits\Conditionable; use App\Notifications\AlertNotiffication; +use App\Notifications\BudgetBreachedEstablishedAmountNotification; use App\Notifications\TransactionAlertNotification; use App\Notifications\TransactionTagAlertNotification; use App\Tag; @@ -116,6 +118,12 @@ public function getAbstractSearchableFields(): array return ['user_id', 'name', 'order_column', 'type']; } + protected function notifyAbout($notification) + { + $this->load('user'); + $this->user->notify($notification); + } + public function createNotification(Transaction $transaction) { /** @var AlertLog $log */ @@ -125,9 +133,7 @@ public function createNotification(Transaction $transaction) // do the same thing as create notification, but give access to the tag variab $notification = new TransactionTagAlertNotification($log); - foreach ($this->channels as $channel) { - $this->user->notify($notification); - } + $this->notifyAbout($notification); } public function createNotificationWithTag(Transaction $transaction, Tag $tag) @@ -140,9 +146,15 @@ public function createNotificationWithTag(Transaction $transaction, Tag $tag) // do the same thing as create notification, but give access to the tag variab $notification = new TransactionTagAlertNotification($log); - foreach ($this->channels as $channel) { - $this->user->notify($notification); -// \Notification::channel($channel)->send($this->user, $notification); - } + $this->notifyAbout($notification); + } + + public function createBudgetBreachNotification(Budget $budget) + { + $notification = new BudgetBreachedEstablishedAmountNotification($this->logs()->create([ + 'triggered_by_budget_id' => $budget->id, + ])); + + $this->notifyAbout($notification); } } diff --git a/app/Models/AlertLog.php b/app/Models/AlertLog.php index afb8b5f7..193dcf2a 100644 --- a/app/Models/AlertLog.php +++ b/app/Models/AlertLog.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Budget; use App\Tag; use Illuminate\Database\Eloquent\Model; @@ -46,4 +47,8 @@ public function transaction() { return $this->belongsTo(Transaction::class, 'triggered_by_transaction_id'); } + public function budget() + { + return $this->belongsTo(Budget::class, 'triggered_by_budget_id'); + } } diff --git a/app/Models/Scopes/SummedTransactionsForBudget.php b/app/Models/Scopes/SummedTransactionsForBudget.php new file mode 100644 index 00000000..ef71042f --- /dev/null +++ b/app/Models/Scopes/SummedTransactionsForBudget.php @@ -0,0 +1,21 @@ +load(['alert', 'transaction']); + $alertLog->load(['alert', 'transaction', 'budget']); $this->alertLog = $alertLog; } @@ -42,11 +42,15 @@ public function toArray($notifiable) protected function renderField($field) { if (Str::contains($field, ['{{', '}}'])) { - return $this->render($field, array_merge([ + return $this->render($field, array_merge($this->alertLog->transaction ? [ 'transaction' => $this->alertLog->transaction->toArray(), - ], $this->alertLog->tag ? [ - 'tag' => $this->alertLog->tag->toArray(), - ] : [])); + ] : [], + $this->alertLog->tag ? [ + 'tag' => $this->alertLog->tag->toArray(), + ] : [], + $this->alertLog->budget ? [ + 'budget' => $this->alertLog->budget->toArray(), + ] : [])); } return $field; diff --git a/app/Notifications/BudgetBreachedEstablishedAmountNotification.php b/app/Notifications/BudgetBreachedEstablishedAmountNotification.php new file mode 100644 index 00000000..38b9e71b --- /dev/null +++ b/app/Notifications/BudgetBreachedEstablishedAmountNotification.php @@ -0,0 +1,30 @@ +alertLog))->to($notifiable->email); + } + + public function toSlack($notifiable) + { + return (new SlackMessage) + ->content(sprintf('Your budget %s breached the set amount!', $this->alertLog->budget->name)) + ->to($this->alertLog->alert->messaging_service_channel) + ->warning(); + } + + public function toDiscord($notifiable) + { + return (new DiscordMessage) + ->body(sprintf('Your budget %s breached the set amount!', $this->alertLog->budget->name)); + } +} diff --git a/app/Providers/AbstractRouteServiceProvider.php b/app/Providers/AbstractRouteServiceProvider.php index 2568807c..bcd772da 100644 --- a/app/Providers/AbstractRouteServiceProvider.php +++ b/app/Providers/AbstractRouteServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\AccountKpi; +use App\Budget; use App\FailedJob; use App\Models\Alert; use App\Models\DatabaseNotification; @@ -41,6 +42,7 @@ public function boot() 'groups' => Tag::class, 'alerts' => Alert::class, 'failed_jobs' => FailedJob::class, + 'budgets' => Budget::class, ]); Route::bind('abstract_model', abstracted()->resolveModelsUsing ?? function ($value) { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f9685db6..666e7ae4 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,6 +7,9 @@ use App\Repositories\AccountRepositoryEloquent; use App\Services\Banking\PlaidService; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Str; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Query\Expression; class AppServiceProvider extends ServiceProvider { @@ -19,6 +22,76 @@ public function register() { $this->app->bind(AccountRepository::class, AccountRepositoryEloquent::class); $this->app->bind(PlaidServiceContract::class, PlaidService::class); + /** + * @see https://stackoverflow.com/a/58668526/2736425 + */ + Builder::macro('withSum', function ($columns) { + if (empty($columns)) { + return $this; + } + + if (is_null($this->query->columns)) { + $this->query->select([$this->query->from.'.*']); + } + + $columns = is_array($columns) ? $columns : func_get_args(); + $columnAndConstraints = []; + + foreach ($columns as $name => $constraints) { + // If the "name" value is a numeric key, we can assume that no + // constraints have been specified. We'll just put an empty + // Closure there, so that we can treat them all the same. + if (is_numeric($name)) { + $name = $constraints; + $constraints = static function () { + // + }; + } + + $columnAndConstraints[$name] = $constraints; + } + + foreach ($columnAndConstraints as $name => $constraints) { + $segments = explode(' ', $name); + + unset($alias); + + if (count($segments) === 3 && Str::lower($segments[1]) === 'as') { + [$name, $alias] = [$segments[0], $segments[2]]; + } + + // Here we'll extract the relation name and the actual column name that's need to sum. + $segments = explode('.', $name); + + $relationName = $segments[0]; + $column = $segments[1]; + + $relation = $this->getRelationWithoutConstraints($relationName); + + $query = $relation->getRelationExistenceQuery( + $relation->getRelated()->newQuery(), + $this, + new Expression("sum(`$column`)") + )->setBindings([], 'select'); + + $query->callScope($constraints); + + $query = $query->mergeConstraintsFrom($relation->getQuery())->toBase(); + + if (count($query->columns) > 1) { + $query->columns = [$query->columns[0]]; + } + + // Finally we will add the proper result column alias to the query and run the subselect + // statement against the query builder. Then we will return the builder instance back + // to the developer for further constraint chaining that needs to take place on it. + $column = $alias ?? Str::snake(Str::replaceFirst('.', ' ', $name.'_sum')); + + $this->selectSub($query, $column); + } + + return $this; + }); } /** diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index d3b87ba2..0924ccf8 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Events\BudgetBreachedEstablishedAmount; use App\Events\RegroupEvent; use App\Events\TransactionCreated; use App\Events\TransactionGroupedEvent; @@ -9,7 +10,8 @@ use App\Listeners\ApplyGroupToTransactionAutomaticallyListener; use App\Listeners\CreateDefaultAlertsForUser; use App\Listeners\CreateDefaultTagsForUser; -use App\Listeners\TriggerAlertIfConditionsPassListener; +use App\Listeners\TriggerAlertForBreachedBudget; +use App\Listeners\TriggerAlertIfConditionsPassListenerForTransaction; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; @@ -30,21 +32,25 @@ class EventServiceProvider extends ServiceProvider TransactionUpdated::class => [ ApplyGroupToTransactionAutomaticallyListener::class, - TriggerAlertIfConditionsPassListener::class, + TriggerAlertIfConditionsPassListenerForTransaction::class, ], TransactionCreated::class => [ ApplyGroupToTransactionAutomaticallyListener::class, - TriggerAlertIfConditionsPassListener::class, + TriggerAlertIfConditionsPassListenerForTransaction::class, ], RegroupEvent::class => [ ApplyGroupToTransactionAutomaticallyListener::class, ], + BudgetBreachedEstablishedAmount::class => [ + TriggerAlertForBreachedBudget::class, + ], + TransactionGroupedEvent::class => [ // A transaction was newly grouped into some group. Do something. - TriggerAlertIfConditionsPassListener::class + TriggerAlertIfConditionsPassListenerForTransaction::class ], ]; diff --git a/composer.json b/composer.json index b871484e..0773d554 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,7 @@ "laravel/ui": "^2.0", "mustache/mustache": "^2.13", "predis/predis": "^1.1", + "rlanvin/php-rrule": "^2.2", "spatie/laravel-tags": "^2.6", "staudenmeir/belongs-to-through": "^2.10", "staudenmeir/eloquent-has-many-deep": "^1.7" diff --git a/composer.lock b/composer.lock index c1b44de6..b1e167ba 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "76e1d63ca0c3267ad89fe5ec62e07e5d", + "content-hash": "595eb33b973d57075e80fb3b2eda0ed6", "packages": [ { "name": "asm89/stack-cors", @@ -3905,6 +3905,51 @@ ], "time": "2020-03-29T20:13:32+00:00" }, + { + "name": "rlanvin/php-rrule", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/rlanvin/php-rrule.git", + "reference": "931d53d162cd84b46f6fa388cb4ea916bec02c18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rlanvin/php-rrule/zipball/931d53d162cd84b46f6fa388cb4ea916bec02c18", + "reference": "931d53d162cd84b46f6fa388cb4ea916bec02c18", + "shasum": "" + }, + "require": { + "php": ">=5.6.0" + }, + "require-dev": { + "phpmd/phpmd": "@stable", + "phpunit/phpunit": "^4.8|^5.5|^6.5" + }, + "suggest": { + "ext-intl": "Intl extension is needed for humanReadable()" + }, + "type": "library", + "autoload": { + "psr-4": { + "RRule\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Lightweight and fast recurrence rules for PHP (RFC 5545)", + "homepage": "https://github.com/rlanvin/php-rrule", + "keywords": [ + "date", + "ical", + "recurrence", + "recurring", + "rrule" + ], + "time": "2019-11-01T11:51:17+00:00" + }, { "name": "spatie/eloquent-sortable", "version": "3.8.2", diff --git a/database/factories/BudgetFactory.php b/database/factories/BudgetFactory.php new file mode 100644 index 00000000..c3af9907 --- /dev/null +++ b/database/factories/BudgetFactory.php @@ -0,0 +1,18 @@ +define(App\Budget::class, function (Faker $faker) { + return [ + 'user_id' => factory(\App\User::class), + 'name' => $faker->name, + 'amount' => $faker->numberBetween(10, 30), + 'frequency' => 1, + 'interval' => 'MONTHLY', + 'started_at' => $faker->dateTime, + 'count' => null, + 'breached_at' => null, + ]; +}); diff --git a/database/migrations/2020_07_28_035054_create_budgets_table.php b/database/migrations/2020_07_28_035054_create_budgets_table.php new file mode 100644 index 00000000..092c68df --- /dev/null +++ b/database/migrations/2020_07_28_035054_create_budgets_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->string('name'); + $table->double('amount'); + $table->string('frequency'); // Year, month, week, + $table->string('interval'); + $table->dateTime('started_at'); + $table->integer('count')->nullable(); + $table->dateTime('breached_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('budgets'); + } +} diff --git a/database/migrations/2020_08_10_002938_add_triggered_by_budget_d.php b/database/migrations/2020_08_10_002938_add_triggered_by_budget_d.php new file mode 100644 index 00000000..5c251590 --- /dev/null +++ b/database/migrations/2020_08_10_002938_add_triggered_by_budget_d.php @@ -0,0 +1,34 @@ +unsignedInteger('triggered_by_transaction_id')->nullable()->change(); + $table->unsignedInteger('triggered_by_budget_id')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('alert_logs', function (Blueprint $table) { + $table->unsignedInteger('triggered_by_transaction_id')->change(); + $table->dropColumn('triggered_by_budget_id'); + }); + } +} diff --git a/package.json b/package.json index 60d46f5b..ee8e6e04 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "bootstrap": "^4.1.0", "browser-sync": "^2.26.7", "browser-sync-webpack-plugin": "2.0.1", - "cross-env": "^5.1", + "cross-env": "^5.2.1", "jquery": "^3.2", "laravel-mix": "^4.0.7", "lodash": "^4.17.19", diff --git a/public/mix-manifest.json b/public/mix-manifest.json index 62b469ea..56dab799 100644 --- a/public/mix-manifest.json +++ b/public/mix-manifest.json @@ -1,5 +1,6 @@ { "/js/app.js": "/js/app.js", "/css/app.css": "/css/app.css", - "/js/settings-app.js": "/js/settings-app.js" + "/js/settings-app.js": "/js/settings-app.js", + "/js/app.0ae4edddf3ef7bb70617.hot-update.js": "/js/app.0ae4edddf3ef7bb70617.hot-update.js" } diff --git a/resources/js/alert-events.js b/resources/js/alert-events.js index c3fd9deb..0afd55cb 100644 --- a/resources/js/alert-events.js +++ b/resources/js/alert-events.js @@ -10,5 +10,9 @@ module.exports = [ { "type": "App\\Events\\TransactionGroupedEvent", "name": "When a transaction is added to a group (this gives you access to the `tag` variable in your title, body and payload.)" + }, + { + "type": "App\\Events\\BudgetBreachedEstablishedAmount", + "name": "When a budget's total spend amount for a period exceeds the set amount." } ] \ No newline at end of file diff --git a/resources/js/app.js b/resources/js/app.js index f723e5f7..bacb603b 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -13,6 +13,7 @@ import locale from 'dayjs/plugin/localizedFormat' import relativeTime from 'dayjs/plugin/relativeTime' window.buildUrl = buildUrl +dayjs.extend(require('dayjs/plugin/utc')) dayjs.extend(relativeTime) dayjs.extend(locale) dayjs.extend(require('./FormatToLocaleTimezone').default) @@ -83,6 +84,11 @@ const router = new VueRouter({ component: require('./routes/TransactionAlerts').default, props: true, }, + { + path: '/budgets', + component: require('./routes/Budgets').default, + props: true, + }, { path: '*', component: require('./routes/404').default, @@ -108,6 +114,7 @@ const start = async () => { await store.dispatch('fetchAccounts'); store.dispatch('fetchGroups'); store.dispatch('fetchAlerts'); + store.dispatch('fetchBudgets'); } catch (e) { console.error(e) } diff --git a/resources/js/components/BudgetModal.vue b/resources/js/components/BudgetModal.vue new file mode 100644 index 00000000..ffcfd80b --- /dev/null +++ b/resources/js/components/BudgetModal.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/resources/js/components/Utils/Card.vue b/resources/js/components/Utils/Card.vue index 0abd2ab4..8a0baaf4 100644 --- a/resources/js/components/Utils/Card.vue +++ b/resources/js/components/Utils/Card.vue @@ -1,7 +1,7 @@