user(); $validated = $request->validated(); $currency = $user->currency_code; $filters = array_filter([ 'date_from' => $validated['date_from'] ?? null, 'date_to' => $validated['date_to'] ?? null, 'amount_min' => $validated['amount_min'] ?? null, 'amount_max' => $validated['amount_max'] ?? null, 'category_ids' => $validated['category_ids'] ?? null, 'account_ids' => $validated['account_ids'] ?? null, 'label_ids' => $validated['label_ids'] ?? null, 'creditor_name' => $validated['creditor_name'] ?? null, 'debtor_name' => $validated['debtor_name'] ?? null, 'search' => $validated['search'] ?? null, ], fn ($value) => $value !== null); $transactions = Transaction::query() ->where('user_id', $user->id) ->with(['account.bank', 'category', 'labels']) ->applyFilters($filters) ->get(); $this->preloadExchangeRates($transactions, $currency); $byCategory = $this->categoryBreakdown($transactions, $currency, $user->id); $byTag = $this->tagBreakdown($transactions, $currency); $byPayee = $this->payeeBreakdown($transactions, $currency); $byAccount = $this->accountBreakdown($transactions, $currency); return response() ->json([ 'currency' => $currency, 'summary' => $this->summaryTotals($transactions, $currency), 'by_category' => $byCategory->values(), 'distinct_category_count' => $byCategory->count(), 'by_tag' => $byTag->values(), 'distinct_label_count' => $byTag->count(), 'by_payee' => $byPayee->values(), 'distinct_payee_count' => $byPayee->count(), 'by_account' => $byAccount->values(), 'distinct_account_count' => $byAccount->count(), 'largest_expenses' => $this->largestExpenses($transactions, $currency), 'over_time' => $this->overTime($transactions, $currency), ]) ->header('Cache-Control', 'no-store, private'); } /** * @return array{income: int, expense: int, net: int, count: int, days: int, average_expense_per_day: int} */ private function summaryTotals(Collection $transactions, string $currency): array { $income = 0; $expense = 0; foreach ($transactions as $transaction) { if ($this->isIncomeSide($transaction)) { $income += $this->convertTransactionAmount($transaction, $currency); } elseif ($this->isExpenseSide($transaction)) { $expense += $this->convertTransactionAmount($transaction, $currency); } } // Refunds net against their side before it is clamped, so a credit in // an expense category lowers spending instead of inflating income — // matching how the cashflow screen reconciles the same transactions. $income = max(0, $income); $expense = max(0, -$expense); $days = $this->spanInDays($transactions); return [ 'income' => $income, 'expense' => $expense, 'net' => $income - $expense, 'count' => $transactions->count(), 'days' => $days, 'average_expense_per_day' => $days > 0 ? intdiv($expense, $days) : $expense, ]; } /** * Expenses grouped by their top-level category, with the sub-categories * that carry spending nested beneath each parent so the split is visible * instead of folded into the parent total. */ private function categoryBreakdown(Collection $transactions, string $currency, string $userId): Collection { $expenses = $transactions->filter( fn (Transaction $transaction): bool => $this->isExpenseSide($transaction), ); $grouped = $expenses ->filter(fn (Transaction $transaction): bool => $transaction->category_id !== null) ->groupBy('category_id') ->map(fn (Collection $group): array => [ 'category_id' => $group->first()->category_id, 'amount' => -$group->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency)), ]) ->filter(fn (array $node): bool => $node['amount'] > 0) ->values() ->all(); $rows = array_map(fn (array $node): array => [ 'category_id' => $node['category_id'], 'name' => $node['category']->name, 'color' => $node['category']->color, 'icon' => $node['category']->icon, 'amount' => $node['amount'], 'children' => array_map(fn (array $child): array => [ 'category_id' => $child['category_id'], 'name' => $child['category']->name, 'color' => $child['category']->color, 'icon' => $child['category']->icon, 'amount' => $child['amount'], ], $node['children']), ], $this->tree->spendingBreakdown($grouped, $userId)); $uncategorized = -$expenses ->filter(fn (Transaction $transaction): bool => $transaction->category_id === null) ->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency)); if ($uncategorized > 0) { $rows[] = [ 'category_id' => null, 'name' => __('Uncategorized'), 'color' => 'gray', 'icon' => 'HelpCircle', 'amount' => $uncategorized, 'children' => [], ]; } return collect($rows) ->filter(fn (array $node): bool => $node['amount'] > 0) ->sortByDesc('amount') ->values(); } /** * Spending grouped by label. A transaction contributes to every label * attached to it, so the totals can exceed overall expenses. */ private function tagBreakdown(Collection $transactions, string $currency): Collection { $totals = []; foreach ($transactions as $transaction) { if (! $this->isExpenseSide($transaction)) { continue; } $amount = -$this->convertTransactionAmount($transaction, $currency); foreach ($transaction->labels as $label) { $totals[$label->id] ??= ['id' => $label->id, 'name' => $label->name, 'color' => $label->color, 'amount' => 0]; $totals[$label->id]['amount'] += $amount; } } return collect($totals) ->filter(fn (array $tag): bool => $tag['amount'] > 0) ->sortByDesc('amount') ->values(); } /** * Expenses grouped by the party paid (the creditor on the transaction). * Transactions without a named creditor are skipped, since an unnamed * bucket carries no meaning for the user. */ private function payeeBreakdown(Collection $transactions, string $currency): Collection { $totals = []; foreach ($transactions as $transaction) { if (! $this->isExpenseSide($transaction)) { continue; } $amount = -$this->convertTransactionAmount($transaction, $currency); $name = trim((string) $transaction->creditor_name); if ($name === '') { continue; } $totals[$name] ??= ['name' => $name, 'amount' => 0]; $totals[$name]['amount'] += $amount; } return collect($totals) ->filter(fn (array $payee): bool => $payee['amount'] > 0) ->sortByDesc('amount') ->values(); } /** * Expenses grouped by the account that funded them, so a set spanning * several cards shows where the spending was charged. */ private function accountBreakdown(Collection $transactions, string $currency): Collection { $totals = []; foreach ($transactions as $transaction) { if (! $this->isExpenseSide($transaction)) { continue; } $amount = -$this->convertTransactionAmount($transaction, $currency); $account = $transaction->account; $totals[$account->id] ??= [ 'id' => $account->id, 'name' => $account->name, 'bank' => $account->bank ? ['name' => $account->bank->name, 'logo' => $account->bank->logo] : null, 'amount' => 0, ]; $totals[$account->id]['amount'] += $amount; } return collect($totals) ->filter(fn (array $account): bool => $account['amount'] > 0) ->sortByDesc('amount') ->values(); } /** * The biggest individual expenses, richest-first, each carrying the same * display fields the transaction table shows so the drawer can render a * familiar row. Capped at the limit the drawer can reveal. * * @return array}> */ private function largestExpenses(Collection $transactions, string $currency): array { return $transactions ->filter(fn (Transaction $transaction): bool => $this->isExpenseSide($transaction) && $transaction->amount < 0) ->sortBy(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency)) ->take(self::LARGEST_EXPENSES_LIMIT) ->map(fn (Transaction $transaction): array => [ 'id' => $transaction->id, 'date' => $transaction->transaction_date->toDateString(), 'description' => $transaction->description, 'amount' => abs($this->convertTransactionAmount($transaction, $currency)), 'category' => $transaction->category ? [ 'name' => $transaction->category->name, 'color' => $transaction->category->color, 'icon' => $transaction->category->icon, ] : null, 'account' => [ 'name' => $transaction->account->name, 'bank' => $transaction->account->bank ? [ 'name' => $transaction->account->bank->name, 'logo' => $transaction->account->bank->logo, ] : null, ], 'labels' => $transaction->labels ->map(fn (Label $label): array => ['id' => $label->id, 'name' => $label->name, 'color' => $label->color]) ->values() ->all(), ]) ->values() ->all(); } /** * Income and expense bucketed over the filtered span, plus a running * expense total so the pace of spending is visible. * * @return array{bucket: string, points: array} */ private function overTime(Collection $transactions, string $currency): array { if ($transactions->isEmpty()) { return ['bucket' => 'day', 'points' => []]; } $dates = $transactions->map(fn (Transaction $transaction): Carbon => $transaction->transaction_date->copy()); $start = $dates->min(); $end = $dates->max(); $daily = $start->diffInDays($end) <= self::DAILY_BUCKET_MAX_DAYS; $keyFormat = $daily ? 'Y-m-d' : 'Y-m'; $buckets = []; foreach ($transactions as $transaction) { $key = $transaction->transaction_date->format($keyFormat); $buckets[$key] ??= ['income' => 0, 'expense' => 0]; if ($this->isIncomeSide($transaction)) { $buckets[$key]['income'] += $this->convertTransactionAmount($transaction, $currency); } elseif ($this->isExpenseSide($transaction)) { $buckets[$key]['expense'] -= $this->convertTransactionAmount($transaction, $currency); } } $points = []; $cumulativeExpense = 0; $cumulativeNet = 0; $cursor = $daily ? $start->copy()->startOfDay() : $start->copy()->startOfMonth(); $last = $daily ? $end->copy()->startOfDay() : $end->copy()->startOfMonth(); while ($cursor->lte($last)) { $key = $cursor->format($keyFormat); $income = $buckets[$key]['income'] ?? 0; $expense = $buckets[$key]['expense'] ?? 0; $cumulativeExpense += $expense; $cumulativeNet += $income - $expense; $points[] = [ 'date' => $key, 'label' => $daily ? $cursor->format('M j') : $cursor->format('M Y'), 'income' => $income, 'expense' => $expense, 'cumulative_expense' => $cumulativeExpense, 'cumulative_net' => $cumulativeNet, ]; $daily ? $cursor->addDay() : $cursor->addMonth(); } return ['bucket' => $daily ? 'day' : 'month', 'points' => $points]; } private function spanInDays(Collection $transactions): int { if ($transactions->isEmpty()) { return 0; } $dates = $transactions->map(fn (Transaction $transaction): Carbon => $transaction->transaction_date); return (int) $dates->min()->diffInDays($dates->max()) + 1; } /** * Whether a transaction belongs to the expense side: anything booked to an * expense category (a refund there nets back out), plus uncategorized * outflows. Transfers, savings and investments are internal movements, so * they sit on neither side — matching the cashflow screen. */ private function isExpenseSide(Transaction $transaction): bool { return $transaction->categoryType() === CategoryType::Expense || ($transaction->category_id === null && $transaction->amount < 0); } /** * Whether a transaction belongs to the income side: anything booked to an * income category (a reversal there nets back out), plus uncategorized * inflows. Internal movements are excluded for the same reason as expenses. */ private function isIncomeSide(Transaction $transaction): bool { return $transaction->categoryType() === CategoryType::Income || ($transaction->category_id === null && $transaction->amount > 0); } }