Add dashboard analytics backend

- Create DashboardAnalyticsController with 6 analytics endpoints:
  - Net worth calculation and comparison
  - Monthly spending with period comparison
  - Cash flow (income vs expenses)
  - Net worth evolution over time
  - Account balance history
  - Top spending categories
- Add PeriodComparator service for date range comparisons
- Update DashboardController to pass categories, accounts, and banks
- Add comprehensive feature tests for all analytics endpoints
- Register analytics routes under /api/dashboard
This commit is contained in:
Víctor Falcón 2025-12-01 10:30:35 +01:00
parent 82b94eafe1
commit 595057c66e
5 changed files with 571 additions and 3 deletions

View File

@ -0,0 +1,273 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\AccountType;
use App\Enums\CategoryType;
use App\Http\Controllers\Controller;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Transaction;
use App\Services\PeriodComparator;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DashboardAnalyticsController extends Controller
{
public function netWorth(Request $request)
{
$validated = $request->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),
];
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Category;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __invoke(Request $request): Response
{
$user = $request->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,
]);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Services;
use Carbon\Carbon;
class PeriodComparator
{
public function __construct(
public readonly Carbon $from,
public readonly Carbon $to
) {}
public function previous(): self
{
$days = $this->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'])
);
}
}

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\AccountBalanceController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\EncryptionController;
use App\Http\Controllers\RobotsController;
use App\Http\Controllers\SitemapController;
@ -65,12 +66,20 @@ Route::middleware(['auth'])->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');

View File

@ -0,0 +1,196 @@
<?php
use App\Enums\AccountType;
use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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']);
});