diff --git a/app/Http/Controllers/Api/CashflowAnalyticsController.php b/app/Http/Controllers/Api/CashflowAnalyticsController.php index 3a155b51..367c160a 100644 --- a/app/Http/Controllers/Api/CashflowAnalyticsController.php +++ b/app/Http/Controllers/Api/CashflowAnalyticsController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api; use App\Enums\CategoryCashflowDirection; use App\Enums\CategoryType; +use App\Http\Controllers\Api\Concerns\ConvertsTransactionCurrency; use App\Http\Controllers\Controller; use App\Models\Category; use App\Models\Transaction; @@ -17,6 +18,8 @@ use Illuminate\Support\Collection; class CashflowAnalyticsController extends Controller { + use ConvertsTransactionCurrency; + private const MAX_TREND_MONTHS = 24; public function __construct( @@ -222,7 +225,7 @@ class CashflowAnalyticsController extends Controller { return $transactions ->filter(function (Transaction $transaction) use ($type): bool { - if ($this->categoryType($transaction) === $type) { + if ($transaction->categoryType() === $type) { return true; } @@ -235,7 +238,7 @@ class CashflowAnalyticsController extends Controller private function sumOutflowTransactions(Collection $transactions, string $userCurrency, CategoryType $type): int { return abs($transactions - ->filter(fn (Transaction $transaction): bool => $this->categoryType($transaction) === $type + ->filter(fn (Transaction $transaction): bool => $transaction->categoryType() === $type && $transaction->amount < 0) ->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency))); } @@ -254,7 +257,7 @@ class CashflowAnalyticsController extends Controller $regularCategories = $transactions ->filter(function (Transaction $transaction) use ($type): bool { - $categoryType = $this->categoryType($transaction); + $categoryType = $transaction->categoryType(); return $transaction->category_id !== null && ($categoryType === $type @@ -282,7 +285,7 @@ class CashflowAnalyticsController extends Controller $transferCategories = $transactions ->filter(function (Transaction $transaction) use ($isIncome): bool { return $transaction->category_id !== null - && $this->categoryType($transaction) === CategoryType::Transfer + && $transaction->categoryType() === CategoryType::Transfer && $this->categoryCashflowDirection($transaction) === ($isIncome ? CategoryCashflowDirection::Inflow : CategoryCashflowDirection::Outflow); @@ -359,7 +362,7 @@ class CashflowAnalyticsController extends Controller foreach ($categorized as $categoryTransactions) { $firstTransaction = $categoryTransactions->first(); - $type = $this->categoryType($firstTransaction); + $type = $firstTransaction->categoryType(); if (! in_array($type, [CategoryType::Income, CategoryType::Expense], true)) { continue; @@ -406,7 +409,7 @@ class CashflowAnalyticsController extends Controller $this->preloadExchangeRates($transactions, $userCurrency); $categorized = $transactions - ->filter(fn (Transaction $transaction): bool => $this->categoryType($transaction) === $type) + ->filter(fn (Transaction $transaction): bool => $transaction->categoryType() === $type) ->groupBy('category_id') ->map(function (Collection $transactions) use ($userCurrency): array { $totalAmount = $transactions->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)); @@ -461,42 +464,6 @@ class CashflowAnalyticsController extends Controller }); } - 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(), - ); - } - - 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); - } - - private function categoryType(Transaction $transaction): ?CategoryType - { - $type = $transaction->category?->getAttribute('type'); - - if ($type instanceof CategoryType) { - return $type; - } - - return is_string($type) ? CategoryType::tryFrom($type) : null; - } - private function categoryCashflowDirection(Transaction $transaction): ?CategoryCashflowDirection { $direction = $transaction->category?->getAttribute('cashflow_direction'); diff --git a/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php b/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php index 3b68bf7d..7e005a83 100644 --- a/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php +++ b/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Enums\CategoryType; +use App\Http\Controllers\Api\Concerns\ConvertsTransactionCurrency; use App\Http\Controllers\Controller; use App\Models\Category; use App\Models\Transaction; @@ -14,6 +15,8 @@ use Illuminate\Support\Collection; class CategoryMonthlyBreakdownController extends Controller { + use ConvertsTransactionCurrency; + /** * The rolling window shown on the chart, in months (including the current). */ @@ -276,32 +279,4 @@ class CategoryMonthlyBreakdownController extends Controller return $months; } - - private function convertTransactionAmount(Transaction $transaction, string $currency): int - { - return $this->exchangeRateService->convert( - $transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, - $currency, - $transaction->amount, - $transaction->transaction_date->toDateString(), - ); - } - - /** - * @param Collection $transactions - */ - private function preloadExchangeRates(Collection $transactions, string $currency): void - { - $dates = $transactions - ->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, $currency) !== 0) - ->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString()) - ->unique() - ->values(); - - if ($dates->isEmpty()) { - return; - } - - $this->exchangeRateService->preloadRates($currency, $dates); - } } diff --git a/app/Http/Controllers/Api/Concerns/ConvertsTransactionCurrency.php b/app/Http/Controllers/Api/Concerns/ConvertsTransactionCurrency.php new file mode 100644 index 00000000..ffb434f2 --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/ConvertsTransactionCurrency.php @@ -0,0 +1,43 @@ +exchangeRateService->convert( + $transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, + $currency, + $transaction->amount, + $transaction->transaction_date->toDateString(), + ); + } + + /** + * @param Collection $transactions + */ + protected function preloadExchangeRates(Collection $transactions, string $currency): void + { + $dates = $transactions + ->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, $currency) !== 0) + ->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString()) + ->unique() + ->values(); + + if ($dates->isEmpty()) { + return; + } + + $this->exchangeRateService->preloadRates($currency, $dates); + } +} diff --git a/app/Http/Controllers/Api/TransactionAnalysisController.php b/app/Http/Controllers/Api/TransactionAnalysisController.php index 61c20702..a0d54de1 100644 --- a/app/Http/Controllers/Api/TransactionAnalysisController.php +++ b/app/Http/Controllers/Api/TransactionAnalysisController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Enums\CategoryType; +use App\Http\Controllers\Api\Concerns\ConvertsTransactionCurrency; use App\Http\Controllers\Controller; use App\Http\Requests\IndexTransactionRequest; use App\Models\Label; @@ -15,6 +16,8 @@ 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. @@ -374,7 +377,7 @@ class TransactionAnalysisController extends Controller private function isExpense(Transaction $transaction): bool { return $transaction->amount < 0 - && in_array($this->categoryType($transaction), [CategoryType::Expense, null], true); + && in_array($transaction->categoryType(), [CategoryType::Expense, null], true); } /** @@ -385,42 +388,6 @@ class TransactionAnalysisController extends Controller private function isIncome(Transaction $transaction): bool { return $transaction->amount > 0 - && in_array($this->categoryType($transaction), [CategoryType::Income, null], true); - } - - private function categoryType(Transaction $transaction): ?CategoryType - { - $type = $transaction->category?->getAttribute('type'); - - if ($type instanceof CategoryType) { - return $type; - } - - return is_string($type) ? CategoryType::tryFrom($type) : null; - } - - private function convertTransactionAmount(Transaction $transaction, string $currency): int - { - return $this->exchangeRateService->convert( - $transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, - $currency, - $transaction->amount, - $transaction->transaction_date->toDateString(), - ); - } - - private function preloadExchangeRates(Collection $transactions, string $currency): void - { - $dates = $transactions - ->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, $currency) !== 0) - ->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString()) - ->unique() - ->values(); - - if ($dates->isEmpty()) { - return; - } - - $this->exchangeRateService->preloadRates($currency, $dates); + && in_array($transaction->categoryType(), [CategoryType::Income, null], true); } } diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 12e3ebe6..2fea5d2a 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\CategorySource; +use App\Enums\CategoryType; use App\Enums\RuleOrigin; use App\Enums\TransactionSource; use App\Events\TransactionCreated; @@ -116,6 +117,22 @@ class Transaction extends Model return $this->belongsTo(Category::class); } + /** + * The type of the assigned category, resilient to phantom categories that + * are force-filled with a raw string type (e.g. the synthetic + * "uncategorized" rows the analytics controllers build). + */ + public function categoryType(): ?CategoryType + { + $type = $this->category?->getAttribute('type'); + + if ($type instanceof CategoryType) { + return $type; + } + + return is_string($type) ? CategoryType::tryFrom($type) : null; + } + /** @return BelongsTo */ public function categorizedByRule(): BelongsTo {