validate([ 'from' => 'required|date', 'to' => 'required|date', ]); $period = PeriodComparator::fromRequest($validated); $previousPeriod = $period->previous(); $userCurrency = $request->user()->currency_code; $accounts = Account::query() ->where('user_id', $request->user()->id) ->get(); // Load every account's balance history for the compared range in a // fixed number of queries instead of one balance lookup per account. $lookup = BalanceLookup::forAccounts( $accounts->pluck('id'), $previousPeriod->to, $period->to, ); return response()->json([ 'current' => $this->calculateNetWorthAt($accounts, $lookup, $period->to, $userCurrency), 'previous' => $this->calculateNetWorthAt($accounts, $lookup, $previousPeriod->to, $userCurrency), 'currency_code' => $userCurrency, ]); } 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']); $userCurrency = $request->user()->currency_code; $accounts = Account::query() ->where('user_id', $request->user()->id) ->with(['bank:id,name,logo']) ->get(); return response()->json( $this->accountMetricsService->getNetWorthEvolution($userCurrency, $accounts, $start, $end) ); } public function accountBalanceEvolution(Request $request, Account $account) { [$start, $end] = $this->authorizedDateRange($request, $account); $linkedLoanAccount = $this->getLinkedLoanAccount($account); $displayCurrencyCode = $this->displayCurrencyFor($request, $account); $lookup = $this->balanceLookupFor($account, $linkedLoanAccount, $start->copy()->startOfMonth(), $end); $points = []; $current = $start->copy()->startOfMonth(); $endMonth = $end->copy()->startOfMonth(); while ($current->lte($endMonth)) { $date = $current->copy()->endOfMonth(); $points[] = [ 'month' => $date->format('Y-m'), ...$this->balancePointAt($account, $linkedLoanAccount, $lookup, $date, $displayCurrencyCode), ]; $current->addMonth(); } return $this->evolutionResponse( [...$points, ...$this->projectedPoints($account, $linkedLoanAccount, $points, $displayCurrencyCode)], $account, $displayCurrencyCode, ); } public function accountDailyBalanceEvolution(Request $request, Account $account) { [$start, $end] = $this->authorizedDateRange($request, $account); $linkedLoanAccount = $this->getLinkedLoanAccount($account); $displayCurrencyCode = $this->displayCurrencyFor($request, $account); $lookup = $this->balanceLookupFor($account, $linkedLoanAccount, $start, $end); $points = []; $current = $start->copy(); while ($current->lte($end)) { $date = $current->copy()->endOfDay(); $points[] = [ 'date' => $date->format('Y-m-d'), ...$this->balancePointAt($account, $linkedLoanAccount, $lookup, $date, $displayCurrencyCode), ]; $current->addDay(); } return $this->evolutionResponse($points, $account, $displayCurrencyCode); } public function netWorthDailyEvolution(Request $request) { $validated = $request->validate([ 'from' => 'required|date', 'to' => 'required|date', ]); $start = Carbon::parse($validated['from']); $end = Carbon::parse($validated['to']); $userCurrency = $request->user()->currency_code; $accounts = Account::query() ->where('user_id', $request->user()->id) ->with(['bank:id,name,logo']) ->get(); return response()->json( $this->accountMetricsService->getNetWorthDailyEvolution($userCurrency, $accounts, $start, $end) ); } public function topCategories(Request $request) { $validated = $request->validate([ 'from' => 'required|date', 'to' => 'required|date', 'parent' => 'nullable|uuid', ]); $period = PeriodComparator::fromRequest($validated); $previousPeriod = $period->previous(); $drillParentId = $validated['parent'] ?? null; $currentSpending = $this->categorySpendingService->forPeriod($request->user()->id, $period->from, $period->to, $drillParentId); $previousSpending = $this->categorySpendingService->forPeriod($request->user()->id, $previousPeriod->from, $previousPeriod->to, $drillParentId); $totalAmount = $currentSpending->sum('amount'); $top = $currentSpending ->sortByDesc('amount') ->take(10) ->map(function ($item) use ($previousSpending, $totalAmount) { $previousAmount = $previousSpending->firstWhere('category_id', $item['category_id'])['amount'] ?? 0; return [ 'category' => $item['category'], 'category_id' => $item['category_id'], 'amount' => $item['amount'], 'previous_amount' => $previousAmount, 'total_amount' => $totalAmount, 'has_children' => $item['has_children'], 'is_direct' => $item['is_direct'], ]; }) ->values(); return response()->json($top); } /** * @param Collection $accounts */ private function calculateNetWorthAt(Collection $accounts, BalanceLookup $lookup, Carbon $date, string $userCurrency): int { $total = 0; foreach ($accounts as $account) { if (! $account->type->countsInNetWorth()) { continue; } $balance = $lookup->getBalanceAt($account->id, $date); $convertedBalance = $this->exchangeRateService->convert( $account->currency_code, $userCurrency, $balance, $date->toDateString(), ); $total += $account->type->reducesNetWorth() ? -abs($convertedBalance) : $convertedBalance; } return $total; } private function calculateSpending(Carbon $from, Carbon $to): int { return max(0, -$this->sumByCategoryType(CategoryType::Expense, $from, $to)); } private function calculateCashFlow(Carbon $from, Carbon $to): array { return [ 'income' => max(0, $this->sumByCategoryType(CategoryType::Income, $from, $to)), 'expense' => max(0, -$this->sumByCategoryType(CategoryType::Expense, $from, $to)), ]; } /** * Signed sum of the user's transactions in the range whose category is of the * given type: income comes out positive, expenses negative. */ private function sumByCategoryType(CategoryType $type, Carbon $from, Carbon $to): int { return (int) Transaction::query() ->where('transactions.user_id', request()->user()->id) ->whereBetween('transactions.transaction_date', [$from, $to]) ->joinOwningAccount() ->join('categories', function ($join) use ($type) { $join->on('transactions.category_id', '=', 'categories.id') ->where('categories.type', '=', $type) ->whereNull('categories.deleted_at'); }) ->sum(Transaction::ownedAmount()); } /** * Make sure the account belongs to the caller and return the requested range. * * @return array{0: Carbon, 1: Carbon} */ private function authorizedDateRange(Request $request, Account $account): array { abort_if($account->user_id !== $request->user()->id, 403); $validated = $request->validate([ 'from' => 'required|date', 'to' => 'required|date', ]); return [Carbon::parse($validated['from']), Carbon::parse($validated['to'])]; } /** * The user's currency when it differs from the account's, null when no * conversion is needed. */ private function displayCurrencyFor(Request $request, Account $account): ?string { $userCurrency = $request->user()->currency_code; return strcasecmp($account->currency_code, $userCurrency) !== 0 ? $userCurrency : null; } private function balanceLookupFor(Account $account, ?Account $linkedLoanAccount, Carbon $from, Carbon $to): BalanceLookup { return BalanceLookup::forAccounts( array_values(array_filter([$account->id, $linkedLoanAccount?->id])), $from, $to, ); } /** * Balance, invested amount and linked-mortgage balance at a single date, with * their display-currency equivalents. The monthly and daily series share this: * they differ only in the dates they ask for. * * @return array */ private function balancePointAt(Account $account, ?Account $linkedLoanAccount, BalanceLookup $lookup, Carbon $date, ?string $displayCurrencyCode): array { $value = $lookup->getBalanceAt($account->id, $date); $point = [ 'timestamp' => $date->timestamp, 'value' => $value, ]; if ($account->type->supportsInvestedAmount()) { $investedAmount = $lookup->getInvestedAmountAt($account->id, $date); $point['invested_amount'] = $investedAmount; if ($displayCurrencyCode !== null && $investedAmount !== null) { $point['display_invested_amount'] = $this->convertBalanceForDate($displayCurrencyCode, $account->currency_code, $investedAmount, $date); } } if ($linkedLoanAccount !== null) { $mortgageBalance = $lookup->getBalanceAt($linkedLoanAccount->id, $date); $point['mortgage_balance'] = $this->convertBalanceForDate( $linkedLoanAccount->currency_code, $account->currency_code, $mortgageBalance, $date, ); if ($displayCurrencyCode !== null) { $point['display_mortgage_balance'] = $this->convertBalanceForDate( $linkedLoanAccount->currency_code, $displayCurrencyCode, $mortgageBalance, $date, ); } } if ($displayCurrencyCode !== null) { $point['display_value'] = $this->convertBalanceForDate( $account->currency_code, $displayCurrencyCode, $value, $date, ); } return $point; } /** * Future months appended to the historical series: the amortization schedule * for loans, and revaluation plus the linked mortgage for real estate. * * @param list> $points the historical series * @return list> */ private function projectedPoints(Account $account, ?Account $linkedLoanAccount, array $points, ?string $displayCurrencyCode): array { return match ($account->type) { AccountType::Loan => $this->loanProjection($account, $displayCurrencyCode), AccountType::RealEstate => $this->realEstateProjection($account, $linkedLoanAccount, $points, $displayCurrencyCode), default => [], }; } /** * @return list> */ private function loanProjection(Account $account, ?string $displayCurrencyCode): array { $loanDetail = $account->loanDetail; if ($loanDetail === null) { return []; } $now = Carbon::now(); $points = []; foreach ($this->loanAmortizationService->generateProjection($loanDetail, 12) as $yearMonth => $balanceCents) { $projectedDate = Carbon::createFromFormat('Y-m', $yearMonth)->endOfMonth(); // Only add future months that are beyond the current date if ($projectedDate->lte($now)) { continue; } $points[] = $this->projectedPoint($account, $projectedDate, $balanceCents, $displayCurrencyCode); } return $points; } /** * Real estate projects market value forward from the last historical point at * the revaluation rate, alongside the linked mortgage, so the chart shows both * moving together. * * @param list> $points the historical series * @return list> */ private function realEstateProjection(Account $account, ?Account $linkedLoanAccount, array $points, ?string $displayCurrencyCode): array { // The ?? already covers a missing detail row, so no nullsafe operator here // (phpstan flags the combination as redundant). $revaluationPercentage = (float) ($account->realEstateDetail->revaluation_percentage ?? 0); $linkedLoanDetail = $linkedLoanAccount?->loadMissing('loanDetail')->loanDetail; if ($revaluationPercentage === 0.0 && $linkedLoanDetail === null) { return []; } $monthsAhead = 12; $lastPoint = end($points); $baseValue = is_array($lastPoint) ? $lastPoint['value'] : 0; $monthlyRate = $revaluationPercentage / 12 / 100; $loanProjection = $linkedLoanDetail !== null ? $this->loanAmortizationService->generateProjection($linkedLoanDetail, $monthsAhead) : []; $now = Carbon::now(); $projected = []; for ($i = 1; $i <= $monthsAhead; $i++) { $projectedDate = $now->copy()->addMonthsNoOverflow($i)->endOfMonth(); $projectedValue = (int) round($baseValue * pow(1 + $monthlyRate, $i)); $point = $this->projectedPoint($account, $projectedDate, $projectedValue, $displayCurrencyCode); $mortgageProj = $loanProjection[$projectedDate->format('Y-m')] ?? null; if ($mortgageProj !== null) { $point['mortgage_balance'] = $this->convertBalanceForDate( $linkedLoanAccount->currency_code, $account->currency_code, $mortgageProj, $projectedDate, ); if ($displayCurrencyCode !== null) { $point['display_mortgage_balance'] = $this->convertBalanceForDate( $linkedLoanAccount->currency_code, $displayCurrencyCode, $mortgageProj, $projectedDate, ); } } $projected[] = $point; } return $projected; } /** * A single projected month. Its display value is converted at today's rate: * there is no exchange rate for a date that hasn't happened yet. * * @return array */ private function projectedPoint(Account $account, Carbon $projectedDate, int $value, ?string $displayCurrencyCode): array { $point = [ 'month' => $projectedDate->format('Y-m'), 'timestamp' => $projectedDate->timestamp, 'value' => $value, 'projected' => true, ]; if ($displayCurrencyCode !== null) { $point['display_value'] = $this->convertBalanceForDate( $account->currency_code, $displayCurrencyCode, $value, Carbon::today(), ); } return $point; } /** * @param list> $points */ private function evolutionResponse(array $points, Account $account, ?string $displayCurrencyCode): JsonResponse { $response = [ 'data' => $points, 'account' => [ 'id' => $account->id, 'name' => $account->name, 'name_iv' => $account->name_iv, 'encrypted' => $account->encrypted, 'type' => $account->type, 'currency_code' => $account->currency_code, ], ]; if ($displayCurrencyCode !== null) { $response['display_currency_code'] = $displayCurrencyCode; } return response()->json($response); } /** * Get the linked loan account for a real estate account, if any. */ private function getLinkedLoanAccount(Account $account): ?Account { if ($account->type !== AccountType::RealEstate) { return null; } return $account->realEstateDetail?->linkedLoanAccount; } private function convertBalanceForDate(string $sourceCurrency, string $targetCurrency, int $amount, Carbon $date): int { if (strcasecmp($sourceCurrency, $targetCurrency) === 0) { return $amount; } return $this->exchangeRateService->convert( $sourceCurrency, $targetCurrency, $amount, $date->toDateString(), ); } }