whisper-money/app/Http/Controllers/Api/AnalysisController.php

151 lines
5.4 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\AnalysisRequest;
use App\Models\Transaction;
use App\Services\ExchangeRateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
class AnalysisController extends Controller
{
public function __construct(private ExchangeRateService $exchangeRateService) {}
public function index(AnalysisRequest $request): JsonResponse
{
$user = $request->user();
$userCurrency = $user->currency_code;
$groupBy = $request->validated()['group_by'];
$transactions = Transaction::query()
->where('user_id', $user->id)
->with(['account:id,currency_code', 'category:id,name,icon,color,type', 'labels:id,name,color'])
->applyFilters($request->filters())
->get();
$this->preloadExchangeRates($transactions, $userCurrency);
$converted = $transactions->map(fn (Transaction $transaction): array => [
'transaction' => $transaction,
'amount' => $this->convertTransactionAmount($transaction, $userCurrency),
]);
return response()
->json([
'summary' => $this->summary($converted),
'groups' => $this->groups($converted, $groupBy)->values(),
])
->header('Cache-Control', 'no-store, private');
}
/**
* @param Collection<int, array{transaction: Transaction, amount: int}> $converted
* @return array{income: int, expense: int, net: int, count: int}
*/
private function summary(Collection $converted): array
{
$income = $converted->filter(fn (array $row): bool => $row['amount'] > 0)->sum('amount');
$expense = abs($converted->filter(fn (array $row): bool => $row['amount'] < 0)->sum('amount'));
return [
'income' => $income,
'expense' => $expense,
'net' => $income - $expense,
'count' => $converted->count(),
];
}
/**
* @param Collection<int, array{transaction: Transaction, amount: int}> $converted
* @return Collection<int, array{key: ?string, amount: int, count: int}>
*/
private function groups(Collection $converted, string $groupBy): Collection
{
if ($groupBy === 'label') {
return $this->labelGroups($converted);
}
return $converted
->groupBy(fn (array $row): string => $this->groupKey($row['transaction'], $groupBy) ?? '__null__')
->map(fn (Collection $rows, string $key): array => [
'key' => $key === '__null__' ? null : $key,
'amount' => $rows->sum('amount'),
'count' => $rows->count(),
])
->sortByDesc(fn (array $group): int => abs($group['amount']));
}
/**
* A transaction with several labels contributes to each label's bucket.
* Transactions without labels fall into a single null bucket.
*
* @param Collection<int, array{transaction: Transaction, amount: int}> $converted
* @return Collection<int, array{key: ?string, amount: int, count: int}>
*/
private function labelGroups(Collection $converted): Collection
{
$buckets = [];
foreach ($converted as $row) {
$labels = $row['transaction']->labels;
if ($labels->isEmpty()) {
$buckets['__null__'] ??= ['key' => null, 'amount' => 0, 'count' => 0];
$buckets['__null__']['amount'] += $row['amount'];
$buckets['__null__']['count']++;
continue;
}
foreach ($labels as $label) {
$buckets[$label->id] ??= ['key' => $label->id, 'amount' => 0, 'count' => 0];
$buckets[$label->id]['amount'] += $row['amount'];
$buckets[$label->id]['count']++;
}
}
return collect(array_values($buckets))
->sortByDesc(fn (array $group): int => abs($group['amount']));
}
private function groupKey(Transaction $transaction, string $groupBy): ?string
{
return match ($groupBy) {
'category' => $transaction->category_id,
'account' => $transaction->account_id,
'month' => $transaction->transaction_date->format('Y-m'),
default => null,
};
}
private function convertTransactionAmount(Transaction $transaction, string $userCurrency): int
{
return $this->exchangeRateService->convert(
$transaction->currency_code ?: $transaction->account?->currency_code ?: $userCurrency,
$userCurrency,
$transaction->amount,
$transaction->transaction_date->toDateString(),
);
}
/**
* @param Collection<int, Transaction> $transactions
*/
private function preloadExchangeRates(Collection $transactions, string $userCurrency): void
{
$dates = $transactions
->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $userCurrency, $userCurrency) !== 0)
->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString())
->unique()
->values();
if ($dates->isEmpty()) {
return;
}
$this->exchangeRateService->preloadRates($userCurrency, $dates);
}
}