feat(cashflow): show tracked transfers in Sankey diagram (#237)

## Summary

Transfer categories with `cashflow_direction` set to `outflow` or
`inflow` now appear in the **Sankey diagram** instead of the 12-month
trend chart. This gives a clearer picture of money flow (e.g.
investments, savings transfers) without inflating the income/expense
trend lines. Hidden transfers remain excluded from everything.

## Changes

- **Sankey endpoint**: Updated join to include tracked transfer
categories on the matching flow side (outflow → expense side, inflow →
income side)
- **Trend endpoint**: Removed `transfer_inflow` and `transfer_outflow`
columns from the SQL query and API response
- **Trend chart UI**: Removed tracked inflow/outflow bars, tooltip rows,
legend items, and related config
- **TypeScript types**: Removed `transfer_inflow`/`transfer_outflow`
from `TrendDataPoint`
- **Category form**: Updated direction field description to reference
"Sankey chart" instead of "monthly trend"
- **Tests**: Updated 4 existing tests and added 2 new tests covering
tracked transfers in Sankey breakdown

## Design decision

Tracked transfers appear **only in the Sankey diagram** — they do not
affect summary totals, savings rate, or breakdown cards. This keeps the
high-level numbers accurate while still visualizing where money flows
internally.
This commit is contained in:
Víctor Falcón 2026-03-19 10:49:54 +00:00 committed by GitHub
parent 272dac14b8
commit 6dda5f56ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 163 additions and 135 deletions

View File

@ -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()

View File

@ -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."
}

View File

@ -65,7 +65,7 @@ export function CategoryCashflowDirectionFields({
</Label>
<p className="text-xs text-muted-foreground">
{__(
'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.',
)}
</p>
</div>
@ -102,7 +102,7 @@ export function CategoryCashflowDirectionFields({
<Info className="h-4 w-4 opacity-50" />
<AlertDescription className="text-xs leading-relaxed">
{__(
'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.',
)}
</AlertDescription>
</Alert>

View File

@ -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"
/>
</div>
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-2">
<span
className="size-2 rounded-full"
style={{ background: transferInflowColor }}
/>
{__('Tracked Inflows')}
</span>
<AmountDisplay
amountInCents={data.transfer_inflow}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
className="font-mono font-medium tabular-nums"
/>
</div>
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-2">
<span
className="size-2 rounded-full"
style={{ background: transferOutflowColor }}
/>
{__('Tracked Outflows')}
</span>
<AmountDisplay
amountInCents={data.transfer_outflow}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
className="font-mono font-medium tabular-nums"
/>
</div>
<div className="flex items-center justify-between gap-4 border-t pt-1">
<span className="flex items-center gap-2">
<span className="size-2 rounded-full bg-[var(--color-chart-1)]" />
@ -167,8 +131,6 @@ export function CashflowTrendChart({
const scrollContainerRef = useRef<HTMLDivElement>(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({
</CardTitle>
<CardDescription>
{__(
'Monthly income, expenses, tracked transfers, and net cashflow over the last 12 months',
'Monthly income, expenses, and net cashflow over the last 12 months',
)}
</CardDescription>
</CardHeader>
@ -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"
/>
<Bar
dataKey="transfer_inflow"
fill={transferInflowColor}
radius={[4, 4, 0, 0]}
stackId="c"
name="Tracked Inflows"
/>
<Bar
dataKey="transfer_outflow"
fill={transferOutflowColor}
radius={[4, 4, 0, 0]}
stackId="d"
name="Tracked Outflows"
/>
<Line
type="monotone"
dataKey="net"
@ -354,20 +286,6 @@ export function CashflowTrendChart({
/>
<span>{__('Expenses')}</span>
</div>
<div className="flex items-center gap-2">
<span
className="size-3 rounded"
style={{ background: transferInflowColor }}
/>
<span>{__('Tracked Inflows')}</span>
</div>
<div className="flex items-center gap-2">
<span
className="size-3 rounded"
style={{ background: transferOutflowColor }}
/>
<span>{__('Tracked Outflows')}</span>
</div>
<div className="flex items-center gap-2">
<span className="size-3 rounded-full bg-[var(--color-chart-1)]" />
<span>{__('Net')}</span>

View File

@ -27,8 +27,6 @@ export interface TrendDataPoint {
income: number;
expense: number;
net: number;
transfer_inflow: number;
transfer_outflow: number;
}
export interface BreakdownItem {

View File

@ -295,6 +295,33 @@ export default function Categories() {
);
},
},
{
accessorKey: 'cashflow_direction',
header: __('Cashflow analytics'),
cell: ({ row }) => {
if (row.original.type !== 'transfer') {
return (
<span className="text-sm text-muted-foreground">
{__('Standard')}
</span>
);
}
const directionConfig = {
hidden: __('Do not show'),
inflow: __('Cash inflow'),
outflow: __('Cash outflow'),
};
const direction = row.original.cashflow_direction;
return (
<span className="text-sm">
{directionConfig[direction] ?? direction}
</span>
);
},
},
{
id: 'actions',
enableHiding: false,

View File

@ -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,