Skip to content

Commit

Permalink
Merge pull request #187 from range-of-motion/152-implement-budgeting
Browse files Browse the repository at this point in the history
Implement budgeting
  • Loading branch information
range-of-motion authored Jul 13, 2020
2 parents ea33fcc + f80dd2d commit eefa451
Show file tree
Hide file tree
Showing 36 changed files with 830 additions and 11 deletions.
58 changes: 58 additions & 0 deletions app/Http/Controllers/BudgetController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

namespace App\Http\Controllers;

use App\Helper;
use App\Repositories\BudgetRepository;
use App\Repositories\TagRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;

class BudgetController extends Controller
{
private $budgetRepository;
private $tagRepository;

public function __construct(BudgetRepository $budgetRepository, TagRepository $tagRepository)
{
$this->budgetRepository = $budgetRepository;
$this->tagRepository = $tagRepository;
}

public function index()
{
return view('budgets.index', [
'budgets' => $this->budgetRepository->getActive()
]);
}

public function create()
{
return view('budgets.create', [
'tags' => session('space')->tags()->orderBy('created_at', 'DESC')->get()
]);
}

public function store(Request $request)
{
$request->validate($this->budgetRepository->getValidationRules());

$user = Auth::user();
$tag = $this->tagRepository->getById($request->tag_id);

if (!$user->can('view', $tag)) {
throw ValidationException::withMessages(['tag_id' => __('validation.forbidden')]);
}

if ($this->budgetRepository->doesExist(session('space')->id, $request->tag_id)) {
return redirect('/budgets/create')
->with('message', 'A budget like this already exists');
}

$amount = Helper::rawNumberToInteger($request->amount);
$this->budgetRepository->create(session('space')->id, $request->tag_id, $request->period, $amount);

return redirect('/budgets');
}
}
47 changes: 47 additions & 0 deletions app/Models/Budget.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Models;

use App\Helper;
use App\Repositories\BudgetRepository;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Budget extends Model
{
use SoftDeletes;

protected $fillable = [
'space_id',
'tag_id',
'period',
'amount',
'starts_on'
];

public function space()
{
return $this->belongsTo(Space::class);
}

public function tag()
{
return $this->belongsTo(Tag::class);
}

// Accessors
public function getFormattedAmountAttribute()
{
return Helper::formatNumber($this->amount / 100);
}

public function getSpentAttribute()
{
return (new BudgetRepository())->getSpentById($this->id);
}

public function getFormattedSpentAttribute()
{
return Helper::formatNumber($this->spent / 100);
}
}
5 changes: 5 additions & 0 deletions app/Policies/TagPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@

class TagPolicy
{
public function view(User $user, Tag $tag)
{
return $user->spaces->contains($tag->space_id);
}

public function edit(User $user, Tag $tag)
{
return $user->spaces->contains($tag->space_id);
Expand Down
128 changes: 128 additions & 0 deletions app/Repositories/BudgetRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

namespace App\Repositories;

use App\Models\Budget;
use App\Models\Spending;
use Exception;
use Illuminate\Support\Facades\DB;

class BudgetRepository
{
public function getValidationRules()
{
return [
'tag_id' => 'required|exists:tags,id',
'period' => 'required|in:' . implode(',', $this->getSupportedPeriods()),
'amount' => 'required|regex:/^\d*(\.\d{2})?$/'
];
}

public function getSupportedPeriods(): array
{
return [
'yearly',
'monthly',
'weekly',
'daily'
];
}

public function doesExist(int $spaceId, int $tagId): bool
{
return DB::selectOne('
SELECT COUNT(*) AS count
FROM budgets
WHERE
space_id = ?
AND tag_id = ?
AND (
starts_on <= NOW()
AND (
ends_on IS NULL
OR ends_on > NOW()
)
)
', [
$spaceId,
$tagId
])->count > 0;
}

public function getActive()
{
$today = date('Y-m-d');

return Budget::whereRaw('space_id = ?', [session('space')->id])
->whereRaw('starts_on <= ?', [$today])
->whereRaw('(ends_on >= ? OR ends_on IS NULL)', [$today])
->get();
}

public function getById(int $id): ?Budget
{
return Budget::find($id);
}

public function getSpentById(int $id): int
{
$budget = $this->getById($id);

if (!$budget) {
throw new Exception('Could not find budget (where ID is ' . $id . ')');
}

if ($budget->period === 'yearly') {
return Spending::where('space_id', session('space')->id)
->where('tag_id', $budget->tag->id)
->whereRaw('YEAR(happened_on) = ?', [date('Y')])
->sum('amount');
}

if ($budget->period === 'monthly') {
return Spending::where('space_id', session('space')->id)
->where('tag_id', $budget->tag->id)
->whereRaw('MONTH(happened_on) = ?', [date('n')])
->whereRaw('YEAR(happened_on) = ?', [date('Y')])
->sum('amount');
}

if ($budget->period === 'weekly') {
return Spending::where('space_id', session('space')->id)
->where('tag_id', $budget->tag->id)
->whereRaw('WEEK(happened_on) = WEEK(NOW())')
->sum('amount');
}

if ($budget->period === 'daily') {
return Spending::where('space_id', session('space')->id)
->where('tag_id', $budget->tag->id)
->whereRaw('happened_on = ?', [date('Y-m-d')])
->sum('amount');
}

throw new Exception('No clue what to do with period "' . $budget->period . '"');
}

public function create(int $spaceId, int $tagId, string $period, int $amount): ?Budget
{
if ($this->doesExist($spaceId, $tagId)) {
throw new Exception(vsprintf('Budget (with space ID being %s and tag ID being %s) already exists', [
$spaceId,
$tagId
]));
}

if (!in_array($period, $this->getSupportedPeriods())) {
throw new Exception('Unknown period "' . $period . '"');
}

return Budget::create([
'space_id' => $spaceId,
'tag_id' => $tagId,
'period' => $period,
'amount' => $amount,
'starts_on' => date('Y-m-d')
]);
}
}
5 changes: 5 additions & 0 deletions app/Repositories/TagRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ public function getValidationRules(): array
];
}

public function getById(int $id): ?Tag
{
return Tag::find($id);
}

public function getMostExpensiveTags(int $spaceId, int $limit = null, int $year = null, int $month = null)
{
$sql = '
Expand Down
8 changes: 8 additions & 0 deletions database/factories/ModelFactory.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use App\Models\Budget;
use App\Models\ConversionRate;
use App\Models\Currency;
use Faker\Generator;
Expand Down Expand Up @@ -49,6 +50,13 @@
];
});

$factory->define(Budget::class, function (Generator $faker) {
return [
'period' => 'monthly',
'amount' => $faker->randomNumber(3)
];
});

$factory->define(Recurring::class, function (Generator $faker) {
return [
'type' => 'earning',
Expand Down
28 changes: 28 additions & 0 deletions database/migrations/2020_07_01_181201_create_budgets_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateBudgetsTable extends Migration
{
public function up(): void
{
Schema::create('budgets', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('space_id');
$table->unsignedInteger('tag_id');
$table->string('period');
$table->unsignedInteger('amount');
$table->date('starts_on');
$table->date('ends_on')->nullable();
$table->timestamps();
$table->softDeletes();
});
}

public function down(): void
{
Schema::dropIfExists('budgets');
}
}
20 changes: 20 additions & 0 deletions resources/assets/sass/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@ canvas {
width: 100%;
}

progress {
appearance: none;
width: 100%;
max-width: 300px;
height: 10px;
vertical-align: 0;

&::-webkit-progress-bar {
background: #E2E8F0;
border-radius: 5px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
overflow: hidden; // To prevent the border-radius from the value from overflowing when its width is below 10px
}

&::-webkit-progress-value {
background: $colors-primary;
border-radius: 5px;
}
}

.bg {
display: flex;

Expand Down
7 changes: 7 additions & 0 deletions resources/lang/de/calendar.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,12 @@
4 => 'Freitag',
5 => 'Samstag',
6 => 'Sonntag'
],
'intervals' => [
'yearly' => 'Jährlich',
'monthly' => 'Monatlich',
'biweekly' => 'Zweiwöchentlich',
'weekly' => 'Wöchentlich',
'daily' => 'Täglich'
]
];
5 changes: 5 additions & 0 deletions resources/lang/de/fields.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

return [
'period' => 'Zeitraum'
];
4 changes: 3 additions & 1 deletion resources/lang/de/general.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,7 @@
'password' => 'Passwort',
'language' => 'Sprache',
'theme' => 'Design',
'recurrings' => 'Zyklisch'
'recurrings' => 'Zyklisch',

'of' => 'von'
];
7 changes: 7 additions & 0 deletions resources/lang/dk/calendar.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,12 @@
4 => 'Fredag',
5 => 'Lørdag',
6 => 'Søndag'
],
'intervals' => [
'yearly' => 'Årligt',
'monthly' => 'Månedligt',
'biweekly' => 'Hver anden uge',
'weekly' => 'Ugentlig',
'daily' => 'Dagligt'
]
];
2 changes: 2 additions & 0 deletions resources/lang/dk/fields.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,7 @@
'description' => 'Beskrivelse',
'amount' => 'Beløb',

'period' => 'Periode',

'file' => 'Fil'
];
4 changes: 3 additions & 1 deletion resources/lang/dk/general.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,7 @@
'account' => 'Account',
'preferences' => 'Præferencer',

'empty_state' => 'Der er endnu ingen :resource'
'empty_state' => 'Der er endnu ingen :resource',

'of' => 'af'
];
Loading

0 comments on commit eefa451

Please sign in to comment.