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,