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:
parent
272dac14b8
commit
6dda5f56ad
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@ export interface TrendDataPoint {
|
|||
income: number;
|
||||
expense: number;
|
||||
net: number;
|
||||
transfer_inflow: number;
|
||||
transfer_outflow: number;
|
||||
}
|
||||
|
||||
export interface BreakdownItem {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue