fix(analysis): respect category types like the cashflow screen (#612)
## Why On the **analysis** drawer, income/expense were derived from each transaction's amount sign alone, ignoring the category type. Transactions in `transfer` / `savings` / `investment` categories therefore leaked into income/expense totals and every breakdown — e.g. moving money between your own accounts inflated both sides. The **cashflow** screen already keys off the category type; analysis now does the same. ## What changed Three commits: 1. **fix(analysis): exclude transfer, savings and investment categories from income and expense** Only `expense` categories (or uncategorized outflows) count as expenses, and only `income` categories (or uncategorized inflows) count as income. `transfer` / `savings` / `investment` are internal movements and sit on neither side — identical to how cashflow computes income/expense/net. 2. **refactor(analytics): de-duplicate currency conversion and category-type helpers** `convertTransactionAmount()` / `preloadExchangeRates()` were byte-identical in three controllers, and `categoryType()` in two. Extracted the currency helpers into a shared `Api\Concerns\ConvertsTransactionCurrency` trait (following the existing `OpenBanking/Concerns` pattern) and moved `categoryType()` onto the `Transaction` model. No behavior change. 3. **fix(analysis): net refunds within a category so totals reconcile with cashflow** Classification keyed off each transaction's own sign dropped contra-sign rows entirely, so a refund booked to an expense category disappeared instead of netting the spend down — and analysis disagreed with cashflow on the same data (a `-10000` charge + `3000` refund read as `10000` spent, not `7000`). A transaction's side is now decided by its category type and signed amounts are summed before clamping, mirroring cashflow's reconciliation across summary, category, payee, account, tag and over-time. The largest-expenses list still shows only genuine outflows. ## Tests Added analysis coverage for: transfer/savings/investment exclusion (summary, breakdowns, largest, over-time), refund netting (asserts parity with cashflow's `7000`), income-category reversals, foreign-currency conversion, and uncategorized inflows. Full non-browser suite green (1823 passed); `pint --test`, `bun run format`, `bun run lint` all clean. ## Follow-up for product (not in this PR) On **cashflow**, savings/investment outflows are excluded from expense but re-surfaced in a dedicated "Saved & Invested" card. The **analysis** drawer has no equivalent surface, so money categorized as savings/investment is now correctly excluded from spending but isn't shown anywhere. If we want analysis to fully account for it, we should add a small "set aside" summary there. Flagged for a product decision rather than bundled into this bugfix.
This commit is contained in:
parent
6727a9c64a
commit
986f43705a
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<int, Transaction> $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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use App\Services\ExchangeRateService;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Shared currency conversion for the analytics controllers. Each consumer
|
||||
* injects an {@see ExchangeRateService} as `$exchangeRateService`, then reads
|
||||
* transaction amounts in the user's currency through these helpers.
|
||||
*/
|
||||
trait ConvertsTransactionCurrency
|
||||
{
|
||||
protected 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<int, Transaction> $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);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
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;
|
||||
|
|
@ -14,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.
|
||||
|
|
@ -91,15 +95,19 @@ class TransactionAnalysisController extends Controller
|
|||
$expense = 0;
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$amount = $this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
if ($amount > 0) {
|
||||
$income += $amount;
|
||||
} else {
|
||||
$expense += abs($amount);
|
||||
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 [
|
||||
|
|
@ -120,7 +128,7 @@ class TransactionAnalysisController extends Controller
|
|||
private function categoryBreakdown(Collection $transactions, string $currency, string $userId): Collection
|
||||
{
|
||||
$expenses = $transactions->filter(
|
||||
fn (Transaction $transaction): bool => $this->convertTransactionAmount($transaction, $currency) < 0,
|
||||
fn (Transaction $transaction): bool => $this->isExpenseSide($transaction),
|
||||
);
|
||||
|
||||
$grouped = $expenses
|
||||
|
|
@ -128,8 +136,9 @@ class TransactionAnalysisController extends Controller
|
|||
->groupBy('category_id')
|
||||
->map(fn (Collection $group): array => [
|
||||
'category_id' => $group->first()->category_id,
|
||||
'amount' => abs($group->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency))),
|
||||
'amount' => -$group->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency)),
|
||||
])
|
||||
->filter(fn (array $node): bool => $node['amount'] > 0)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
|
|
@ -148,9 +157,9 @@ class TransactionAnalysisController extends Controller
|
|||
], $node['children']),
|
||||
], $this->tree->spendingBreakdown($grouped, $userId));
|
||||
|
||||
$uncategorized = abs($expenses
|
||||
$uncategorized = -$expenses
|
||||
->filter(fn (Transaction $transaction): bool => $transaction->category_id === null)
|
||||
->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency)));
|
||||
->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency));
|
||||
|
||||
if ($uncategorized > 0) {
|
||||
$rows[] = [
|
||||
|
|
@ -178,15 +187,15 @@ class TransactionAnalysisController extends Controller
|
|||
$totals = [];
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$amount = $this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
if ($amount >= 0) {
|
||||
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'] += abs($amount);
|
||||
$totals[$label->id]['amount'] += $amount;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -206,12 +215,12 @@ class TransactionAnalysisController extends Controller
|
|||
$totals = [];
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$amount = $this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
if ($amount >= 0) {
|
||||
if (! $this->isExpenseSide($transaction)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = -$this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
$name = trim((string) $transaction->creditor_name);
|
||||
|
||||
if ($name === '') {
|
||||
|
|
@ -219,10 +228,11 @@ class TransactionAnalysisController extends Controller
|
|||
}
|
||||
|
||||
$totals[$name] ??= ['name' => $name, 'amount' => 0];
|
||||
$totals[$name]['amount'] += abs($amount);
|
||||
$totals[$name]['amount'] += $amount;
|
||||
}
|
||||
|
||||
return collect($totals)
|
||||
->filter(fn (array $payee): bool => $payee['amount'] > 0)
|
||||
->sortByDesc('amount')
|
||||
->values();
|
||||
}
|
||||
|
|
@ -236,12 +246,12 @@ class TransactionAnalysisController extends Controller
|
|||
$totals = [];
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$amount = $this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
if ($amount >= 0) {
|
||||
if (! $this->isExpenseSide($transaction)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = -$this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
$account = $transaction->account;
|
||||
|
||||
$totals[$account->id] ??= [
|
||||
|
|
@ -250,10 +260,11 @@ class TransactionAnalysisController extends Controller
|
|||
'bank' => $account->bank ? ['name' => $account->bank->name, 'logo' => $account->bank->logo] : null,
|
||||
'amount' => 0,
|
||||
];
|
||||
$totals[$account->id]['amount'] += abs($amount);
|
||||
$totals[$account->id]['amount'] += $amount;
|
||||
}
|
||||
|
||||
return collect($totals)
|
||||
->filter(fn (array $account): bool => $account['amount'] > 0)
|
||||
->sortByDesc('amount')
|
||||
->values();
|
||||
}
|
||||
|
|
@ -268,7 +279,7 @@ class TransactionAnalysisController extends Controller
|
|||
private function largestExpenses(Collection $transactions, string $currency): array
|
||||
{
|
||||
return $transactions
|
||||
->filter(fn (Transaction $transaction): bool => $this->convertTransactionAmount($transaction, $currency) < 0)
|
||||
->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 => [
|
||||
|
|
@ -319,13 +330,12 @@ class TransactionAnalysisController extends Controller
|
|||
$buckets = [];
|
||||
foreach ($transactions as $transaction) {
|
||||
$key = $transaction->transaction_date->format($keyFormat);
|
||||
$amount = $this->convertTransactionAmount($transaction, $currency);
|
||||
$buckets[$key] ??= ['income' => 0, 'expense' => 0];
|
||||
|
||||
if ($amount > 0) {
|
||||
$buckets[$key]['income'] += $amount;
|
||||
} else {
|
||||
$buckets[$key]['expense'] += abs($amount);
|
||||
if ($this->isIncomeSide($transaction)) {
|
||||
$buckets[$key]['income'] += $this->convertTransactionAmount($transaction, $currency);
|
||||
} elseif ($this->isExpenseSide($transaction)) {
|
||||
$buckets[$key]['expense'] -= $this->convertTransactionAmount($transaction, $currency);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -368,28 +378,26 @@ class TransactionAnalysisController extends Controller
|
|||
return (int) $dates->min()->diffInDays($dates->max()) + 1;
|
||||
}
|
||||
|
||||
private function convertTransactionAmount(Transaction $transaction, string $currency): int
|
||||
/**
|
||||
* 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 $this->exchangeRateService->convert(
|
||||
$transaction->currency_code ?: $transaction->account?->currency_code ?: $currency,
|
||||
$currency,
|
||||
$transaction->amount,
|
||||
$transaction->transaction_date->toDateString(),
|
||||
);
|
||||
return $transaction->categoryType() === CategoryType::Expense
|
||||
|| ($transaction->category_id === null && $transaction->amount < 0);
|
||||
}
|
||||
|
||||
private function preloadExchangeRates(Collection $transactions, string $currency): void
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$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);
|
||||
return $transaction->categoryType() === CategoryType::Income
|
||||
|| ($transaction->category_id === null && $transaction->amount > 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AutomationRule, $this> */
|
||||
public function categorizedByRule(): BelongsTo
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use App\Enums\CategoryType;
|
|||
use App\Models\Account;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Category;
|
||||
use App\Models\ExchangeRate;
|
||||
use App\Models\Label;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
|
|
@ -27,6 +28,7 @@ function makeTransaction(array $attributes = []): Transaction
|
|||
'user_id' => test()->user->id,
|
||||
'account_id' => test()->account->id,
|
||||
'currency_code' => 'USD',
|
||||
'category_id' => null,
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
|
@ -255,3 +257,152 @@ test('account breakdown sums expenses per funding account', function () {
|
|||
expect($response->json('distinct_account_count'))->toBe(2);
|
||||
expect($response->json('by_account.0'))->toMatchArray(['name' => 'Travel card', 'amount' => 6000]);
|
||||
});
|
||||
|
||||
test('summary excludes transfer category transactions from income and expense', function () {
|
||||
$transfer = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Transfer]);
|
||||
|
||||
makeTransaction(['amount' => 100000, 'transaction_date' => '2026-01-10']);
|
||||
makeTransaction(['amount' => -40000, 'transaction_date' => '2026-01-11']);
|
||||
|
||||
// Internal movements between own accounts: must not move income or expense.
|
||||
makeTransaction(['amount' => 70000, 'category_id' => $transfer->id, 'transaction_date' => '2026-01-12']);
|
||||
makeTransaction(['amount' => -70000, 'category_id' => $transfer->id, 'transaction_date' => '2026-01-13']);
|
||||
|
||||
$this->getJson('/api/transactions/analysis')
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'summary' => [
|
||||
'income' => 100000,
|
||||
'expense' => 40000,
|
||||
'net' => 60000,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('summary excludes savings and investment outflows from expense, matching cashflow', function () {
|
||||
$savings = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Savings]);
|
||||
$investment = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Investment]);
|
||||
|
||||
makeTransaction(['amount' => -40000, 'transaction_date' => '2026-01-10']);
|
||||
makeTransaction(['amount' => -25000, 'category_id' => $savings->id, 'transaction_date' => '2026-01-11']);
|
||||
makeTransaction(['amount' => -15000, 'category_id' => $investment->id, 'transaction_date' => '2026-01-12']);
|
||||
|
||||
$this->getJson('/api/transactions/analysis')
|
||||
->assertOk()
|
||||
->assertJson(['summary' => ['expense' => 40000]]);
|
||||
});
|
||||
|
||||
test('category breakdown ignores transfer, savings and investment categories', function () {
|
||||
$expense = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Groceries']);
|
||||
$transfer = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Transfer, 'name' => 'Move money']);
|
||||
$savings = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Savings, 'name' => 'Rainy day']);
|
||||
|
||||
makeTransaction(['amount' => -30000, 'category_id' => $expense->id, 'transaction_date' => '2026-01-10']);
|
||||
makeTransaction(['amount' => -50000, 'category_id' => $transfer->id, 'transaction_date' => '2026-01-11']);
|
||||
makeTransaction(['amount' => -20000, 'category_id' => $savings->id, 'transaction_date' => '2026-01-12']);
|
||||
|
||||
$response = $this->getJson('/api/transactions/analysis')->assertOk();
|
||||
|
||||
expect($response->json('distinct_category_count'))->toBe(1);
|
||||
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Groceries', 'amount' => 30000]);
|
||||
});
|
||||
|
||||
test('tag, payee and account breakdowns ignore transfer categories', function () {
|
||||
$label = Label::factory()->create(['user_id' => $this->user->id]);
|
||||
$transfer = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Transfer]);
|
||||
|
||||
makeTransaction(['amount' => -10000, 'creditor_name' => 'Shop', 'transaction_date' => '2026-01-10'])
|
||||
->labels()->attach($label);
|
||||
|
||||
$tagged = makeTransaction(['amount' => -90000, 'category_id' => $transfer->id, 'creditor_name' => 'My other account', 'transaction_date' => '2026-01-11']);
|
||||
$tagged->labels()->attach($label);
|
||||
|
||||
$response = $this->getJson('/api/transactions/analysis')->assertOk();
|
||||
|
||||
expect($response->json('distinct_label_count'))->toBe(1);
|
||||
expect($response->json('by_tag.0.amount'))->toBe(10000);
|
||||
expect($response->json('distinct_payee_count'))->toBe(1);
|
||||
expect($response->json('by_payee.0'))->toMatchArray(['name' => 'Shop', 'amount' => 10000]);
|
||||
expect($response->json('distinct_account_count'))->toBe(1);
|
||||
expect($response->json('by_account.0.amount'))->toBe(10000);
|
||||
});
|
||||
|
||||
test('largest expenses ignores transfer category outflows', function () {
|
||||
$transfer = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Transfer]);
|
||||
|
||||
makeTransaction(['amount' => -5000, 'description' => 'Dinner', 'transaction_date' => '2026-01-10']);
|
||||
makeTransaction(['amount' => -99000, 'category_id' => $transfer->id, 'description' => 'Transfer out', 'transaction_date' => '2026-01-11']);
|
||||
|
||||
$largest = $this->getJson('/api/transactions/analysis')->assertOk()->json('largest_expenses');
|
||||
|
||||
expect($largest)->toHaveCount(1);
|
||||
expect($largest[0])->toMatchArray(['description' => 'Dinner', 'amount' => 5000]);
|
||||
});
|
||||
|
||||
test('over time buckets ignore transfer categories', function () {
|
||||
$transfer = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Transfer]);
|
||||
|
||||
makeTransaction(['amount' => 30000, 'transaction_date' => '2026-01-10']);
|
||||
makeTransaction(['amount' => -10000, 'transaction_date' => '2026-01-10']);
|
||||
makeTransaction(['amount' => 50000, 'category_id' => $transfer->id, 'transaction_date' => '2026-01-10']);
|
||||
makeTransaction(['amount' => -50000, 'category_id' => $transfer->id, 'transaction_date' => '2026-01-10']);
|
||||
|
||||
$points = collect($this->getJson('/api/transactions/analysis')->assertOk()->json('over_time.points'));
|
||||
$day = $points->firstWhere('date', '2026-01-10');
|
||||
|
||||
expect($day)->toMatchArray(['income' => 30000, 'expense' => 10000]);
|
||||
});
|
||||
|
||||
test('refunds net against spending within an expense category, matching cashflow', function () {
|
||||
$shopping = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Shopping']);
|
||||
|
||||
makeTransaction(['amount' => -10000, 'category_id' => $shopping->id, 'transaction_date' => '2026-01-10']);
|
||||
makeTransaction(['amount' => 3000, 'category_id' => $shopping->id, 'transaction_date' => '2026-01-15']);
|
||||
|
||||
$response = $this->getJson('/api/transactions/analysis')->assertOk();
|
||||
|
||||
// The +3000 refund is not income: it nets the expense down to 7000.
|
||||
$response->assertJson(['summary' => ['income' => 0, 'expense' => 7000, 'net' => -7000]]);
|
||||
expect($response->json('distinct_category_count'))->toBe(1);
|
||||
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Shopping', 'amount' => 7000]);
|
||||
});
|
||||
|
||||
test('reversals net against income within an income category', function () {
|
||||
$salary = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Income, 'name' => 'Salary']);
|
||||
|
||||
makeTransaction(['amount' => 10000, 'category_id' => $salary->id, 'transaction_date' => '2026-01-10']);
|
||||
makeTransaction(['amount' => -2000, 'category_id' => $salary->id, 'transaction_date' => '2026-01-15']);
|
||||
|
||||
$this->getJson('/api/transactions/analysis')
|
||||
->assertOk()
|
||||
->assertJson(['summary' => ['income' => 8000, 'expense' => 0, 'net' => 8000]]);
|
||||
});
|
||||
|
||||
test('an uncategorized inflow is income, never an expense category row', function () {
|
||||
makeTransaction(['amount' => 5000, 'transaction_date' => '2026-01-10']);
|
||||
|
||||
$response = $this->getJson('/api/transactions/analysis')->assertOk();
|
||||
|
||||
$response->assertJson(['summary' => ['income' => 5000, 'expense' => 0, 'net' => 5000]]);
|
||||
expect($response->json('distinct_category_count'))->toBe(0);
|
||||
expect($response->json('by_category'))->toBe([]);
|
||||
});
|
||||
|
||||
test('foreign currency expenses are converted to the user currency', function () {
|
||||
$shopping = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
|
||||
$eurAccount = Account::factory()->create(['user_id' => $this->user->id, 'currency_code' => 'EUR']);
|
||||
|
||||
ExchangeRate::factory()->create([
|
||||
'base_currency' => 'usd',
|
||||
'date' => '2026-01-10',
|
||||
'rates' => ['eur' => 0.80],
|
||||
]);
|
||||
|
||||
makeTransaction(['amount' => -5000, 'category_id' => $shopping->id, 'transaction_date' => '2026-01-10']);
|
||||
makeTransaction(['amount' => -4000, 'category_id' => $shopping->id, 'account_id' => $eurAccount->id, 'currency_code' => 'EUR', 'transaction_date' => '2026-01-10']);
|
||||
|
||||
// 4000 EUR / 0.80 = 5000 USD, on top of the 5000 USD spend.
|
||||
$this->getJson('/api/transactions/analysis')
|
||||
->assertOk()
|
||||
->assertJson(['summary' => ['expense' => 10000]]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue