From d21cb1f176f489b21df0d50f8908ab1cfb300f01 Mon Sep 17 00:00:00 2001 From: Saleem Hadad Date: Mon, 22 Apr 2024 14:02:20 +0400 Subject: [PATCH] add budget backend logic beta --- app/Models/Budget.php | 86 +++++++++++++++++++ app/Models/Category.php | 20 ++++- app/Models/Sms.php | 1 - database/factories/BudgetFactory.php | 28 ++++++ ...2024_04_21_173031_create_budgets_table.php | 34 ++++++++ ...22_011017_create_budget_category_table.php | 29 +++++++ graphql/schema.graphql | 9 ++ tests/Unit/Models/Budgets/BudgetTest.php | 65 ++++++++++++++ 8 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 app/Models/Budget.php create mode 100644 database/factories/BudgetFactory.php create mode 100644 database/migrations/2024_04_21_173031_create_budgets_table.php create mode 100644 database/migrations/2024_04_22_011017_create_budget_category_table.php create mode 100644 tests/Unit/Models/Budgets/BudgetTest.php diff --git a/app/Models/Budget.php b/app/Models/Budget.php new file mode 100644 index 0000000..b72b35d --- /dev/null +++ b/app/Models/Budget.php @@ -0,0 +1,86 @@ + 'datetime', + 'end_at' => 'datetime', + ]; + + /** + * @return BelongsToMany + */ + public function categories(): BelongsToMany + { + return $this->belongsToMany(Category::class); + } + + public function getStartAtDateAttribute() + { + return $this->getCurrentWindowStartAndEndDates()[0]->format('Y-m-d'); + } + + public function getEndAtDateAttribute() + { + return $this->getCurrentWindowStartAndEndDates()[1]->format('Y-m-d'); + } + + public function getTotalAccumulatedTransactionsAmountAttribute() + { + $categories = $this->categories()->with('transactions')->get(); + [$startAt, $endAt] = $this->getCurrentWindowStartAndEndDates(); + + return $categories->sum(function ($category) use ($startAt, $endAt){ + return $category->transactions() + ->whereBetween('transactions.created_at', [$startAt, $endAt]) + ->sum('amount'); + }); + } + + private function getCurrentWindowStartAndEndDates() + { + if($this->reoccurrence === self::CUSTOM) { + return [$this->start_at, $this->end_at]; + } + + $unit = $this->getUnitMapping(); + $intervalString = $this->period . ' ' . $unit; + $ranges = CarbonPeriod::create($this->start_at->startOfDay(), $intervalString, now()->copy()->add($unit, $this->period))->toArray(); + + foreach (array_reverse($ranges) as $range) { + if(now()->isAfter($range)) { + return [$range->copy(), $range->copy()->add($unit, $this->period)]; + } + } + } + + /** + * @return string + */ + private function getUnitMapping(): string + { + return [ + self::DAILY => 'day', + self::WEEKLY => 'week', + self::MONTHLY => 'month', + self::YEARLY => 'year', + ][$this->reoccurrence]; + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php index c416661..26608ab 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -5,7 +5,9 @@ use App\Contracts\Searchable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; class Category extends Model implements Searchable { @@ -18,18 +20,32 @@ class Category extends Model implements Searchable protected $guarded = []; - protected static function booted() + /** + * @return void + */ + protected static function booted(): void { static::deleted(function ($category) { $category->brands->each->delete(); }); } - public function brands() + /** + * @return HasMany + */ + public function brands(): HasMany { return $this->hasMany(Brand::class); } + /** + * @return HasManyThrough + */ + public function transactions(): HasManyThrough + { + return $this->hasManyThrough(Transaction::class, Brand::class); + } + /** * @param $query * @return Builder diff --git a/app/Models/Sms.php b/app/Models/Sms.php index 70fcab5..353340c 100644 --- a/app/Models/Sms.php +++ b/app/Models/Sms.php @@ -6,7 +6,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; -use function PHPUnit\Framework\isEmpty; class Sms extends Model implements Searchable { diff --git a/database/factories/BudgetFactory.php b/database/factories/BudgetFactory.php new file mode 100644 index 0000000..d310f85 --- /dev/null +++ b/database/factories/BudgetFactory.php @@ -0,0 +1,28 @@ + + */ +class BudgetFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->name(), + 'amount' => $this->faker->numberBetween(), + 'start_at' => now(), + 'reoccurrence' => Budget::DAILY, + 'period' => 1, + ]; + } +} diff --git a/database/migrations/2024_04_21_173031_create_budgets_table.php b/database/migrations/2024_04_21_173031_create_budgets_table.php new file mode 100644 index 0000000..bc08a96 --- /dev/null +++ b/database/migrations/2024_04_21_173031_create_budgets_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('name'); + $table->decimal('amount'); + $table->dateTime('start_at'); + $table->dateTime('end_at')->nullable(); + $table->boolean('saving')->default(false); + $table->unsignedInteger('period')->default(1); + $table->string('reoccurrence'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('budgets'); + } +}; diff --git a/database/migrations/2024_04_22_011017_create_budget_category_table.php b/database/migrations/2024_04_22_011017_create_budget_category_table.php new file mode 100644 index 0000000..c9e48fa --- /dev/null +++ b/database/migrations/2024_04_22_011017_create_budget_category_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('budget_id')->constrained()->onDelete('cascade'); + $table->foreignId('category_id')->constrained()->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('budget_category'); + } +}; diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 3131302..2f3fe11 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -14,6 +14,14 @@ type Category { color: String } +type Budget { + id: ID! + amount: Float! + totalAccumulatedTransactionsAmount: Float! + startAtDate: String! + endAtDate: String! +} + type Brand { id: ID! name: String! @@ -48,6 +56,7 @@ type Query { @orderBy(column: id direction: DESC) allBrands: [Brand!]! @all + budgets: [Budget!]! @all brands(search: String @search): [Brand!]! @paginate(defaultCount: 50) @lazyLoad(relations: ["category"]) diff --git a/tests/Unit/Models/Budgets/BudgetTest.php b/tests/Unit/Models/Budgets/BudgetTest.php new file mode 100644 index 0000000..b47c419 --- /dev/null +++ b/tests/Unit/Models/Budgets/BudgetTest.php @@ -0,0 +1,65 @@ +create(['name' => 'test']); + + $this->assertEquals("test", $sut->name); + } + + /** @test */ + public function it_belongs_to_categories() + { + $categories = Category::factory()->createMany(3); + $sut = Budget::factory()->create(); + $sut->categories()->attach($categories); + + $this->assertCount(3, $sut->categories); + } + + /** @test */ + public function it_has_total_categories_transactions_amount() + { + $category = Category::factory()->create(); + $brand = Brand::factory()->create(['category_id' => $category->id]); + $sut = Budget::factory()->create(['start_at' => now()->subDays(1), 'end_at' => now()->addDays(1)]); + $sut->categories()->attach($category); + + $category->transactions()->create(['amount' => 100, 'brand_id' => $brand->id]); + $category->transactions()->create(['amount' => 200, 'brand_id' => $brand->id]); + $category->transactions()->create(['amount' => 200, 'brand_id' => $brand->id, 'created_at' => now()->addDays(2)]); + + $this->assertEquals(300, $sut->totalAccumulatedTransactionsAmount); + } + + /** @test */ + public function it_has_start_and_end_at() + { + $sut = Budget::factory()->create(['start_at' => now()->subDays(1), 'end_at' => now()->addDays(1)]); + + $this->assertEquals(now()->subDays(1)->format('Y-m-d'), $sut->start_at->format('Y-m-d')); + $this->assertEquals(now()->addDays(1)->format('Y-m-d'), $sut->end_at->format('Y-m-d')); + } + + /** @test */ + public function it_has_start_and_end_dates_window() + { + $sut = Budget::factory()->create(['start_at' => now()->subDays(1), 'period' => 1, 'reoccurrence' => Budget::DAILY]); + + $this->assertEquals(now()->format('Y-m-d'), $sut->startAtDate); + $this->assertEquals(now()->addDays(1)->format('Y-m-d'), $sut->endAtDate); + } +}