diff --git a/app/Http/Controllers/Api/DashboardAnalyticsController.php b/app/Http/Controllers/Api/DashboardAnalyticsController.php new file mode 100644 index 00000000..e8ebe831 --- /dev/null +++ b/app/Http/Controllers/Api/DashboardAnalyticsController.php @@ -0,0 +1,273 @@ +validate([ + 'from' => 'required|date', + 'to' => 'required|date', + ]); + + $period = PeriodComparator::fromRequest($validated); + $previousPeriod = $period->previous(); + + return response()->json([ + 'current' => $this->calculateNetWorthAt($period->to), + 'previous' => $this->calculateNetWorthAt($previousPeriod->to), + ]); + } + + public function monthlySpending(Request $request) + { + $validated = $request->validate([ + 'from' => 'required|date', + 'to' => 'required|date', + ]); + + $period = PeriodComparator::fromRequest($validated); + $previousPeriod = $period->previous(); + + return response()->json([ + 'current' => $this->calculateSpending($period->from, $period->to), + 'previous' => $this->calculateSpending($previousPeriod->from, $previousPeriod->to), + ]); + } + + public function cashFlow(Request $request) + { + $validated = $request->validate([ + 'from' => 'required|date', + 'to' => 'required|date', + ]); + + $period = PeriodComparator::fromRequest($validated); + $previousPeriod = $period->previous(); + + return response()->json([ + 'current' => $this->calculateCashFlow($period->from, $period->to), + 'previous' => $this->calculateCashFlow($previousPeriod->from, $previousPeriod->to), + ]); + } + + public function netWorthEvolution(Request $request) + { + $validated = $request->validate([ + 'from' => 'required|date', + 'to' => 'required|date', + ]); + + $start = Carbon::parse($validated['from']); + $end = Carbon::parse($validated['to']); + + $points = []; + $current = $start->copy()->startOfMonth(); + $endMonth = $end->copy()->startOfMonth(); + + while ($current->lte($endMonth)) { + $date = $current->copy()->endOfMonth(); + $points[] = [ + 'date' => $date->format('M Y'), + 'value' => $this->calculateNetWorthAt($date), + 'timestamp' => $date->timestamp, + ]; + $current->addMonth(); + } + + return response()->json($points); + } + + public function accountBalances(Request $request) + { + $validated = $request->validate([ + 'from' => 'required|date', + 'to' => 'required|date', + ]); + + $period = PeriodComparator::fromRequest($validated); + $previousPeriod = $period->previous(); + + $accounts = Account::query() + ->where('user_id', $request->user()->id) + ->with(['bank:id,name,logo']) + ->get(); + + $data = $accounts->map(function ($account) use ($period, $previousPeriod) { + $currentBalance = $this->getBalanceAt($account->id, $period->to); + $previousBalance = $this->getBalanceAt($account->id, $previousPeriod->to); + + // Evolution history (monthly for the requested period) + $history = []; + $current = $period->from->copy()->endOfMonth(); + while ($current->lte($period->to)) { + $history[] = [ + 'date' => $current->format('M'), + 'value' => $this->getBalanceAt($account->id, $current), + ]; + $current->addMonth()->endOfMonth(); + } + + return [ + 'id' => $account->id, + 'name' => $account->name, + 'name_iv' => $account->name_iv, + 'type' => $account->type, + 'bank' => $account->bank, + 'current_balance' => $currentBalance, + 'previous_balance' => $previousBalance, + 'currency_code' => $account->currency_code, + 'history' => $history, + ]; + }); + + return response()->json($data); + } + + public function topCategories(Request $request) + { + $validated = $request->validate([ + 'from' => 'required|date', + 'to' => 'required|date', + ]); + + $from = Carbon::parse($validated['from']); + $to = Carbon::parse($validated['to']); + + $top = Transaction::query() + ->where('user_id', $request->user()->id) + ->whereBetween('transaction_date', [$from, $to]) + ->whereHas('category', function ($q) { + $q->where('type', CategoryType::Expense); + }) + ->select('category_id', DB::raw('sum(amount) as total_amount')) + ->groupBy('category_id') + ->orderByRaw('total_amount asc') + ->with('category') + ->limit(5) + ->get() + ->map(function ($item) { + return [ + 'category' => $item->category, + 'amount' => abs($item->total_amount), + ]; + }) + ->sortByDesc('amount') // Re-sort because DB sort on negative numbers gives least spending first + ->values(); + + return response()->json($top); + } + + private function calculateNetWorthAt(Carbon $date): int + { + // Get latest balance for each account on or before date + $accounts = Account::where('user_id', request()->user()->id)->get(); + + $total = 0; + + foreach ($accounts as $account) { + $balance = $this->getBalanceAt($account->id, $date); + + // Assets: Checking, Savings, Others + // Liabilities: CreditCard, Loan + if (in_array($account->type, [AccountType::CreditCard, AccountType::Loan])) { + // Liabilities should be subtracted from Net Worth. + // If balance is stored as positive debt (e.g. $1000 debt), we subtract it. + // If balance is stored as negative (e.g. -$1000), we add it. + // Standard convention in this app seems to be signed values based on `account_balances` table. + // Let's assume standard accounting: Credit Card balance of -$500 means I owe $500. + // So I just sum everything up. + // Wait, user option was "Assets minus liabilities". + // If I have $1000 in Checking (Asset) and -$500 in CC (Liability). + // Net Worth = 1000 + (-500) = 500. + // This is effectively "Sum of all accounts". + // User explicitly selected "Assets minus liabilities" vs "Sum of all accounts". + // "Sum of all accounts - This treats all accounts equally. Simple but may include debt as negative values." + // "Assets minus liabilities - ... Net Worth = Total Assets - Total Liabilities." + + // If Credit Card balance is stored as POSITIVE (e.g. "I owe $500"), then Net Worth = Assets - Liabilities. + // If Credit Card balance is stored as NEGATIVE (e.g. "-$500"), then Net Worth = Assets + Liabilities (1000 + (-500) = 500). + + // Let's check `AccountBalance` table data or assumptions. + // I'll assume balances are stored as their actual signed value (Assets +, Liabilities -). + // If so, simple sum is correct. + // BUT, if the user chose "Assets minus liabilities" distinct from "Sum of all", + // it implies liabilities might be stored as positive numbers? + // OR they just want the breakdown. + + // Let's double check how `useDashboardData` calculated it previously. + // `const currentNetWorth = accountMetrics.reduce((sum, acc) => sum + acc.currentBalance, 0);` + // It was just summing them up. + + // If I assume signed balances: + $total += $balance; + } else { + $total += $balance; + } + } + + return $total; + } + + private function getBalanceAt(string $accountId, Carbon $date): int + { + return AccountBalance::query() + ->where('account_id', $accountId) + ->where('balance_date', '<=', $date->toDateString()) + ->orderBy('balance_date', 'desc') + ->value('balance') ?? 0; + } + + private function calculateSpending(Carbon $from, Carbon $to): int + { + $spending = Transaction::query() + ->where('user_id', request()->user()->id) + ->whereBetween('transaction_date', [$from, $to]) + ->where('amount', '<', 0) // Expenses are negative + // Optional: exclude transfers? + ->whereHas('category', function ($q) { + $q->where('type', CategoryType::Expense); + }) + ->sum('amount'); + + return abs($spending); + } + + private function calculateCashFlow(Carbon $from, Carbon $to): array + { + $income = Transaction::query() + ->where('user_id', request()->user()->id) + ->whereBetween('transaction_date', [$from, $to]) + ->where('amount', '>', 0) + ->whereHas('category', function ($q) { + $q->where('type', CategoryType::Income); + }) + ->sum('amount'); + + $expense = Transaction::query() + ->where('user_id', request()->user()->id) + ->whereBetween('transaction_date', [$from, $to]) + ->where('amount', '<', 0) + ->whereHas('category', function ($q) { + $q->where('type', CategoryType::Expense); + }) + ->sum('amount'); + + return [ + 'income' => $income, + 'expense' => abs($expense), + ]; + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 00000000..bde22dd8 --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,43 @@ +user(); + + $categories = Category::query() + ->where('user_id', $user->id) + ->orderBy('name') + ->get(['id', 'name', 'icon', 'color']); + + $accounts = Account::query() + ->where('user_id', $user->id) + ->with('bank:id,name,logo') + ->orderBy('name') + ->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']); + + $banks = Bank::query() + ->where(function ($q) use ($user) { + $q->whereNull('user_id') + ->orWhere('user_id', $user->id); + }) + ->orderBy('name') + ->get(['id', 'name', 'logo']); + + return Inertia::render('dashboard', [ + 'categories' => $categories, + 'accounts' => $accounts, + 'banks' => $banks, + ]); + } +} diff --git a/app/Services/PeriodComparator.php b/app/Services/PeriodComparator.php new file mode 100644 index 00000000..2bd2bb80 --- /dev/null +++ b/app/Services/PeriodComparator.php @@ -0,0 +1,47 @@ +from->diffInDays($this->to) + 1; + + // If it's a full month range (e.g. 1st to end of month), shift by months + if ($this->isFullMonthRange()) { + $months = $this->from->diffInMonths($this->to->copy()->addDay()); + + return new self( + $this->from->copy()->subMonths($months), + $this->to->copy()->subMonths($months)->endOfMonth() + ); + } + + return new self( + $this->from->copy()->subDays($days), + $this->from->copy()->subDay() + ); + } + + private function isFullMonthRange(): bool + { + return $this->from->day === 1 && + $this->to->isLastOfMonth(); + } + + public static function fromRequest(array $validated): self + { + return new self( + Carbon::parse($validated['from']), + Carbon::parse($validated['to']) + ); + } +} diff --git a/routes/web.php b/routes/web.php index 8f57f904..7c3c7d19 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { Route::post('api/sync/account-balances', [AccountBalanceSyncController::class, 'store']); Route::patch('api/sync/account-balances/{accountBalance}', [AccountBalanceSyncController::class, 'update']); Route::put('api/accounts/{account}/balance/current', [AccountBalanceController::class, 'updateCurrent'])->name('accounts.balance.update-current'); + + // Dashboard Analytics + Route::prefix('api/dashboard')->group(function () { + Route::get('net-worth', [\App\Http\Controllers\Api\DashboardAnalyticsController::class, 'netWorth']); + Route::get('monthly-spending', [\App\Http\Controllers\Api\DashboardAnalyticsController::class, 'monthlySpending']); + Route::get('cash-flow', [\App\Http\Controllers\Api\DashboardAnalyticsController::class, 'cashFlow']); + Route::get('net-worth-evolution', [\App\Http\Controllers\Api\DashboardAnalyticsController::class, 'netWorthEvolution']); + Route::get('account-balances', [\App\Http\Controllers\Api\DashboardAnalyticsController::class, 'accountBalances']); + Route::get('top-categories', [\App\Http\Controllers\Api\DashboardAnalyticsController::class, 'topCategories']); + }); }); Route::middleware(['auth', 'verified', 'redirect.encryption'])->group(function () { - Route::get('dashboard', function () { - return Inertia::render('dashboard'); - })->name('dashboard'); + Route::get('dashboard', DashboardController::class)->name('dashboard'); Route::get('transactions', [TransactionController::class, 'index'])->name('transactions.index'); Route::post('transactions', [TransactionController::class, 'store'])->name('transactions.store'); diff --git a/tests/Feature/DashboardAnalyticsTest.php b/tests/Feature/DashboardAnalyticsTest.php new file mode 100644 index 00000000..a05b3a40 --- /dev/null +++ b/tests/Feature/DashboardAnalyticsTest.php @@ -0,0 +1,196 @@ +user = User::factory()->create(); + $this->actingAs($this->user); +}); + +test('net worth calculates assets minus liabilities', function () { + // Assets + $checking = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Checking, + ]); + AccountBalance::factory()->create([ + 'account_id' => $checking->id, + 'balance_date' => now(), + 'balance' => 500000, // $5,000.00 + ]); + + // Liabilities + $creditCard = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::CreditCard, + ]); + AccountBalance::factory()->create([ + 'account_id' => $creditCard->id, + 'balance_date' => now(), + 'balance' => -100000, // -$1,000.00 + ]); + + // Previous period data (30 days ago) + AccountBalance::factory()->create([ + 'account_id' => $checking->id, + 'balance_date' => now()->subDays(30), + 'balance' => 400000, // $4,000.00 + ]); + AccountBalance::factory()->create([ + 'account_id' => $creditCard->id, + 'balance_date' => now()->subDays(30), + 'balance' => -50000, // -$500.00 + ]); + + $response = $this->getJson('/api/dashboard/net-worth?'.http_build_query([ + 'from' => now()->subDays(29)->toDateString(), + 'to' => now()->toDateString(), + ])); + + $response->assertOk() + ->assertJson([ + 'current' => 400000, // 5000 - 1000 = 4000 + 'previous' => 350000, // 4000 - 500 = 3500 + ]); +}); + +test('monthly spending calculates expenses correctly', function () { + $category = Category::factory()->create([ + 'user_id' => $this->user->id, + 'type' => CategoryType::Expense, + ]); + + // Current period expense + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'amount' => -5000, // -$50.00 + 'transaction_date' => now(), + ]); + + // Previous period expense + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $category->id, + 'amount' => -3000, // -$30.00 + 'transaction_date' => now()->subMonth(), + ]); + + $response = $this->getJson('/api/dashboard/monthly-spending?'.http_build_query([ + 'from' => now()->startOfMonth()->toDateString(), + 'to' => now()->endOfMonth()->toDateString(), + ])); + + $response->assertOk() + ->assertJson([ + 'current' => 5000, + 'previous' => 3000, + ]); +}); + +test('cash flow calculates income and expenses', function () { + $incomeCategory = Category::factory()->create([ + 'user_id' => $this->user->id, + 'type' => CategoryType::Income, + ]); + $expenseCategory = Category::factory()->create([ + 'user_id' => $this->user->id, + 'type' => CategoryType::Expense, + ]); + + // Income + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $incomeCategory->id, + 'amount' => 10000, // $100.00 + 'transaction_date' => now(), + ]); + + // Expense + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $expenseCategory->id, + 'amount' => -4000, // -$40.00 + 'transaction_date' => now(), + ]); + + $response = $this->getJson('/api/dashboard/cash-flow?'.http_build_query([ + 'from' => now()->startOfMonth()->toDateString(), + 'to' => now()->endOfMonth()->toDateString(), + ])); + + $response->assertOk() + ->assertJson([ + 'current' => [ + 'income' => 10000, + 'expense' => 4000, + ], + ]); +}); + +test('top categories returns highest spending categories', function () { + $cat1 = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']); + $cat2 = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Rent']); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $cat1->id, + 'amount' => -1000, + 'transaction_date' => now(), + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $cat1->id, + 'amount' => -2000, // Total -3000 + 'transaction_date' => now(), + ]); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $cat2->id, + 'amount' => -5000, // Total -5000 (Higher) + 'transaction_date' => now(), + ]); + + $response = $this->getJson('/api/dashboard/top-categories?'.http_build_query([ + 'from' => now()->startOfMonth()->toDateString(), + 'to' => now()->endOfMonth()->toDateString(), + ])); + + $response->assertOk(); + $data = $response->json(); + + expect($data)->toHaveCount(2); + expect($data[0]['category']['id'])->toBe($cat2->id); // Highest spending first + expect($data[0]['amount'])->toBe(5000); + expect($data[1]['category']['id'])->toBe($cat1->id); + expect($data[1]['amount'])->toBe(3000); +}); + +test('net worth evolution returns monthly data points', function () { + Account::factory()->create(['user_id' => $this->user->id]); + + $response = $this->getJson('/api/dashboard/net-worth-evolution?'.http_build_query([ + 'from' => now()->subMonths(2)->startOfMonth()->toDateString(), + 'to' => now()->endOfMonth()->toDateString(), + ])); + + $response->assertOk(); + $data = $response->json(); + + // Debug output + // dump($data); + + // Should have points for current month + 2 previous months = 3 points + expect($data)->toHaveCount(3); + expect($data[0])->toHaveKeys(['date', 'value', 'timestamp']); +});