Add Cashflow Analytics Feature (#49)

Summary
- Add new Cashflow page with income/expense analytics, Sankey chart
visualization, and trend analysis
- Add Cashflow summary widget to the dashboard
- Implement feature flag system using Laravel Pennant to control feature
visibility per user
- Add artisan commands to enable/disable features: `php artisan
feature:enable/disable [feature] [email|all]`

## New Features
- **Cashflow Page**: Period-based income/expense breakdown with category
analysis
- **Sankey Chart**: Visual flow diagram showing money movement between
categories
- **Trend Chart**: 12-month historical cashflow visualization
- **Dashboard Widget**: Quick cashflow summary card
- **Feature Flags**: Cashflow feature hidden behind feature flag,
controllable per-user

## How to enable it
This feature it's under a feature flag until we are sure it's working
fine. If you want to test it, [send us a message on the general channel
in our discord](https://discord.gg/9UQWZECDDv). If you're self hosting
just run `php artisan feature:enable CashflowFeature {your_email}`.

## New Cashflow Page
<img width="1278" height="1185" alt="image"
src="https://github.com/user-attachments/assets/83469f24-9f96-4e5f-bef9-86c076d31243"
/>
<img width="1274" height="1035" alt="image"
src="https://github.com/user-attachments/assets/8949ff9d-c370-4fc4-81c6-268307c587a5"
/>

## New Dashboard Widget
<img width="1180" height="289" alt="image"
src="https://github.com/user-attachments/assets/ad984a66-9ffd-4079-85ce-0b2d28278d76"
/>
<img width="1179" height="286" alt="image"
src="https://github.com/user-attachments/assets/bff60286-8ea2-4e9c-8fa6-22755566648f"
/>
This commit is contained in:
Víctor Falcón 2026-01-05 13:06:50 +01:00 committed by GitHub
parent 2634a501b5
commit 1cec4a6421
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2667 additions and 28 deletions

View File

@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands\Concerns;
use Illuminate\Support\Facades\File;
trait ResolvesFeatures
{
private function resolveFeatureClass(string $name): ?string
{
$featureClass = "App\\Features\\{$name}";
if (class_exists($featureClass)) {
return $featureClass;
}
return null;
}
private function getAvailableFeatures(): string
{
$featuresPath = app_path('Features');
if (! File::isDirectory($featuresPath)) {
return 'None';
}
$files = File::files($featuresPath);
$features = [];
foreach ($files as $file) {
$features[] = $file->getFilenameWithoutExtension();
}
return implode(', ', $features) ?: 'None';
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\ResolvesFeatures;
use App\Models\User;
use Illuminate\Console\Command;
use Laravel\Pennant\Feature;
class FeatureDisableCommand extends Command
{
use ResolvesFeatures;
protected $signature = 'feature:disable {feature : The feature class name (e.g., CashflowFeature)} {target : User email or "all" for everyone}';
protected $description = 'Disable a feature for a specific user or all users';
public function handle(): int
{
$featureName = $this->argument('feature');
$target = $this->argument('target');
$featureClass = $this->resolveFeatureClass($featureName);
if (! $featureClass) {
$this->error("Feature '{$featureName}' not found.");
$this->line('Available features: '.$this->getAvailableFeatures());
return self::FAILURE;
}
if ($target === 'all') {
Feature::deactivateForEveryone($featureClass);
$this->info("Feature '{$featureName}' disabled for all users.");
return self::SUCCESS;
}
$user = User::where('email', $target)->first();
if (! $user) {
$this->error("User with email '{$target}' not found.");
return self::FAILURE;
}
Feature::for($user)->deactivate($featureClass);
$this->info("Feature '{$featureName}' disabled for user '{$user->email}'.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\ResolvesFeatures;
use App\Models\User;
use Illuminate\Console\Command;
use Laravel\Pennant\Feature;
class FeatureEnableCommand extends Command
{
use ResolvesFeatures;
protected $signature = 'feature:enable {feature : The feature class name (e.g., CashflowFeature)} {target : User email or "all" for everyone}';
protected $description = 'Enable a feature for a specific user or all users';
public function handle(): int
{
$featureName = $this->argument('feature');
$target = $this->argument('target');
$featureClass = $this->resolveFeatureClass($featureName);
if (! $featureClass) {
$this->error("Feature '{$featureName}' not found.");
$this->line('Available features: '.$this->getAvailableFeatures());
return self::FAILURE;
}
if ($target === 'all') {
Feature::activateForEveryone($featureClass);
$this->info("Feature '{$featureName}' enabled for all users.");
return self::SUCCESS;
}
$user = User::where('email', $target)->first();
if (! $user) {
$this->error("User with email '{$target}' not found.");
return self::FAILURE;
}
Feature::for($user)->activate($featureClass);
$this->info("Feature '{$featureName}' enabled for user '{$user->email}'.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Features;
use App\Models\User;
class CashflowFeature
{
public function resolve(User $user): bool
{
return false;
}
}

View File

@ -0,0 +1,216 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\CategoryType;
use App\Http\Controllers\Controller;
use App\Models\Transaction;
use App\Services\PeriodComparator;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class CashflowAnalyticsController extends Controller
{
public function summary(Request $request): JsonResponse
{
$validated = $request->validate([
'from' => 'required|date',
'to' => 'required|date',
]);
$period = PeriodComparator::fromRequest($validated);
$previousPeriod = $period->previous();
$current = $this->calculateCashflowSummary($request->user()->id, $period->from, $period->to);
$previous = $this->calculateCashflowSummary($request->user()->id, $previousPeriod->from, $previousPeriod->to);
return response()->json([
'current' => $current,
'previous' => $previous,
]);
}
public function sankey(Request $request): JsonResponse
{
$validated = $request->validate([
'from' => 'required|date',
'to' => 'required|date',
]);
$from = Carbon::parse($validated['from']);
$to = Carbon::parse($validated['to']);
$userId = $request->user()->id;
$incomeCategories = $this->getCategoryBreakdown($userId, $from, $to, CategoryType::Income);
$expenseCategories = $this->getCategoryBreakdown($userId, $from, $to, CategoryType::Expense);
$totalIncome = $incomeCategories->sum('amount');
$totalExpense = $expenseCategories->sum('amount');
return response()->json([
'income_categories' => $incomeCategories->values(),
'expense_categories' => $expenseCategories->values(),
'total_income' => $totalIncome,
'total_expense' => $totalExpense,
]);
}
public function trend(Request $request): JsonResponse
{
$validated = $request->validate([
'months' => 'nullable|integer|min:1|max:24',
]);
$months = $validated['months'] ?? 12;
$userId = $request->user()->id;
$end = Carbon::now()->endOfMonth();
$start = Carbon::now()->subMonths($months - 1)->startOfMonth();
$data = [];
$current = $start->copy();
while ($current->lte($end)) {
$monthStart = $current->copy()->startOfMonth();
$monthEnd = $current->copy()->endOfMonth();
$income = $this->getTransactionSum($userId, $monthStart, $monthEnd, CategoryType::Income);
$expense = $this->getTransactionSum($userId, $monthStart, $monthEnd, CategoryType::Expense);
$data[] = [
'month' => $current->format('Y-m'),
'income' => $income,
'expense' => abs($expense),
'net' => $income + $expense, // expense is negative, so this gives net
];
$current->addMonth();
}
return response()->json([
'data' => $data,
]);
}
public function breakdown(Request $request): JsonResponse
{
$validated = $request->validate([
'from' => 'required|date',
'to' => 'required|date',
'type' => 'required|in:income,expense',
]);
$period = PeriodComparator::fromRequest($validated);
$previousPeriod = $period->previous();
$userId = $request->user()->id;
$categoryType = $validated['type'] === 'income' ? CategoryType::Income : CategoryType::Expense;
$current = $this->getCategoryBreakdown($userId, $period->from, $period->to, $categoryType);
$previous = $this->getCategoryBreakdown($userId, $previousPeriod->from, $previousPeriod->to, $categoryType);
$currentTotal = $current->sum('amount');
$previousTotal = $previous->sum('amount');
// Add percentage and previous amount to current
$currentWithPercentage = $current->map(function ($item) use ($currentTotal, $previous) {
$previousAmount = $previous->firstWhere('category_id', $item['category_id'])['amount'] ?? 0;
return [
'category' => $item['category'],
'category_id' => $item['category_id'],
'amount' => $item['amount'],
'percentage' => $currentTotal > 0 ? round(($item['amount'] / $currentTotal) * 100, 1) : 0,
'previous_amount' => $previousAmount,
];
})->sortByDesc('amount')->values();
return response()->json([
'data' => $currentWithPercentage,
'total' => $currentTotal,
'previous_total' => $previousTotal,
]);
}
private function calculateCashflowSummary(string $userId, Carbon $from, Carbon $to): array
{
$income = $this->getTransactionSum($userId, $from, $to, CategoryType::Income);
$expense = abs($this->getTransactionSum($userId, $from, $to, CategoryType::Expense));
$net = $income - $expense;
$savingsRate = $income > 0 ? round((($income - $expense) / $income) * 100, 1) : 0;
return [
'income' => $income,
'expense' => $expense,
'net' => $net,
'savings_rate' => $savingsRate,
];
}
private function getTransactionSum(string $userId, Carbon $from, Carbon $to, CategoryType $type): int
{
return Transaction::query()
->where('user_id', $userId)
->whereBetween('transaction_date', [$from, $to])
->where(function ($q) use ($type) {
$q->whereHas('category', function ($q) use ($type) {
$q->where('type', $type);
})
->orWhere(function ($q) use ($type) {
$q->whereNull('category_id')
->where('amount', $type === CategoryType::Income ? '>' : '<', 0);
});
})
->sum('amount');
}
private function getCategoryBreakdown(string $userId, Carbon $from, Carbon $to, CategoryType $type)
{
// Get categorized transactions
$categorized = Transaction::query()
->where('user_id', $userId)
->whereBetween('transaction_date', [$from, $to])
->whereHas('category', function ($q) use ($type) {
$q->where('type', $type);
})
->select('category_id', DB::raw('sum(amount) as total_amount'))
->groupBy('category_id')
->with('category')
->get()
->map(function ($item) {
return [
'category_id' => $item->category_id,
'category' => $item->category,
'amount' => abs($item->total_amount),
];
});
// Get uncategorized transactions
$uncategorized = Transaction::query()
->where('user_id', $userId)
->whereBetween('transaction_date', [$from, $to])
->whereNull('category_id')
->where('amount', $type === CategoryType::Income ? '>' : '<', 0)
->sum('amount');
// Add uncategorized as a special category if there are any
if ($uncategorized != 0) {
$categorized->push([
'category_id' => null,
'category' => (object) [
'id' => null,
'name' => $type === CategoryType::Income ? 'Unknown Income' : 'Unknown Expense',
'type' => $type,
'color' => 'gray',
'icon' => 'HelpCircle',
],
'amount' => abs($uncategorized),
]);
}
return $categorized;
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Category;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CashflowController extends Controller
{
public function __invoke(Request $request): Response
{
$user = $request->user();
$categories = Category::query()
->where('user_id', $user->id)
->orderBy('name')
->get(['id', 'name', 'type', 'icon', 'color']);
$accounts = Account::query()
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']);
$banks = Bank::query()
->where(function ($q) use ($user) {
$q->whereNull('user_id')
->orWhere('user_id', $user->id);
})
->orderBy('name')
->get(['id', 'name', 'logo']);
return Inertia::render('cashflow/index', [
'categories' => $categories,
'accounts' => $accounts,
'banks' => $banks,
]);
}
}

View File

@ -2,9 +2,11 @@
namespace App\Http\Middleware;
use App\Features\CashflowFeature;
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;
use Laravel\Pennant\Feature;
class HandleInertiaRequests extends Middleware
{
@ -58,6 +60,9 @@ class HandleInertiaRequests extends Middleware
'promo' => config('subscriptions.promo', []),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'features' => [
'cashflow' => $user ? Feature::for($user)->active(CashflowFeature::class) : false,
],
];
}
}

View File

@ -14,6 +14,7 @@
"laravel/cashier": "^16.1",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.0",
"laravel/pennant": "^1.18",
"laravel/tinker": "^2.10.1",
"laravel/wayfinder": "^0.1.9",
"resend/resend-php": "^1.1",

76
composer.lock generated
View File

@ -1658,6 +1658,82 @@
},
"time": "2025-11-04T15:39:33+00:00"
},
{
"name": "laravel/pennant",
"version": "v1.18.5",
"source": {
"type": "git",
"url": "https://github.com/laravel/pennant.git",
"reference": "c7d824a46b6fa801925dd3b93470382bcc5b2b58"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/pennant/zipball/c7d824a46b6fa801925dd3b93470382bcc5b2b58",
"reference": "c7d824a46b6fa801925dd3b93470382bcc5b2b58",
"shasum": ""
},
"require": {
"illuminate/console": "^10.0|^11.0|^12.0",
"illuminate/container": "^10.0|^11.0|^12.0",
"illuminate/contracts": "^10.0|^11.0|^12.0",
"illuminate/database": "^10.0|^11.0|^12.0",
"illuminate/queue": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"php": "^8.1",
"symfony/console": "^6.0|^7.0",
"symfony/finder": "^6.0|^7.0"
},
"require-dev": {
"laravel/octane": "^1.4|^2.0",
"orchestra/testbench": "^8.36|^9.15|^10.8",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Feature": "Laravel\\Pennant\\Feature"
},
"providers": [
"Laravel\\Pennant\\PennantServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Laravel\\Pennant\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "A simple, lightweight library for managing feature flags.",
"homepage": "https://github.com/laravel/pennant",
"keywords": [
"feature",
"flags",
"laravel",
"pennant"
],
"support": {
"issues": "https://github.com/laravel/pennant/issues",
"source": "https://github.com/laravel/pennant"
},
"time": "2025-11-27T16:22:11+00:00"
},
{
"name": "laravel/prompts",
"version": "v0.3.7",

44
config/pennant.php Normal file
View File

@ -0,0 +1,44 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Pennant Store
|--------------------------------------------------------------------------
|
| Here you will specify the default store that Pennant should use when
| storing and resolving feature flag values. Pennant ships with the
| ability to store flag values in an in-memory array or database.
|
| Supported: "array", "database"
|
*/
'default' => env('PENNANT_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Pennant Stores
|--------------------------------------------------------------------------
|
| Here you may configure each of the stores that should be available to
| Pennant. These stores shall be used to store resolved feature flag
| values - you may configure as many as your application requires.
|
*/
'stores' => [
'array' => [
'driver' => 'array',
],
'database' => [
'driver' => 'database',
'connection' => null,
'table' => 'features',
],
],
];

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Laravel\Pennant\Migrations\PennantMigration;
return new class extends PennantMigration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('features', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('scope');
$table->text('value');
$table->timestamps();
$table->unique(['name', 'scope']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('features');
}
};

View File

@ -11,13 +11,22 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { cn, resolveUrl } from '@/lib/utils';
import { footerNavItems, mainNavItems } from '@/providers/menu-item-provider';
import {
footerNavItems,
getMainNavItems,
} from '@/providers/menu-item-provider';
import { dashboard } from '@/routes';
import { SharedData } from '@/types';
import { Link, usePage } from '@inertiajs/react';
import { useMemo } from 'react';
import AppLogo from './app-logo';
export function AppSidebar() {
const page = usePage();
const page = usePage<SharedData>();
const mainNavItems = useMemo(
() => getMainNavItems(page.props.features),
[page.props.features],
);
return (
<>

View File

@ -0,0 +1,160 @@
import { PercentageTrendIndicator } from '@/components/dashboard/percentage-trend-indicator';
import { AmountDisplay } from '@/components/ui/amount-display';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { BreakdownData } from '@/hooks/use-cashflow-data';
import { cn } from '@/lib/utils';
import { getCategoryColorClasses } from '@/types/category';
import * as Icons from 'lucide-react';
import { LucideIcon } from 'lucide-react';
interface BreakdownCardProps {
type: 'income' | 'expense';
data: BreakdownData;
loading?: boolean;
}
const CHART_COLORS = [
'var(--chart-1)',
'var(--chart-2)',
'var(--chart-3)',
'var(--chart-4)',
'var(--chart-5)',
'var(--chart-6)',
'var(--chart-7)',
'var(--chart-8)',
];
export function BreakdownCard({ type, data, loading }: BreakdownCardProps) {
const title = type === 'income' ? 'Income Sources' : 'Expense Categories';
const description =
type === 'income'
? 'Where your money comes from'
: 'Where your money goes';
const emptyMessage =
type === 'income' ? 'No income this period' : 'No expenses this period';
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="flex items-center gap-3">
<div className="size-6 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="ml-auto h-4 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</div>
<div className="h-2 w-full animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="gap-1 pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{title}</CardTitle>
<AmountDisplay
amountInCents={data.total}
currencyCode="USD"
minimumFractionDigits={0}
maximumFractionDigits={0}
weight="semibold"
highlightPositive
/>
</div>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{data.data.map((item, index) => {
const Icon = (Icons[
item.category.icon as keyof typeof Icons
] || Icons.HelpCircle) as LucideIcon;
const percentageChange =
item.previous_amount > 0
? ((item.amount - item.previous_amount) /
item.previous_amount) *
100
: null;
const categoryColor = getCategoryColorClasses(
item.category.color,
);
const chartColor =
CHART_COLORS[index % CHART_COLORS.length];
return (
<div key={item.category_id} className="space-y-1.5">
<div className="flex min-w-0 items-center gap-2">
<div
className={cn([
'flex size-6 shrink-0 items-center justify-center rounded-full',
`${categoryColor.bg} ${categoryColor.text}`,
])}
>
<Icon className="size-3.5" />
</div>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{item.category.name}
</span>
{percentageChange !== null && (
<PercentageTrendIndicator
trend={percentageChange}
label=""
previousAmount={
item.previous_amount
}
currentAmount={item.amount}
currencyCode="USD"
invertColors={type === 'expense'}
className="shrink-0 text-xs"
/>
)}
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-muted-foreground">
{item.percentage.toFixed(0)}%
</span>
<AmountDisplay
amountInCents={item.amount}
currencyCode="USD"
variant="compact"
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
</div>
</div>
<Progress
value={item.percentage}
className="h-1.5"
indicatorColor={chartColor}
/>
</div>
);
})}
{data.data.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,142 @@
import { AmountDisplay } from '@/components/ui/amount-display';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { CashflowSummary } from '@/hooks/use-cashflow-data';
import { cn } from '@/lib/utils';
import { ArrowDown, ArrowUp, TrendingDown, TrendingUp } from 'lucide-react';
interface NetCashflowCardProps {
current: CashflowSummary;
previous: CashflowSummary;
loading?: boolean;
}
export function NetCashflowCard({
current,
previous,
loading,
}: NetCashflowCardProps) {
if (loading) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Net Cashflow
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-12 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</CardContent>
</Card>
);
}
const isPositive = current.net >= 0;
const diff = current.net - previous.net;
const diffIsPositive = diff >= 0;
const hasPreviousData = previous.income > 0 || previous.expense > 0;
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Net Cashflow
</CardTitle>
<CardDescription>Income minus expenses</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<div className={cn('flex items-center gap-1')}>
{isPositive ? (
<ArrowUp
className={cn(
'size-4',
'text-green-600 dark:text-green-400',
)}
/>
) : (
<ArrowDown
className={cn(
'size-4',
'text-red-600 dark:text-red-400',
)}
/>
)}
<AmountDisplay
amountInCents={Math.abs(current.net)}
currencyCode="USD"
size="2xl"
weight="bold"
minimumFractionDigits={0}
maximumFractionDigits={0}
highlightPositive
/>
</div>
</div>
{hasPreviousData && (
<div className={cn('mt-2 flex items-center gap-1 text-sm')}>
{diffIsPositive ? (
<TrendingUp
className={cn(
'size-4',
'text-green-600 dark:text-green-400',
)}
/>
) : (
<TrendingDown
className={cn(
'size-4',
'text-red-600 dark:text-red-400',
)}
/>
)}
<span>
{diffIsPositive ? '+' : ''}
<AmountDisplay
amountInCents={diff}
currencyCode="USD"
minimumFractionDigits={0}
maximumFractionDigits={0}
className="text-sm"
highlightPositive
/>
</span>
<span className="text-muted-foreground">
vs last period
</span>
</div>
)}
<div className="mt-3 grid grid-cols-2 gap-4 border-t pt-3">
<div>
<p className="text-xs text-muted-foreground">Income</p>
<AmountDisplay
amountInCents={current.income}
currencyCode="USD"
minimumFractionDigits={0}
maximumFractionDigits={0}
weight="medium"
highlightPositive
/>
</div>
<div>
<p className="text-xs text-muted-foreground">
Expenses
</p>
<AmountDisplay
amountInCents={current.expense}
currencyCode="USD"
minimumFractionDigits={0}
maximumFractionDigits={0}
weight="medium"
/>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,58 @@
import { Button } from '@/components/ui/button';
import { addMonths, format, isSameMonth, subMonths } from 'date-fns';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface PeriodNavigationProps {
currentDate: Date;
onDateChange: (date: Date) => void;
}
export function PeriodNavigation({
currentDate,
onDateChange,
}: PeriodNavigationProps) {
const now = new Date();
const isCurrentMonth = isSameMonth(currentDate, now);
const handlePrevMonth = () => {
onDateChange(subMonths(currentDate, 1));
};
const handleNextMonth = () => {
onDateChange(addMonths(currentDate, 1));
};
const handleCurrentMonth = () => {
onDateChange(now);
};
return (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon-sm"
onClick={handlePrevMonth}
aria-label="Previous month"
>
<ChevronLeft className="size-4" />
</Button>
<button
onClick={handleCurrentMonth}
className="min-w-[140px] rounded-md px-3 py-1.5 text-center text-sm font-medium hover:bg-accent"
>
{format(currentDate, 'MMMM yyyy')}
</button>
<Button
variant="outline"
size="icon-sm"
onClick={handleNextMonth}
disabled={isCurrentMonth}
aria-label="Next month"
>
<ChevronRight className="size-4" />
</Button>
</div>
);
}

View File

@ -0,0 +1,96 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { CashflowSummary } from '@/hooks/use-cashflow-data';
import { cn } from '@/lib/utils';
import { TrendingDown, TrendingUp } from 'lucide-react';
interface SavingsRateCardProps {
current: CashflowSummary;
previous: CashflowSummary;
loading?: boolean;
}
export function SavingsRateCard({
current,
previous,
loading,
}: SavingsRateCardProps) {
if (loading) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Savings Rate
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-12 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</CardContent>
</Card>
);
}
const diff = current.savings_rate - previous.savings_rate;
const isPositive = diff >= 0;
const hasPreviousData = previous.income > 0;
// Determine color based on savings rate
const rateColor =
current.savings_rate >= 20
? 'text-green-600 dark:text-green-400'
: current.savings_rate >= 10
? 'text-yellow-600 dark:text-yellow-400'
: current.savings_rate >= 0
? 'text-orange-600 dark:text-orange-400'
: 'text-red-600 dark:text-red-400';
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Savings Rate
</CardTitle>
<CardDescription>Percentage of income saved</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<span
className={cn(
'text-3xl font-bold tabular-nums',
rateColor,
)}
>
{current.savings_rate.toFixed(1)}%
</span>
{hasPreviousData && (
<div className={cn('flex items-center gap-1 text-sm')}>
{isPositive ? (
<TrendingUp className="size-4 text-green-600 dark:text-green-400" />
) : (
<TrendingDown className="size-4 text-red-600 dark:text-red-400" />
)}
<span>
{isPositive ? '+' : ''}
{diff.toFixed(1)}%
</span>
</div>
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">
{current.savings_rate >= 20
? "Great job! You're saving well."
: current.savings_rate >= 10
? 'Good progress on your savings.'
: current.savings_rate >= 0
? 'Consider saving more if possible.'
: 'Spending exceeds income this period.'}
</p>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,260 @@
import { AmountDisplay } from '@/components/ui/amount-display';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
import { TrendDataPoint } from '@/hooks/use-cashflow-data';
import { cn } from '@/lib/utils';
import { useEffect, useRef } from 'react';
import {
Bar,
BarChart,
CartesianGrid,
Line,
ReferenceLine,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface CashflowTrendChartProps {
data: TrendDataPoint[];
loading?: boolean;
className?: string;
}
const chartConfig: ChartConfig = {
income: {
label: 'Income',
color: 'var(--color-chart-2)',
},
expense: {
label: 'Expenses',
color: 'var(--color-chart-5)',
},
net: {
label: 'Net',
color: 'var(--color-chart-1)',
},
};
interface TooltipPayloadItem {
dataKey?: string;
value?: number;
color?: string;
name?: string;
payload?: TrendDataPoint;
}
interface CustomTooltipProps {
active?: boolean;
payload?: TooltipPayloadItem[];
}
function formatMonth(yearMonth: string): string {
const [year, month] = yearMonth.split('-');
const date = new Date(parseInt(year), parseInt(month) - 1);
const isCurrentYear = date.getFullYear() === new Date().getFullYear();
return date.toLocaleDateString(
'en-US',
isCurrentYear
? { month: 'short' }
: { year: '2-digit', month: 'short' },
);
}
function CustomTooltip({ active, payload }: CustomTooltipProps) {
if (!active || !payload?.length) return null;
const data = payload[0].payload;
if (!data) return null;
return (
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-xs shadow-xl">
<div className="mb-2 font-medium">{formatMonth(data.month)}</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-2">
<span className="size-2 rounded-full bg-[var(--color-chart-2)]" />
Income
</span>
<AmountDisplay
amountInCents={data.income}
currencyCode="USD"
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 bg-[var(--color-chart-5)]" />
Expenses
</span>
<AmountDisplay
amountInCents={data.expense}
currencyCode="USD"
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)]" />
Net
</span>
<AmountDisplay
amountInCents={data.net}
currencyCode="USD"
minimumFractionDigits={0}
maximumFractionDigits={0}
className={cn(
'font-mono font-medium tabular-nums',
data.net >= 0 ? 'text-green-600' : 'text-red-600',
)}
/>
</div>
</div>
</div>
);
}
export function CashflowTrendChart({
data,
loading,
className,
}: CashflowTrendChartProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const minChartWidth = data.length * 60;
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollLeft =
scrollContainerRef.current.scrollWidth;
}
}, [data]);
if (loading) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-base">Cashflow Trend</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[250px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</CardContent>
</Card>
);
}
return (
<Card className={className}>
<CardHeader className="gap-1 pb-4">
<CardTitle className="text-base">Cashflow Trend</CardTitle>
<CardDescription>
Monthly income, expenses, and net cashflow over the last 12
months
</CardDescription>
</CardHeader>
<CardContent>
<div ref={scrollContainerRef} className="overflow-x-auto">
<ChartContainer
config={chartConfig}
className="h-[250px] w-full"
style={{ minWidth: `${minChartWidth}px` }}
>
<BarChart accessibilityLayer data={data}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="var(--color-border)"
opacity={0.3}
/>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatMonth}
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(value: number) => {
return new Intl.NumberFormat('en-US', {
notation: 'compact',
compactDisplay: 'short',
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value / 100);
}}
width={60}
/>
<ReferenceLine
y={0}
stroke="var(--color-border)"
strokeDasharray="3 3"
/>
<Tooltip
content={<CustomTooltip />}
cursor={{
fill: 'var(--color-muted)',
opacity: 0.3,
}}
/>
<Bar
dataKey="income"
fill="var(--color-chart-2)"
radius={[4, 4, 0, 0]}
stackId="a"
name="Income"
/>
<Bar
dataKey="expense"
fill="var(--color-chart-5)"
radius={[4, 4, 0, 0]}
stackId="b"
name="Expenses"
/>
<Line
type="monotone"
dataKey="net"
stroke="var(--color-chart-1)"
strokeWidth={2}
dot={{
fill: 'var(--color-chart-1)',
strokeWidth: 0,
r: 3,
}}
activeDot={{ r: 5 }}
name="Net"
/>
</BarChart>
</ChartContainer>
</div>
<div className="mt-4 flex items-center justify-center gap-6 text-xs">
<div className="flex items-center gap-2">
<span className="size-3 rounded bg-[var(--color-chart-2)]" />
<span>Income</span>
</div>
<div className="flex items-center gap-2">
<span className="size-3 rounded bg-[var(--color-chart-5)]" />
<span>Expenses</span>
</div>
<div className="flex items-center gap-2">
<span className="size-3 rounded-full bg-[var(--color-chart-1)]" />
<span>Net</span>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,3 +1,5 @@
export { CashflowTrendChart } from './cashflow-trend-chart';
export { ChartViewToggle } from './chart-view-toggle';
export { MoMChart } from './mom-chart';
export { MoMPercentChart } from './mom-percent-chart';
export { SankeyChart } from './sankey-chart';

View File

@ -0,0 +1,339 @@
import { SankeyData } from '@/hooks/use-cashflow-data';
import { cn } from '@/lib/utils';
import { Category } from '@/types/category';
import { useMemo, useState } from 'react';
interface SankeyChartProps {
data: SankeyData;
height?: number;
className?: string;
}
interface NodeData {
id: string;
label: string;
value: number;
color: string;
y: number;
height: number;
column: 0 | 1 | 2;
category?: Category;
}
interface LinkData {
source: string;
target: string;
value: number;
sourceY: number;
targetY: number;
height: number;
}
const COLUMN_POSITIONS = [0.25, 0.5, 0.75];
const NODE_WIDTH = 12;
const NODE_PADDING = 6;
const MIN_NODE_HEIGHT = 20;
function formatAmount(amountInCents: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amountInCents / 100);
}
export function SankeyChart({
data,
height = 400,
className,
}: SankeyChartProps) {
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const [hoveredLink, setHoveredLink] = useState<string | null>(null);
const { nodes, links, isEmpty } = useMemo(() => {
const {
income_categories,
expense_categories,
total_income,
total_expense,
} = data;
if (total_income === 0 && total_expense === 0) {
return { nodes: [], links: [], isEmpty: true };
}
const nodeMap: Record<string, NodeData> = {};
const linkList: LinkData[] = [];
// Calculate available height for nodes
const availableHeight = height - 40; // padding
const maxTotal = Math.max(total_income, total_expense);
// Create income nodes (left column)
let incomeY = 20;
const incomeNodes = income_categories
.sort((a, b) => b.amount - a.amount)
.map((item) => {
const nodeHeight = Math.max(
MIN_NODE_HEIGHT,
(item.amount / maxTotal) * availableHeight * 0.5,
);
const node: NodeData = {
id: `income-${item.category_id}`,
label: item.category.name,
value: item.amount,
color: item.category.color || 'var(--color-chart-2)',
y: incomeY,
height: nodeHeight,
column: 0,
category: item.category,
};
incomeY += nodeHeight + NODE_PADDING;
return node;
});
// Create center node (total cashflow)
const centerHeight = Math.max(
MIN_NODE_HEIGHT * 1.5,
(Math.max(total_income, total_expense) / maxTotal) *
availableHeight *
0.6,
);
const centerY = (height - centerHeight) / 2;
const centerNode: NodeData = {
id: 'center',
label: 'Cashflow',
value: total_income - total_expense,
color: 'var(--color-chart-1)',
y: centerY,
height: centerHeight,
column: 1,
};
// Create expense nodes (right column)
let expenseY = 20;
const expenseNodes = expense_categories
.sort((a, b) => b.amount - a.amount)
.map((item) => {
const nodeHeight = Math.max(
MIN_NODE_HEIGHT,
(item.amount / maxTotal) * availableHeight * 0.5,
);
const node: NodeData = {
id: `expense-${item.category_id}`,
label: item.category.name,
value: item.amount,
color: item.category.color || 'var(--color-chart-3)',
y: expenseY,
height: nodeHeight,
column: 2,
category: item.category,
};
expenseY += nodeHeight + NODE_PADDING;
return node;
});
// Add all nodes to map
incomeNodes.forEach((n) => (nodeMap[n.id] = n));
nodeMap[centerNode.id] = centerNode;
expenseNodes.forEach((n) => (nodeMap[n.id] = n));
// Create links from income to center
let incomeLinkY = centerY;
incomeNodes.forEach((incomeNode) => {
const linkHeight = (incomeNode.value / total_income) * centerHeight;
linkList.push({
source: incomeNode.id,
target: 'center',
value: incomeNode.value,
sourceY: incomeNode.y + incomeNode.height / 2,
targetY: incomeLinkY + linkHeight / 2,
height: linkHeight,
});
incomeLinkY += linkHeight;
});
// Create links from center to expenses
let expenseLinkY = centerY;
expenseNodes.forEach((expenseNode) => {
const linkHeight =
(expenseNode.value / total_expense) * centerHeight;
linkList.push({
source: 'center',
target: expenseNode.id,
value: expenseNode.value,
sourceY: expenseLinkY + linkHeight / 2,
targetY: expenseNode.y + expenseNode.height / 2,
height: linkHeight,
});
expenseLinkY += linkHeight;
});
return {
nodes: Object.values(nodeMap),
links: linkList,
isEmpty: false,
};
}, [data, height]);
if (isEmpty) {
return (
<div
className={cn(
'flex items-center justify-center text-muted-foreground',
className,
)}
style={{ height }}
>
No cashflow data for this period
</div>
);
}
const width = 600; // SVG viewBox width
return (
<div className={cn('w-full overflow-x-auto', className)}>
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full"
style={{ minWidth: 400, maxWidth: '100%' }}
preserveAspectRatio="xMidYMid meet"
>
{/* Links */}
<g className="links">
{links.map((link) => {
const sourceNode = nodes.find(
(n) => n.id === link.source,
);
const targetNode = nodes.find(
(n) => n.id === link.target,
);
if (!sourceNode || !targetNode) return null;
const sourceX =
COLUMN_POSITIONS[sourceNode.column] * width +
NODE_WIDTH / 2;
const targetX =
COLUMN_POSITIONS[targetNode.column] * width -
NODE_WIDTH / 2;
const linkId = `${link.source}-${link.target}`;
const isHovered =
hoveredLink === linkId ||
hoveredNode === link.source ||
hoveredNode === link.target;
// Create a curved path
const path = `
M ${sourceX} ${link.sourceY - link.height / 2}
C ${(sourceX + targetX) / 2} ${link.sourceY - link.height / 2},
${(sourceX + targetX) / 2} ${link.targetY - link.height / 2},
${targetX} ${link.targetY - link.height / 2}
L ${targetX} ${link.targetY + link.height / 2}
C ${(sourceX + targetX) / 2} ${link.targetY + link.height / 2},
${(sourceX + targetX) / 2} ${link.sourceY + link.height / 2},
${sourceX} ${link.sourceY + link.height / 2}
Z
`;
return (
<path
key={linkId}
d={path}
fill={
sourceNode.column === 0
? 'var(--color-chart-2)'
: 'var(--color-chart-3)'
}
fillOpacity={isHovered ? 0.6 : 0.3}
className="transition-all duration-200"
onMouseEnter={() => setHoveredLink(linkId)}
onMouseLeave={() => setHoveredLink(null)}
/>
);
})}
</g>
{/* Nodes */}
<g className="nodes">
{nodes.map((node) => {
const x =
COLUMN_POSITIONS[node.column] * width -
NODE_WIDTH / 2;
const isHovered = hoveredNode === node.id;
return (
<g
key={node.id}
onMouseEnter={() => setHoveredNode(node.id)}
onMouseLeave={() => setHoveredNode(null)}
className="cursor-pointer"
>
<rect
x={x}
y={node.y}
width={NODE_WIDTH}
height={node.height}
rx={4}
fill={
node.id === 'center'
? 'var(--color-chart-1)'
: node.color
}
fillOpacity={isHovered ? 1 : 0.8}
className="transition-all duration-200"
/>
{/* Label */}
<text
x={
node.column === 0
? x - 6
: node.column === 2
? x + NODE_WIDTH + 6
: x + NODE_WIDTH / 2
}
y={node.y + node.height / 2 - 6}
textAnchor={
node.column === 0
? 'end'
: node.column === 2
? 'start'
: 'middle'
}
dominantBaseline="middle"
className="fill-foreground text-[9px] font-medium"
>
{node.label}
</text>
{/* Amount */}
<text
x={
node.column === 0
? x - 6
: node.column === 2
? x + NODE_WIDTH + 6
: x + NODE_WIDTH / 2
}
y={node.y + node.height / 2 + 6}
textAnchor={
node.column === 0
? 'end'
: node.column === 2
? 'start'
: 'middle'
}
dominantBaseline="middle"
className="fill-muted-foreground text-[9px]"
>
{formatAmount(node.value)}
</text>
</g>
);
})}
</g>
</svg>
</div>
);
}

View File

@ -0,0 +1,172 @@
import { AmountDisplay } from '@/components/ui/amount-display';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { cashflow } from '@/routes';
import { Link } from '@inertiajs/react';
import { endOfMonth, format, startOfMonth } from 'date-fns';
import { ArrowRight, TrendingDown, TrendingUp } from 'lucide-react';
import { useEffect, useState } from 'react';
interface CashflowSummary {
income: number;
expense: number;
net: number;
savings_rate: number;
}
interface CashflowSummaryCardProps {
loading?: boolean;
}
export function CashflowSummaryCard({ loading }: CashflowSummaryCardProps) {
const [data, setData] = useState<{
current: CashflowSummary;
previous: CashflowSummary;
} | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const now = new Date();
const from = format(startOfMonth(now), 'yyyy-MM-dd');
const to = format(endOfMonth(now), 'yyyy-MM-dd');
const params = new URLSearchParams({ from, to });
const response = await fetch(
`/api/cashflow/summary?${params.toString()}`,
);
const result = await response.json();
setData(result);
} catch (error) {
console.error('Failed to fetch cashflow summary:', error);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
if (isLoading || loading) {
return (
<Card className="col-span-3">
<CardHeader>
<CardTitle>Cashflow</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-6 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
if (!data) {
return null;
}
const { current } = data;
const isPositiveNet = current.net >= 0;
return (
<Card className="col-span-3">
<CardHeader className="gap-1">
<div className="flex items-center justify-between">
<CardTitle>Cashflow</CardTitle>
<Link
href={cashflow().url}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
View details
<ArrowRight className="size-4" />
</Link>
</div>
<CardDescription>
This month's income and expenses
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
{/* Income */}
<div>
<p className="text-xs text-muted-foreground">Income</p>
<AmountDisplay
amountInCents={current.income}
currencyCode="USD"
minimumFractionDigits={0}
maximumFractionDigits={0}
weight="semibold"
highlightPositive
/>
</div>
{/* Expenses */}
<div>
<p className="text-xs text-muted-foreground">
Expenses
</p>
<AmountDisplay
amountInCents={current.expense}
currencyCode="USD"
minimumFractionDigits={0}
maximumFractionDigits={0}
weight="semibold"
/>
</div>
{/* Net */}
<div>
<p className="text-xs text-muted-foreground">Net</p>
<div className="flex items-center gap-1">
{isPositiveNet ? (
<TrendingUp className="size-4 text-green-600 dark:text-green-400" />
) : (
<TrendingDown className="size-4 text-red-600 dark:text-red-400" />
)}
<AmountDisplay
amountInCents={Math.abs(current.net)}
currencyCode="USD"
minimumFractionDigits={0}
maximumFractionDigits={0}
weight="semibold"
highlightPositive
/>
</div>
</div>
</div>
{/* Savings rate footer */}
<div className="mt-4 flex items-center justify-between border-t pt-3">
<span className="text-xs text-muted-foreground">
Savings rate
</span>
<span
className={cn(
'text-sm font-medium',
current.savings_rate >= 20
? 'text-green-600 dark:text-green-400'
: current.savings_rate >= 0
? 'text-yellow-600 dark:text-yellow-400'
: 'text-red-600 dark:text-red-400',
)}
>
{current.savings_rate.toFixed(1)}%
</span>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,152 @@
import { Category } from '@/types/category';
import { endOfMonth, format, startOfMonth } from 'date-fns';
import { useCallback, useEffect, useState } from 'react';
export interface CashflowSummary {
income: number;
expense: number;
net: number;
savings_rate: number;
}
export interface SankeyCategory {
category: Category;
category_id: string;
amount: number;
}
export interface SankeyData {
income_categories: SankeyCategory[];
expense_categories: SankeyCategory[];
total_income: number;
total_expense: number;
}
export interface TrendDataPoint {
month: string;
income: number;
expense: number;
net: number;
}
export interface BreakdownItem {
category: Category;
category_id: string;
amount: number;
percentage: number;
previous_amount: number;
}
export interface BreakdownData {
data: BreakdownItem[];
total: number;
previous_total: number;
}
export interface CashflowData {
summary: {
current: CashflowSummary;
previous: CashflowSummary;
};
sankey: SankeyData;
trend: TrendDataPoint[];
incomeBreakdown: BreakdownData;
expenseBreakdown: BreakdownData;
isLoading: boolean;
}
interface UseCashflowDataOptions {
from: Date;
to: Date;
}
const emptyBreakdown: BreakdownData = {
data: [],
total: 0,
previous_total: 0,
};
const emptySummary: CashflowSummary = {
income: 0,
expense: 0,
net: 0,
savings_rate: 0,
};
export function useCashflowData({
from,
to,
}: UseCashflowDataOptions): CashflowData & { refetch: () => void } {
const [data, setData] = useState<Omit<CashflowData, 'isLoading'>>({
summary: { current: emptySummary, previous: emptySummary },
sankey: {
income_categories: [],
expense_categories: [],
total_income: 0,
total_expense: 0,
},
trend: [],
incomeBreakdown: emptyBreakdown,
expenseBreakdown: emptyBreakdown,
});
const [isLoading, setIsLoading] = useState(true);
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const fromStr = format(from, 'yyyy-MM-dd');
const toStr = format(to, 'yyyy-MM-dd');
const periodParams = new URLSearchParams({
from: fromStr,
to: toStr,
});
const periodQuery = `?${periodParams.toString()}`;
const [summary, sankey, trend, incomeBreakdown, expenseBreakdown] =
await Promise.all([
fetch(`/api/cashflow/summary${periodQuery}`).then((r) =>
r.json(),
),
fetch(`/api/cashflow/sankey${periodQuery}`).then((r) =>
r.json(),
),
fetch('/api/cashflow/trend?months=12').then((r) =>
r.json(),
),
fetch(
`/api/cashflow/breakdown${periodQuery}&type=income`,
).then((r) => r.json()),
fetch(
`/api/cashflow/breakdown${periodQuery}&type=expense`,
).then((r) => r.json()),
]);
setData({
summary,
sankey,
trend: trend.data,
incomeBreakdown,
expenseBreakdown,
});
} catch (error) {
console.error('Failed to fetch cashflow data:', error);
} finally {
setIsLoading(false);
}
}, [from, to]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { ...data, isLoading, refetch: fetchData };
}
export function getDefaultPeriod(): { from: Date; to: Date } {
const now = new Date();
return {
from: startOfMonth(now),
to: endOfMonth(now),
};
}

View File

@ -0,0 +1,103 @@
import { BreakdownCard } from '@/components/cashflow/breakdown-card';
import { NetCashflowCard } from '@/components/cashflow/net-cashflow-card';
import { PeriodNavigation } from '@/components/cashflow/period-navigation';
import { SavingsRateCard } from '@/components/cashflow/savings-rate-card';
import { CashflowTrendChart, SankeyChart } from '@/components/charts';
import HeadingSmall from '@/components/heading-small';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useCashflowData } from '@/hooks/use-cashflow-data';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { cashflow } from '@/routes';
import { BreadcrumbItem } from '@/types';
import { Head } from '@inertiajs/react';
import { endOfMonth, startOfMonth } from 'date-fns';
import { useState } from 'react';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Cashflow',
href: cashflow().url,
},
];
export default function CashflowPage() {
const [currentDate, setCurrentDate] = useState(new Date());
const period = {
from: startOfMonth(currentDate),
to: endOfMonth(currentDate),
};
const {
summary,
sankey,
trend,
incomeBreakdown,
expenseBreakdown,
isLoading,
} = useCashflowData(period);
return (
<AppSidebarLayout breadcrumbs={breadcrumbs}>
<Head title="Cashflow" />
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<HeadingSmall
title="Cashflow"
description="Track your income, expenses, and savings"
/>
<PeriodNavigation
currentDate={currentDate}
onDateChange={setCurrentDate}
/>
</div>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-2">
<NetCashflowCard
current={summary.current}
previous={summary.previous}
loading={isLoading}
/>
<SavingsRateCard
current={summary.current}
previous={summary.previous}
loading={isLoading}
/>
</div>
{/* Sankey Diagram */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base">Money Flow</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="h-[400px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
) : (
<SankeyChart data={sankey} height={400} />
)}
</CardContent>
</Card>
{/* Breakdown Cards */}
<div className="grid gap-4 md:grid-cols-2">
<BreakdownCard
type="income"
data={incomeBreakdown}
loading={isLoading}
/>
<BreakdownCard
type="expense"
data={expenseBreakdown}
loading={isLoading}
/>
</div>
{/* Trend Chart */}
<CashflowTrendChart data={trend} loading={isLoading} />
</div>
</AppSidebarLayout>
);
}

View File

@ -1,12 +1,13 @@
import { AccountBalanceCard } from '@/components/dashboard/account-balance-card';
import { CashflowSummaryCard } from '@/components/dashboard/cashflow-summary-card';
import { NetWorthChart as NetWorthChartComponent } from '@/components/dashboard/net-worth-chart';
import { TopCategoriesCard } from '@/components/dashboard/top-categories-card';
import HeadingSmall from '@/components/heading-small';
import { useDashboardData } from '@/hooks/use-dashboard-data';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { dashboard } from '@/routes';
import { BreadcrumbItem } from '@/types';
import { Head } from '@inertiajs/react';
import { BreadcrumbItem, SharedData } from '@/types';
import { Head, usePage } from '@inertiajs/react';
const breadcrumbs: BreadcrumbItem[] = [
{
@ -16,6 +17,7 @@ const breadcrumbs: BreadcrumbItem[] = [
];
export default function Dashboard() {
const { props } = usePage<SharedData>();
const {
netWorthEvolution,
accounts: accountMetrics,
@ -58,11 +60,14 @@ export default function Dashboard() {
))}
</div>
<div className="">
<div className="grid gap-4 md:grid-cols-2">
<TopCategoriesCard
categories={topCategories}
loading={isLoading}
/>
{props.features.cashflow && (
<CashflowSummaryCard loading={isLoading} />
)}
</div>
</div>
</AppSidebarLayout>

View File

@ -1,30 +1,52 @@
import { index as accountsIndex } from '@/actions/App/Http/Controllers/AccountController';
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import DiscordIcon from '@/components/icons/DiscordIcon';
import { dashboard } from '@/routes';
import { NavItem } from '@/types';
import { CreditCard, Github, LayoutGrid, Receipt } from 'lucide-react';
import { cashflow, dashboard } from '@/routes';
import { Features, NavItem } from '@/types';
import {
CreditCard,
Github,
LayoutGrid,
Receipt,
TrendingUp,
} from 'lucide-react';
export const mainNavItems: NavItem[] = [
{
type: 'nav-item',
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
{
type: 'nav-item',
title: 'Accounts',
href: accountsIndex(),
icon: CreditCard,
},
{
type: 'nav-item',
title: 'Transactions',
href: transactionsIndex(),
icon: Receipt,
},
];
export function getMainNavItems(features: Features): NavItem[] {
const items: NavItem[] = [
{
type: 'nav-item',
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
];
if (features.cashflow) {
items.push({
type: 'nav-item',
title: 'Cashflow',
href: cashflow(),
icon: TrendingUp,
});
}
items.push(
{
type: 'nav-item',
title: 'Accounts',
href: accountsIndex(),
icon: CreditCard,
},
{
type: 'nav-item',
title: 'Transactions',
href: transactionsIndex(),
icon: Receipt,
},
);
return items;
}
export const footerNavItems: NavItem[] = [
{

View File

@ -37,6 +37,10 @@ export interface NavDivider {
type: 'divider';
}
export interface Features {
cashflow: boolean;
}
export interface SharedData {
name: string;
appUrl: string;
@ -46,6 +50,7 @@ export interface SharedData {
subscriptionsEnabled: boolean;
pricing: PricingConfig;
sidebarOpen: boolean;
features: Features;
[key: string]: unknown;
}

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\AccountBalanceController;
use App\Http\Controllers\Api\CashflowAnalyticsController;
use App\Http\Controllers\Api\DashboardAnalyticsController;
use App\Http\Controllers\EncryptionController;
use App\Http\Controllers\Sync\AccountBalanceSyncController;
@ -49,4 +50,12 @@ Route::middleware(['web', 'auth'])->group(function () {
Route::get('top-categories', [DashboardAnalyticsController::class, 'topCategories']);
Route::get('account/{account}/balance-evolution', [DashboardAnalyticsController::class, 'accountBalanceEvolution']);
});
// Cashflow Analytics
Route::prefix('cashflow')->group(function () {
Route::get('summary', [CashflowAnalyticsController::class, 'summary']);
Route::get('sankey', [CashflowAnalyticsController::class, 'sankey']);
Route::get('trend', [CashflowAnalyticsController::class, 'trend']);
Route::get('breakdown', [CashflowAnalyticsController::class, 'breakdown']);
});
});

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\AccountController;
use App\Http\Controllers\CashflowController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\OnboardingController;
use App\Http\Controllers\RobotsController;
@ -46,6 +47,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
Route::get('dashboard', DashboardController::class)->name('dashboard');
Route::get('cashflow', CashflowController::class)->name('cashflow');
Route::get('accounts', [AccountController::class, 'index'])->name('accounts.list');
Route::get('accounts/{account}', [AccountController::class, 'show'])->name('accounts.show');

View File

@ -0,0 +1,532 @@
<?php
use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->actingAs($this->user);
});
test('cashflow summary returns income, expense, net, and savings rate', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
]);
$expenseCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Income: $1000
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 100000,
'transaction_date' => now(),
]);
// Expense: $400
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory->id,
'amount' => -40000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk()
->assertJson([
'current' => [
'income' => 100000,
'expense' => 40000,
'net' => 60000,
'savings_rate' => 60.0, // (1000 - 400) / 1000 * 100 = 60%
],
]);
});
test('cashflow summary handles zero income for savings rate', function () {
$expenseCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Only expense, no income
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory->id,
'amount' => -10000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk()
->assertJson([
'current' => [
'income' => 0,
'expense' => 10000,
'net' => -10000,
'savings_rate' => 0, // Avoid division by zero
],
]);
});
test('cashflow summary returns previous period data', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Current period income
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 50000,
'transaction_date' => now(),
]);
// Previous period income
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 30000,
'transaction_date' => now()->subMonthNoOverflow(),
]);
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['current']['income'])->toBe(50000);
expect($data['previous']['income'])->toBe(30000);
});
test('sankey returns income and expense categories with amounts', function () {
$incomeCategory1 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
'name' => 'Salary',
]);
$incomeCategory2 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
'name' => 'Freelance',
]);
$expenseCategory1 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
'name' => 'Housing',
]);
$expenseCategory2 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
'name' => 'Food',
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Income transactions
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory1->id,
'amount' => 500000, // $5000
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory2->id,
'amount' => 100000, // $1000
'transaction_date' => now(),
]);
// Expense transactions
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory1->id,
'amount' => -150000, // $1500
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory2->id,
'amount' => -50000, // $500
'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)->toHaveKeys(['income_categories', 'expense_categories', 'total_income', 'total_expense']);
expect($data['total_income'])->toBe(600000);
expect($data['total_expense'])->toBe(200000);
expect($data['income_categories'])->toHaveCount(2);
expect($data['expense_categories'])->toHaveCount(2);
// Check that categories include required data
$incomeIds = collect($data['income_categories'])->pluck('category.id')->toArray();
expect($incomeIds)->toContain($incomeCategory1->id);
expect($incomeIds)->toContain($incomeCategory2->id);
});
test('cashflow trend returns monthly data for specified months', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
]);
$expenseCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Create transactions for 3 months
for ($i = 0; $i < 3; $i++) {
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 100000 + ($i * 10000),
'transaction_date' => now()->subMonths($i),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory->id,
'amount' => -(50000 + ($i * 5000)),
'transaction_date' => now()->subMonths($i),
]);
}
$response = $this->getJson('/api/cashflow/trend?months=3');
$response->assertOk();
$data = $response->json();
expect($data['data'])->toHaveCount(3);
expect($data['data'][0])->toHaveKeys(['month', 'income', 'expense', 'net']);
});
test('cashflow trend defaults to 12 months', function () {
$response = $this->getJson('/api/cashflow/trend');
$response->assertOk();
$data = $response->json();
expect($data['data'])->toHaveCount(12);
});
test('breakdown returns category amounts with percentages for expenses', function () {
$cat1 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
'name' => 'Housing',
]);
$cat2 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
'name' => 'Food',
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Housing: 60% of total
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $cat1->id,
'amount' => -60000,
'transaction_date' => now(),
]);
// Food: 40% of total
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $cat2->id,
'amount' => -40000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
'type' => 'expense',
]));
$response->assertOk();
$data = $response->json();
expect($data)->toHaveKeys(['data', 'total', 'previous_total']);
expect($data['total'])->toBe(100000);
expect($data['data'])->toHaveCount(2);
// Should be sorted by amount desc (Housing first)
expect($data['data'][0]['category']['name'])->toBe('Housing');
expect($data['data'][0]['amount'])->toBe(60000);
expect($data['data'][0]['percentage'])->toEqual(60.0);
expect($data['data'][0])->toHaveKey('previous_amount');
});
test('breakdown returns category amounts for income', function () {
$cat1 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
'name' => 'Salary',
]);
$cat2 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
'name' => 'Freelance',
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $cat1->id,
'amount' => 500000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $cat2->id,
'amount' => 100000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
'type' => 'income',
]));
$response->assertOk();
$data = $response->json();
expect($data['total'])->toBe(600000);
expect($data['data'])->toHaveCount(2);
expect($data['data'][0]['category']['name'])->toBe('Salary');
});
test('breakdown requires type parameter', function () {
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertUnprocessable();
});
test('cashflow page is accessible', function () {
$user = User::factory()->onboarded()->create();
$this->actingAs($user);
$response = $this->get('/cashflow');
$response->assertOk();
$response->assertInertia(fn ($page) => $page->component('cashflow/index'));
});
test('cashflow page returns categories with type', function () {
$user = User::factory()->onboarded()->create();
$this->actingAs($user);
Category::factory()->create([
'user_id' => $user->id,
'type' => CategoryType::Income,
'name' => 'Salary',
]);
Category::factory()->create([
'user_id' => $user->id,
'type' => CategoryType::Expense,
'name' => 'Food',
]);
$response = $this->get('/cashflow');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('cashflow/index')
->has('categories', 2)
->has('categories.0.type')
);
});
test('cashflow summary includes uncategorized income', function () {
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Uncategorized positive amount = income
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => null,
'amount' => 50000, // $500
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk()
->assertJson([
'current' => [
'income' => 50000,
'expense' => 0,
'net' => 50000,
],
]);
});
test('cashflow summary includes uncategorized expenses', function () {
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Uncategorized negative amount = expense
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => null,
'amount' => -30000, // $300
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk()
->assertJson([
'current' => [
'income' => 0,
'expense' => 30000,
'net' => -30000,
],
]);
});
test('sankey includes unknown income and expense categories', function () {
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Uncategorized income
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => null,
'amount' => 100000,
'transaction_date' => now(),
]);
// Uncategorized expense
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => null,
'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['income_categories'])->toHaveCount(1);
expect($data['expense_categories'])->toHaveCount(1);
expect($data['income_categories'][0]['category']['name'])->toBe('Unknown Income');
expect($data['income_categories'][0]['amount'])->toBe(100000);
expect($data['expense_categories'][0]['category']['name'])->toBe('Unknown Expense');
expect($data['expense_categories'][0]['amount'])->toBe(50000);
});
test('breakdown includes unknown income category', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
'name' => 'Salary',
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Categorized income
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 200000,
'transaction_date' => now(),
]);
// Uncategorized income
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => null,
'amount' => 50000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
'type' => 'income',
]));
$response->assertOk();
$data = $response->json();
expect($data['total'])->toBe(250000);
expect($data['data'])->toHaveCount(2);
$unknownCategory = collect($data['data'])->firstWhere('category.name', 'Unknown Income');
expect($unknownCategory)->not->toBeNull();
expect($unknownCategory['amount'])->toBe(50000);
expect($unknownCategory['percentage'])->toEqual(20.0); // 50k / 250k = 20%
});