diff --git a/app/Http/Controllers/Api/CashflowAnalyticsController.php b/app/Http/Controllers/Api/CashflowAnalyticsController.php index 9b3bd03b..df1364ac 100644 --- a/app/Http/Controllers/Api/CashflowAnalyticsController.php +++ b/app/Http/Controllers/Api/CashflowAnalyticsController.php @@ -85,16 +85,12 @@ class CashflowAnalyticsController extends Controller $totals = $monthlyTotals->get($monthKey); $income = (int) ($totals->income ?? 0); $expense = (int) ($totals->expense ?? 0); - $trackedTransferInflow = (int) ($totals->transfer_inflow ?? 0); - $trackedTransferOutflow = (int) ($totals->transfer_outflow ?? 0); $data[] = [ 'month' => $monthKey, 'income' => $income, 'expense' => $expense, 'net' => $income - $expense, - 'transfer_inflow' => $trackedTransferInflow, - 'transfer_outflow' => $trackedTransferOutflow, ]; $current->addMonth(); @@ -195,9 +191,20 @@ class CashflowAnalyticsController extends Controller ->whereBetween('transactions.transaction_date', [$from, $to]) ->where('transactions.amount', $operator, 0) ->whereNotNull('transactions.category_id') - ->join('categories', function ($join) { + ->join('categories', function ($join) use ($isIncome) { $join->on('transactions.category_id', '=', 'categories.id') - ->where('categories.type', '!=', CategoryType::Transfer); + ->where(function ($q) use ($isIncome) { + $q->where('categories.type', '!=', CategoryType::Transfer) + ->orWhere(function ($q) use ($isIncome) { + $q->where('categories.type', CategoryType::Transfer) + ->where( + 'categories.cashflow_direction', + $isIncome + ? CategoryCashflowDirection::Inflow + : CategoryCashflowDirection::Outflow, + ); + }); + }); }) ->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount')) ->groupBy('transactions.category_id') @@ -250,14 +257,6 @@ class CashflowAnalyticsController extends Controller 'SUM(CASE WHEN ((categories.type = ? AND transactions.amount < 0) OR (transactions.category_id IS NULL AND transactions.amount < 0)) THEN -transactions.amount ELSE 0 END) as expense', [CategoryType::Expense->value] ) - ->selectRaw( - 'SUM(CASE WHEN categories.type = ? AND categories.cashflow_direction = ? AND transactions.amount > 0 THEN transactions.amount ELSE 0 END) as transfer_inflow', - [CategoryType::Transfer->value, CategoryCashflowDirection::Inflow->value] - ) - ->selectRaw( - 'SUM(CASE WHEN categories.type = ? AND categories.cashflow_direction = ? AND transactions.amount < 0 THEN -transactions.amount ELSE 0 END) as transfer_outflow', - [CategoryType::Transfer->value, CategoryCashflowDirection::Outflow->value] - ) ->groupBy('month') ->orderBy('month') ->get() diff --git a/lang/es.json b/lang/es.json index 21c246a2..4df444e7 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1590,5 +1590,7 @@ "Tracked Inflows": "Entradas seguidas", "Tracked Outflows": "Salidas seguidas", "Tracked transfers appear only in the monthly cashflow trend. They are not counted as income, expenses, or shown in the money flow Sankey.": "Las transferencias seguidas solo aparecen en la tendencia mensual del flujo de caja. No cuentan como ingresos ni gastos, ni se muestran en el Sankey de flujo de dinero.", - "Transfer categories stay out of income and expense totals, but you can still track them in the monthly chart.": "Las categorias de transferencia quedan fuera de los totales de ingresos y gastos, pero aun puedes seguirlas en el grafico mensual." + "Tracked transfers appear in the money flow Sankey chart. They are not counted as income or expenses.": "Las transferencias seguidas aparecen en el diagrama Sankey de flujo de dinero. No se cuentan como ingresos ni gastos.", + "Transfer categories stay out of income and expense totals, but you can still track them in the monthly chart.": "Las categorias de transferencia quedan fuera de los totales de ingresos y gastos, pero aun puedes seguirlas en el grafico mensual.", + "Transfer categories stay out of income and expense totals, but you can still track them in the money flow chart.": "Las categorías de transferencia quedan fuera de los totales de ingresos y gastos, pero aún puedes seguirlas en el gráfico de flujo de dinero." } diff --git a/resources/js/components/categories/category-cashflow-direction-fields.tsx b/resources/js/components/categories/category-cashflow-direction-fields.tsx index 92671e2a..b8ab404a 100644 --- a/resources/js/components/categories/category-cashflow-direction-fields.tsx +++ b/resources/js/components/categories/category-cashflow-direction-fields.tsx @@ -65,7 +65,7 @@ export function CategoryCashflowDirectionFields({

{__( - 'Transfer categories stay out of income and expense totals, but you can still track them in the monthly chart.', + 'Transfer categories stay out of income and expense totals, but you can still track them in the money flow chart.', )}

@@ -102,7 +102,7 @@ export function CategoryCashflowDirectionFields({ {__( - 'Tracked transfers appear only in the monthly cashflow trend. They are not counted as income, expenses, or shown in the money flow Sankey.', + 'Tracked transfers appear in the money flow Sankey chart. They are not counted as income or expenses.', )} diff --git a/resources/js/components/charts/cashflow-trend-chart.tsx b/resources/js/components/charts/cashflow-trend-chart.tsx index 766d2a1b..4caaddea 100644 --- a/resources/js/components/charts/cashflow-trend-chart.tsx +++ b/resources/js/components/charts/cashflow-trend-chart.tsx @@ -46,8 +46,6 @@ interface CustomTooltipProps { currency?: string; incomeColor?: string; expenseColor?: string; - transferInflowColor?: string; - transferOutflowColor?: string; } function CustomTooltip({ @@ -56,8 +54,6 @@ function CustomTooltip({ currency = 'USD', incomeColor, expenseColor, - transferInflowColor, - transferOutflowColor, }: CustomTooltipProps) { const locale = useLocale(); @@ -104,38 +100,6 @@ function CustomTooltip({ className="font-mono font-medium tabular-nums" /> -
- - - {__('Tracked Inflows')} - - -
-
- - - {__('Tracked Outflows')} - - -
@@ -167,8 +131,6 @@ export function CashflowTrendChart({ const scrollContainerRef = useRef(null); const minChartWidth = data.length * 60; const { cashflowIncomeColor, cashflowExpenseColor } = useChartColors(); - const transferInflowColor = 'var(--color-chart-4)'; - const transferOutflowColor = 'var(--color-chart-6)'; const chartConfig: ChartConfig = { income: { @@ -179,14 +141,6 @@ export function CashflowTrendChart({ label: __('Expenses'), color: cashflowExpenseColor, }, - transfer_inflow: { - label: __('Tracked Inflows'), - color: transferInflowColor, - }, - transfer_outflow: { - label: __('Tracked Outflows'), - color: transferOutflowColor, - }, net: { label: __('Net'), color: 'var(--color-chart-1)', @@ -223,7 +177,7 @@ export function CashflowTrendChart({ {__( - 'Monthly income, expenses, tracked transfers, and net cashflow over the last 12 months', + 'Monthly income, expenses, and net cashflow over the last 12 months', )} @@ -277,12 +231,6 @@ export function CashflowTrendChart({ currency={currency} incomeColor={cashflowIncomeColor} expenseColor={cashflowExpenseColor} - transferInflowColor={ - transferInflowColor - } - transferOutflowColor={ - transferOutflowColor - } /> } cursor={{ @@ -307,22 +255,6 @@ export function CashflowTrendChart({ name="Expenses" /> - - - - {__('Expenses')}
-
- - {__('Tracked Inflows')} -
-
- - {__('Tracked Outflows')} -
{__('Net')} diff --git a/resources/js/hooks/use-cashflow-data.ts b/resources/js/hooks/use-cashflow-data.ts index a03097a7..06971f69 100644 --- a/resources/js/hooks/use-cashflow-data.ts +++ b/resources/js/hooks/use-cashflow-data.ts @@ -27,8 +27,6 @@ export interface TrendDataPoint { income: number; expense: number; net: number; - transfer_inflow: number; - transfer_outflow: number; } export interface BreakdownItem { diff --git a/resources/js/pages/settings/categories.tsx b/resources/js/pages/settings/categories.tsx index ba0c3055..4c0ed5e1 100644 --- a/resources/js/pages/settings/categories.tsx +++ b/resources/js/pages/settings/categories.tsx @@ -295,6 +295,33 @@ export default function Categories() { ); }, }, + { + accessorKey: 'cashflow_direction', + header: __('Cashflow analytics'), + cell: ({ row }) => { + if (row.original.type !== 'transfer') { + return ( + + {__('Standard')} + + ); + } + + const directionConfig = { + hidden: __('Do not show'), + inflow: __('Cash inflow'), + outflow: __('Cash outflow'), + }; + + const direction = row.original.cashflow_direction; + + return ( + + {directionConfig[direction] ?? direction} + + ); + }, + }, { id: 'actions', enableHiding: false, diff --git a/tests/Feature/CashflowAnalyticsTest.php b/tests/Feature/CashflowAnalyticsTest.php index c948a38b..d10a3e86 100644 --- a/tests/Feature/CashflowAnalyticsTest.php +++ b/tests/Feature/CashflowAnalyticsTest.php @@ -241,12 +241,10 @@ test('cashflow trend returns monthly data for specified months', function () { $data = $response->json(); expect($data['data'])->toHaveCount(3); - expect($data['data'][0])->toHaveKeys(['month', 'income', 'expense', 'net', 'transfer_inflow', 'transfer_outflow']); - expect($data['data'][0]['transfer_inflow'])->toBe(0); - expect($data['data'][0]['transfer_outflow'])->toBe(0); + expect($data['data'][0])->toHaveKeys(['month', 'income', 'expense', 'net']); }); -test('cashflow trend includes tracked transfer inflows and outflows without affecting net', function () { +test('cashflow trend does not include tracked transfers', function () { $incomeCategory = Category::factory()->create([ 'user_id' => $this->user->id, 'type' => CategoryType::Income, @@ -267,12 +265,6 @@ test('cashflow trend includes tracked transfer inflows and outflows without affe 'cashflow_direction' => CategoryCashflowDirection::Inflow, 'name' => 'Investment Return Transfer', ]); - $hiddenTransferCategory = Category::factory()->create([ - 'user_id' => $this->user->id, - 'type' => CategoryType::Transfer, - 'cashflow_direction' => CategoryCashflowDirection::Hidden, - 'name' => 'Own Account', - ]); $account = Account::factory()->create(['user_id' => $this->user->id]); @@ -304,32 +296,25 @@ test('cashflow trend includes tracked transfer inflows and outflows without affe 'amount' => 15000, 'transaction_date' => now(), ]); - Transaction::factory()->create([ - 'user_id' => $this->user->id, - 'account_id' => $account->id, - 'category_id' => $hiddenTransferCategory->id, - 'amount' => -5000, - 'transaction_date' => now(), - ]); $response = $this->getJson('/api/cashflow/trend?months=1'); $response->assertOk(); $point = $response->json('data.0'); + // Only income/expense categories affect the trend — transfers are excluded expect($point['income'])->toBe(100000); expect($point['expense'])->toBe(40000); expect($point['net'])->toBe(60000); - expect($point['transfer_outflow'])->toBe(25000); - expect($point['transfer_inflow'])->toBe(15000); + expect($point)->not->toHaveKey('transfer_outflow'); + expect($point)->not->toHaveKey('transfer_inflow'); }); test('cashflow trend anchors the 12-month series to the requested period end month', function () { - $transferCategory = Category::factory()->create([ + $incomeCategory = Category::factory()->create([ 'user_id' => $this->user->id, - 'type' => CategoryType::Transfer, - 'cashflow_direction' => CategoryCashflowDirection::Outflow, - 'name' => 'Investment', + 'type' => CategoryType::Income, + 'name' => 'Salary', ]); $account = Account::factory()->create(['user_id' => $this->user->id]); @@ -337,16 +322,16 @@ test('cashflow trend anchors the 12-month series to the requested period end mon Transaction::factory()->create([ 'user_id' => $this->user->id, 'account_id' => $account->id, - 'category_id' => $transferCategory->id, - 'amount' => -32000, + 'category_id' => $incomeCategory->id, + 'amount' => 32000, 'transaction_date' => '2025-04-14', ]); Transaction::factory()->create([ 'user_id' => $this->user->id, 'account_id' => $account->id, - 'category_id' => $transferCategory->id, - 'amount' => -48000, + 'category_id' => $incomeCategory->id, + 'amount' => 48000, 'transaction_date' => '2025-05-07', ]); @@ -359,8 +344,8 @@ test('cashflow trend anchors the 12-month series to the requested period end mon expect($data)->toHaveCount(12); expect($data->keys()->first())->toBe('2024-06'); expect($data->keys()->last())->toBe('2025-05'); - expect($data['2025-04']['transfer_outflow'])->toBe(32000); - expect($data['2025-05']['transfer_outflow'])->toBe(48000); + expect($data['2025-04']['income'])->toBe(32000); + expect($data['2025-05']['income'])->toBe(48000); }); test('cashflow trend defaults to 12 months', function () { @@ -683,7 +668,7 @@ test('sankey income category with mixed positive and negative transactions shows expect($data['total_expense'])->toBe(26799); }); -test('sankey excludes transfer categories from both sides', function () { +test('sankey excludes hidden transfer categories from both sides', function () { $incomeCategory = Category::factory()->create([ 'user_id' => $this->user->id, 'type' => CategoryType::Income, @@ -694,9 +679,10 @@ test('sankey excludes transfer categories from both sides', function () { 'type' => CategoryType::Expense, 'name' => 'Groceries', ]); - $transferCategory = Category::factory()->create([ + $hiddenTransferCategory = Category::factory()->create([ 'user_id' => $this->user->id, 'type' => CategoryType::Transfer, + 'cashflow_direction' => CategoryCashflowDirection::Hidden, 'name' => 'Internal Transfer', ]); @@ -720,14 +706,14 @@ test('sankey excludes transfer categories from both sides', function () { Transaction::factory()->create([ 'user_id' => $this->user->id, 'account_id' => $account->id, - 'category_id' => $transferCategory->id, + 'category_id' => $hiddenTransferCategory->id, 'amount' => 100000, 'transaction_date' => now(), ]); Transaction::factory()->create([ 'user_id' => $this->user->id, 'account_id' => $account->id, - 'category_id' => $transferCategory->id, + 'category_id' => $hiddenTransferCategory->id, 'amount' => -75000, 'transaction_date' => now(), ]); @@ -746,6 +732,104 @@ test('sankey excludes transfer categories from both sides', function () { expect(collect($data['expense_categories'])->pluck('category.name'))->not->toContain('Internal Transfer'); }); +test('sankey includes outflow transfer categories on the expense side', function () { + $incomeCategory = Category::factory()->create([ + 'user_id' => $this->user->id, + 'type' => CategoryType::Income, + 'name' => 'Salary', + ]); + $investmentCategory = Category::factory()->create([ + 'user_id' => $this->user->id, + 'type' => CategoryType::Transfer, + 'cashflow_direction' => CategoryCashflowDirection::Outflow, + 'name' => 'Investments', + ]); + + $account = Account::factory()->create(['user_id' => $this->user->id]); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $incomeCategory->id, + 'amount' => 300000, + 'transaction_date' => now(), + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $investmentCategory->id, + 'amount' => -50000, + 'transaction_date' => now(), + ]); + + $response = $this->getJson('/api/cashflow/sankey?'.http_build_query([ + 'from' => now()->startOfMonth()->toDateString(), + 'to' => now()->endOfMonth()->toDateString(), + ])); + + $response->assertOk(); + $data = $response->json(); + + expect($data['total_income'])->toBe(300000); + expect($data['total_expense'])->toBe(50000); + + $investmentExpense = collect($data['expense_categories'])->firstWhere('category.name', 'Investments'); + expect($investmentExpense)->not->toBeNull(); + expect($investmentExpense['amount'])->toBe(50000); + + // Outflow transfers should not appear on the income side + expect(collect($data['income_categories'])->pluck('category.name'))->not->toContain('Investments'); +}); + +test('sankey includes inflow transfer categories on the income side', function () { + $expenseCategory = Category::factory()->create([ + 'user_id' => $this->user->id, + 'type' => CategoryType::Expense, + 'name' => 'Groceries', + ]); + $inflowCategory = Category::factory()->create([ + 'user_id' => $this->user->id, + 'type' => CategoryType::Transfer, + 'cashflow_direction' => CategoryCashflowDirection::Inflow, + 'name' => 'From Relatives', + ]); + + $account = Account::factory()->create(['user_id' => $this->user->id]); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $inflowCategory->id, + 'amount' => 80000, + 'transaction_date' => now(), + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $expenseCategory->id, + 'amount' => -20000, + 'transaction_date' => now(), + ]); + + $response = $this->getJson('/api/cashflow/sankey?'.http_build_query([ + 'from' => now()->startOfMonth()->toDateString(), + 'to' => now()->endOfMonth()->toDateString(), + ])); + + $response->assertOk(); + $data = $response->json(); + + expect($data['total_income'])->toBe(80000); + expect($data['total_expense'])->toBe(20000); + + $inflowIncome = collect($data['income_categories'])->firstWhere('category.name', 'From Relatives'); + expect($inflowIncome)->not->toBeNull(); + expect($inflowIncome['amount'])->toBe(80000); + + // Inflow transfers should not appear on the expense side + expect(collect($data['expense_categories'])->pluck('category.name'))->not->toContain('From Relatives'); +}); + test('breakdown includes unknown income category', function () { $incomeCategory = Category::factory()->create([ 'user_id' => $this->user->id,