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:
parent
82b94eafe1
commit
595057c66e
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
Loading…
Reference in New Issue