whisper-money/app/Http/Controllers/Api/TransactionAnalysisControll...

380 lines
15 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ConvertsTransactionCurrency;
use App\Http\Controllers\Controller;
use App\Http\Requests\IndexTransactionRequest;
use App\Models\Label;
use App\Models\Transaction;
use App\Services\CategoryTree;
use App\Services\ExchangeRateService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
class TransactionAnalysisController extends Controller
{
use ConvertsTransactionCurrency;
/**
* A daily breakdown is used while the filtered set spans this many days or
* fewer; beyond that the chart switches to monthly buckets.
*/
private const DAILY_BUCKET_MAX_DAYS = 62;
/**
* The drawer lists the five biggest expenses with an option to reveal the
* rest, so ten covers both states without shipping the whole set.
*/
private const LARGEST_EXPENSES_LIMIT = 10;
public function __construct(
private ExchangeRateService $exchangeRateService,
private CategoryTree $tree,
) {}
public function summary(IndexTransactionRequest $request): JsonResponse
{
$user = $request->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 ($transaction->isIncomeSide()) {
$income += $this->convertTransactionAmount($transaction, $currency);
} elseif ($transaction->isExpenseSide()) {
$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 => $transaction->isExpenseSide(),
);
$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 (! $transaction->isExpenseSide()) {
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 (! $transaction->isExpenseSide()) {
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 (! $transaction->isExpenseSide()) {
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<int, array{id: string, date: string, description: ?string, amount: int, category: ?array{name: string, color: ?string, icon: ?string}, account: array{name: string, bank: ?array{name: string, logo: ?string}}, labels: array<int, array{id: string, name: string, color: ?string}>}>
*/
private function largestExpenses(Collection $transactions, string $currency): array
{
return $transactions
->filter(fn (Transaction $transaction): bool => $transaction->isExpenseSide() && $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<int, array{date: string, label: string, income: int, expense: int, cumulative_expense: int, cumulative_net: int}>}
*/
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 ($transaction->isIncomeSide()) {
$buckets[$key]['income'] += $this->convertTransactionAmount($transaction, $currency);
} elseif ($transaction->isExpenseSide()) {
$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;
}
}