diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 11d53d25..2a4fd90e 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -5,6 +5,11 @@ namespace App\Http\Controllers; use App\Models\Account; use App\Models\Bank; use App\Models\Category; +use App\Models\User; +use App\Services\BalanceLookup; +use App\Services\ExchangeRateService; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\Request; use Inertia\Inertia; @@ -14,6 +19,8 @@ class AccountController extends Controller { use AuthorizesRequests; + public function __construct(private ExchangeRateService $exchangeRateService) {} + public function index(Request $request): Response { $user = $request->user(); @@ -27,9 +34,94 @@ class AccountController extends Controller return Inertia::render('Accounts/Index', [ 'accounts' => $accounts, + 'accountMetrics' => Inertia::defer(fn () => $this->getAccountMetrics($user, $accounts)), ]); } + /** + * Compute per-account balance metrics: current balance, previous month balance, and 12-month sparkline history. + * + * @param Collection $accounts + * @return array}> + */ + private function getAccountMetrics(User $user, Collection $accounts): array + { + $userCurrency = $user->currency_code; + $now = Carbon::now(); + $rangeStart = $now->copy()->subMonths(12)->startOfMonth(); + + $accountIds = $accounts->pluck('id'); + $lookup = BalanceLookup::forAccounts($accountIds, $rangeStart, $now->copy()); + + $metrics = []; + + foreach ($accounts as $account) { + $history = []; + $current = $rangeStart->copy(); + $endMonth = $now->copy()->startOfMonth(); + + while ($current->lte($endMonth)) { + $date = $current->copy()->endOfMonth(); + $originalBalance = $lookup->getBalanceAt($account->id, $date); + $convertedBalance = $this->convertBalance($originalBalance, $account->currency_code, $userCurrency, $date->toDateString()); + + $point = [ + 'date' => $this->formatMonth($date), + 'value' => $convertedBalance, + ]; + + if ($account->type->supportsInvestedAmount()) { + $investedAmount = $lookup->getInvestedAmountAt($account->id, $date); + $point['investedAmount'] = $investedAmount !== null + ? $this->convertBalance($investedAmount, $account->currency_code, $userCurrency, $date->toDateString()) + : null; + } + + $history[] = $point; + $current->addMonth(); + } + + $currentBalance = end($history)['value'] ?? 0; + $previousBalance = count($history) > 1 ? $history[count($history) - 2]['value'] : 0; + + $investedAmount = null; + if ($account->type->supportsInvestedAmount()) { + $rawInvested = $lookup->getInvestedAmountAt($account->id, $now); + $investedAmount = $rawInvested !== null + ? $this->convertBalance($rawInvested, $account->currency_code, $userCurrency, $now->toDateString()) + : null; + } + + $metrics[$account->id] = [ + 'currentBalance' => $currentBalance, + 'previousBalance' => $previousBalance, + 'diff' => $currentBalance - $previousBalance, + 'investedAmount' => $investedAmount, + 'history' => $history, + ]; + } + + return $metrics; + } + + private function formatMonth(Carbon $date): string + { + $isCurrentYear = $date->year === Carbon::now()->year; + + return $isCurrentYear + ? $date->format('M') + : $date->format("M 'y"); + } + + private function convertBalance(int $balance, string $sourceCurrency, string $targetCurrency, string $date): int + { + if (strtolower($sourceCurrency) === strtolower($targetCurrency)) { + return $balance; + } + + return $this->exchangeRateService->convert($sourceCurrency, $targetCurrency, $balance, $date); + } + public function show(Request $request, Account $account): Response { $this->authorize('view', $account); diff --git a/resources/js/pages/Accounts/Index.tsx b/resources/js/pages/Accounts/Index.tsx index 7756db6a..354c698a 100644 --- a/resources/js/pages/Accounts/Index.tsx +++ b/resources/js/pages/Accounts/Index.tsx @@ -1,13 +1,13 @@ import { index } from '@/actions/App/Http/Controllers/AccountController'; import { AccountListCard } from '@/components/accounts/account-list-card'; import HeadingSmall from '@/components/heading-small'; -import { useDashboardData } from '@/hooks/use-dashboard-data'; +import { AccountWithMetrics } from '@/hooks/use-dashboard-data'; import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; import { BreadcrumbItem } from '@/types'; import { Account, AccountType } from '@/types/account'; import { __ } from '@/utils/i18n'; -import { Head } from '@inertiajs/react'; -import { useMemo } from 'react'; +import { Head, router } from '@inertiajs/react'; +import { useCallback, useMemo } from 'react'; const breadcrumbs: BreadcrumbItem[] = [ { @@ -26,16 +26,29 @@ const ACCOUNT_TYPE_ORDER: AccountType[] = [ 'others', ]; -interface Props { - accounts: Account[]; +interface AccountMetrics { + currentBalance: number; + previousBalance: number; + diff: number; + investedAmount: number | null; + history: Array<{ + date: string; + value: number; + investedAmount?: number | null; + }>; } -export default function AccountsIndex({ accounts }: Props) { - const { accounts: accountMetrics, isLoading, refetch } = useDashboardData(); +interface Props { + accounts: Account[]; + accountMetrics?: Record; +} - const accountsWithMetrics = useMemo(() => { +export default function AccountsIndex({ accounts, accountMetrics }: Props) { + const isLoading = !accountMetrics; + + const accountsWithMetrics: AccountWithMetrics[] = useMemo(() => { return accounts.map((account) => { - const metrics = accountMetrics.find((m) => m.id === account.id); + const metrics = accountMetrics?.[account.id]; return { ...account, currentBalance: metrics?.currentBalance ?? 0, @@ -48,7 +61,7 @@ export default function AccountsIndex({ accounts }: Props) { }, [accounts, accountMetrics]); const groupedAccounts = useMemo(() => { - const groups: Record = { + const groups: Record = { checking: [], savings: [], investment: [], @@ -70,6 +83,10 @@ export default function AccountsIndex({ accounts }: Props) { return groups; }, [accountsWithMetrics]); + const handleBalanceUpdated = useCallback(() => { + router.reload({ only: ['accountMetrics'] }); + }, []); + return ( @@ -90,14 +107,14 @@ export default function AccountsIndex({ accounts }: Props) { key={account.id} account={account} loading={isLoading} - onBalanceUpdated={refetch} + onBalanceUpdated={handleBalanceUpdated} /> )); })} {accounts.length === 0 && !isLoading && ( -
+
{__( 'No accounts found. Add your first account in Settings.', )} diff --git a/tests/Browser/AccountsPageTest.php b/tests/Browser/AccountsPageTest.php new file mode 100644 index 00000000..7f7d411b --- /dev/null +++ b/tests/Browser/AccountsPageTest.php @@ -0,0 +1,140 @@ +onboarded()->create(); + + actingAs($user); + + $page = visit('/accounts'); + + $page->assertSee('Accounts') + ->assertSee('View and manage your bank accounts') + ->assertNoJavascriptErrors(); +}); + +it('shows empty state when no accounts exist', function () { + $user = User::factory()->onboarded()->create(); + + actingAs($user); + + $page = visit('/accounts'); + + $page->assertSee('Accounts') + ->waitForText('No accounts found') + ->assertNoJavascriptErrors(); +}); + +it('shows account cards for existing accounts', function () { + $user = User::factory()->onboarded()->create(); + $bank = Bank::factory()->create(['name' => 'Test Bank', 'logo' => null]); + + Account::factory()->create([ + 'user_id' => $user->id, + 'bank_id' => $bank->id, + 'name' => 'My Checking', + 'type' => AccountType::Checking, + 'currency_code' => 'USD', + ]); + + actingAs($user); + + $page = visit('/accounts'); + $page->navigate('/accounts', ['waitUntil' => 'domcontentloaded'])->wait(2); + + $page->assertSee('Accounts') + ->waitForText('My Checking') + ->assertSee('Test Bank') + ->assertNoJavascriptErrors(); +}); + +it('shows multiple accounts grouped by type', function () { + $user = User::factory()->onboarded()->create(); + $bank = Bank::factory()->create(['name' => 'Bank One', 'logo' => null]); + + Account::factory()->create([ + 'user_id' => $user->id, + 'bank_id' => $bank->id, + 'name' => 'Daily Checking', + 'type' => AccountType::Checking, + 'currency_code' => 'USD', + ]); + Account::factory()->create([ + 'user_id' => $user->id, + 'bank_id' => $bank->id, + 'name' => 'Rainy Day Savings', + 'type' => AccountType::Savings, + 'currency_code' => 'USD', + ]); + + actingAs($user); + + $page = visit('/accounts'); + $page->navigate('/accounts', ['waitUntil' => 'domcontentloaded'])->wait(2); + + $page->assertSee('Accounts') + ->waitForText('Daily Checking') + ->assertSee('Rainy Day Savings') + ->assertNoJavascriptErrors(); +}); + +it('can navigate to account details page', function () { + $user = User::factory()->onboarded()->create(); + $bank = Bank::factory()->create(['name' => 'Nav Bank', 'logo' => null]); + + Account::factory()->create([ + 'user_id' => $user->id, + 'bank_id' => $bank->id, + 'name' => 'Navigable Account', + 'type' => AccountType::Checking, + 'currency_code' => 'USD', + ]); + + actingAs($user); + + $page = visit('/accounts'); + $page->navigate('/accounts', ['waitUntil' => 'domcontentloaded'])->wait(2); + + $page->waitForText('Navigable Account') + ->click('Details →') + ->wait(2) + ->assertSee('Update balance') + ->assertSee('Nav Bank') + ->assertNoJavascriptErrors(); +}); + +it('does not show other users accounts', function () { + $user = User::factory()->onboarded()->create(); + $otherUser = User::factory()->onboarded()->create(); + $bank = Bank::factory()->create(['name' => 'Shared Bank', 'logo' => null]); + + Account::factory()->create([ + 'user_id' => $user->id, + 'bank_id' => $bank->id, + 'name' => 'My Own Account', + 'type' => AccountType::Checking, + 'currency_code' => 'USD', + ]); + Account::factory()->create([ + 'user_id' => $otherUser->id, + 'bank_id' => $bank->id, + 'name' => 'Someone Elses Account', + 'type' => AccountType::Checking, + 'currency_code' => 'USD', + ]); + + actingAs($user); + + $page = visit('/accounts'); + $page->navigate('/accounts', ['waitUntil' => 'domcontentloaded'])->wait(2); + + $page->waitForText('My Own Account') + ->assertDontSee('Someone Elses Account') + ->assertNoJavascriptErrors(); +}); diff --git a/tests/Feature/AccountControllerTest.php b/tests/Feature/AccountControllerTest.php index 366b7113..73d4b33d 100644 --- a/tests/Feature/AccountControllerTest.php +++ b/tests/Feature/AccountControllerTest.php @@ -194,6 +194,95 @@ test('account balance evolution denies access to other users accounts', function $response->assertForbidden(); }); +test('accounts index defers account metrics', function () { + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Checking, + 'currency_code' => 'USD', + ]); + + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->subMonthNoOverflow()->startOfMonth(), + 'balance' => 100000, + ]); + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->startOfMonth(), + 'balance' => 150000, + ]); + + $response = $this->get(route('accounts.list')); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Accounts/Index') + ->has('accounts', 1) + ->missing('accountMetrics') + ->loadDeferredProps(fn ($reload) => $reload + ->has('accountMetrics') + ->has("accountMetrics.{$account->id}") + ->has("accountMetrics.{$account->id}.currentBalance") + ->has("accountMetrics.{$account->id}.previousBalance") + ->has("accountMetrics.{$account->id}.diff") + ->has("accountMetrics.{$account->id}.history") + ->where("accountMetrics.{$account->id}.currentBalance", 150000) + ->where("accountMetrics.{$account->id}.previousBalance", 100000) + ->where("accountMetrics.{$account->id}.diff", 50000) + ) + ); +}); + +test('accounts index deferred metrics includes invested amount for investment accounts', function () { + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Investment, + 'currency_code' => 'USD', + ]); + + AccountBalance::factory()->withInvestedAmount(80000)->create([ + 'account_id' => $account->id, + 'balance_date' => now()->startOfMonth(), + 'balance' => 120000, + ]); + + $response = $this->get(route('accounts.list')); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Accounts/Index') + ->missing('accountMetrics') + ->loadDeferredProps(fn ($reload) => $reload + ->where("accountMetrics.{$account->id}.investedAmount", 80000) + ->where("accountMetrics.{$account->id}.currentBalance", 120000) + ) + ); +}); + +test('accounts index deferred metrics returns null invested amount for non-investment accounts', function () { + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Checking, + 'currency_code' => 'USD', + ]); + + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->startOfMonth(), + 'balance' => 100000, + ]); + + $response = $this->get(route('accounts.list')); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->missing('accountMetrics') + ->loadDeferredProps(fn ($reload) => $reload + ->where("accountMetrics.{$account->id}.investedAmount", null) + ) + ); +}); + test('account show includes bank information', function () { $bank = Bank::factory()->create([ 'name' => 'Test Bank',