diff --git a/app/Console/Commands/Concerns/ResolvesFeatures.php b/app/Console/Commands/Concerns/ResolvesFeatures.php new file mode 100644 index 00000000..d7bcd52a --- /dev/null +++ b/app/Console/Commands/Concerns/ResolvesFeatures.php @@ -0,0 +1,37 @@ +getFilenameWithoutExtension(); + } + + return implode(', ', $features) ?: 'None'; + } +} diff --git a/app/Console/Commands/FeatureDisableCommand.php b/app/Console/Commands/FeatureDisableCommand.php new file mode 100644 index 00000000..48838993 --- /dev/null +++ b/app/Console/Commands/FeatureDisableCommand.php @@ -0,0 +1,52 @@ +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; + } +} diff --git a/app/Console/Commands/FeatureEnableCommand.php b/app/Console/Commands/FeatureEnableCommand.php new file mode 100644 index 00000000..3f5459cb --- /dev/null +++ b/app/Console/Commands/FeatureEnableCommand.php @@ -0,0 +1,52 @@ +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; + } +} diff --git a/app/Features/CashflowFeature.php b/app/Features/CashflowFeature.php new file mode 100644 index 00000000..99eade72 --- /dev/null +++ b/app/Features/CashflowFeature.php @@ -0,0 +1,13 @@ +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; + } +} diff --git a/app/Http/Controllers/CashflowController.php b/app/Http/Controllers/CashflowController.php new file mode 100644 index 00000000..c9183aa1 --- /dev/null +++ b/app/Http/Controllers/CashflowController.php @@ -0,0 +1,43 @@ +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, + ]); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index b64ac4b2..e4e08c45 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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, + ], ]; } } diff --git a/composer.json b/composer.json index 593cd5c6..96ccb74b 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index f35bf1ae..59fa41a5 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/pennant.php b/config/pennant.php new file mode 100644 index 00000000..d03ebfb3 --- /dev/null +++ b/config/pennant.php @@ -0,0 +1,44 @@ + 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', + ], + + ], +]; diff --git a/database/migrations/2025_12_20_154221_create_features_table.php b/database/migrations/2025_12_20_154221_create_features_table.php new file mode 100644 index 00000000..a64eea2f --- /dev/null +++ b/database/migrations/2025_12_20_154221_create_features_table.php @@ -0,0 +1,32 @@ +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'); + } +}; diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 6f8a480b..ee3c7406 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -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(); + const mainNavItems = useMemo( + () => getMainNavItems(page.props.features), + [page.props.features], + ); return ( <> diff --git a/resources/js/components/cashflow/breakdown-card.tsx b/resources/js/components/cashflow/breakdown-card.tsx new file mode 100644 index 00000000..39979ffe --- /dev/null +++ b/resources/js/components/cashflow/breakdown-card.tsx @@ -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 ( + + + {title} + + +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ + + ); + } + + return ( + + +
+ {title} + +
+ {description} +
+ +
+ {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 ( +
+
+
+ +
+ + {item.category.name} + + {percentageChange !== null && ( + + )} +
+ + {item.percentage.toFixed(0)}% + + +
+
+ +
+ ); + })} + {data.data.length === 0 && ( +
+ {emptyMessage} +
+ )} +
+
+
+ ); +} diff --git a/resources/js/components/cashflow/net-cashflow-card.tsx b/resources/js/components/cashflow/net-cashflow-card.tsx new file mode 100644 index 00000000..bae1237e --- /dev/null +++ b/resources/js/components/cashflow/net-cashflow-card.tsx @@ -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 ( + + + + Net Cashflow + + + +
+ + + ); + } + + const isPositive = current.net >= 0; + const diff = current.net - previous.net; + const diffIsPositive = diff >= 0; + const hasPreviousData = previous.income > 0 || previous.expense > 0; + + return ( + + + + Net Cashflow + + Income minus expenses + + +
+
+ {isPositive ? ( + + ) : ( + + )} + +
+
+ {hasPreviousData && ( +
+ {diffIsPositive ? ( + + ) : ( + + )} + + {diffIsPositive ? '+' : ''} + + + + vs last period + +
+ )} +
+
+

Income

+ +
+
+

+ Expenses +

+ +
+
+
+
+ ); +} diff --git a/resources/js/components/cashflow/period-navigation.tsx b/resources/js/components/cashflow/period-navigation.tsx new file mode 100644 index 00000000..cf2ddb89 --- /dev/null +++ b/resources/js/components/cashflow/period-navigation.tsx @@ -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 ( +
+ + + + + +
+ ); +} diff --git a/resources/js/components/cashflow/savings-rate-card.tsx b/resources/js/components/cashflow/savings-rate-card.tsx new file mode 100644 index 00000000..918dc000 --- /dev/null +++ b/resources/js/components/cashflow/savings-rate-card.tsx @@ -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 ( + + + + Savings Rate + + + +
+ + + ); + } + + 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 ( + + + + Savings Rate + + Percentage of income saved + + +
+ + {current.savings_rate.toFixed(1)}% + + {hasPreviousData && ( +
+ {isPositive ? ( + + ) : ( + + )} + + {isPositive ? '+' : ''} + {diff.toFixed(1)}% + +
+ )} +
+

+ {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.'} +

+
+
+ ); +} diff --git a/resources/js/components/charts/cashflow-trend-chart.tsx b/resources/js/components/charts/cashflow-trend-chart.tsx new file mode 100644 index 00000000..9cfba87a --- /dev/null +++ b/resources/js/components/charts/cashflow-trend-chart.tsx @@ -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 ( +
+
{formatMonth(data.month)}
+
+
+ + + Income + + +
+
+ + + Expenses + + +
+
+ + + Net + + = 0 ? 'text-green-600' : 'text-red-600', + )} + /> +
+
+
+ ); +} + +export function CashflowTrendChart({ + data, + loading, + className, +}: CashflowTrendChartProps) { + const scrollContainerRef = useRef(null); + const minChartWidth = data.length * 60; + + useEffect(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollLeft = + scrollContainerRef.current.scrollWidth; + } + }, [data]); + + if (loading) { + return ( + + + Cashflow Trend + + +
+ + + ); + } + + return ( + + + Cashflow Trend + + Monthly income, expenses, and net cashflow over the last 12 + months + + + +
+ + + + + { + return new Intl.NumberFormat('en-US', { + notation: 'compact', + compactDisplay: 'short', + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value / 100); + }} + width={60} + /> + + } + cursor={{ + fill: 'var(--color-muted)', + opacity: 0.3, + }} + /> + + + + + +
+
+
+ + Income +
+
+ + Expenses +
+
+ + Net +
+
+
+
+ ); +} diff --git a/resources/js/components/charts/index.ts b/resources/js/components/charts/index.ts index bb0b8e92..8e0d6209 100644 --- a/resources/js/components/charts/index.ts +++ b/resources/js/components/charts/index.ts @@ -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'; diff --git a/resources/js/components/charts/sankey-chart.tsx b/resources/js/components/charts/sankey-chart.tsx new file mode 100644 index 00000000..fa4428d6 --- /dev/null +++ b/resources/js/components/charts/sankey-chart.tsx @@ -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(null); + const [hoveredLink, setHoveredLink] = useState(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 = {}; + 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 ( +
+ No cashflow data for this period +
+ ); + } + + const width = 600; // SVG viewBox width + + return ( +
+ + {/* 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 ( + setHoveredLink(linkId)} + onMouseLeave={() => setHoveredLink(null)} + /> + ); + })} + + + {/* Nodes */} + + {nodes.map((node) => { + const x = + COLUMN_POSITIONS[node.column] * width - + NODE_WIDTH / 2; + const isHovered = hoveredNode === node.id; + + return ( + setHoveredNode(node.id)} + onMouseLeave={() => setHoveredNode(null)} + className="cursor-pointer" + > + + {/* Label */} + + {node.label} + + {/* Amount */} + + {formatAmount(node.value)} + + + ); + })} + + +
+ ); +} diff --git a/resources/js/components/dashboard/cashflow-summary-card.tsx b/resources/js/components/dashboard/cashflow-summary-card.tsx new file mode 100644 index 00000000..9fe8147b --- /dev/null +++ b/resources/js/components/dashboard/cashflow-summary-card.tsx @@ -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 ( + + + Cashflow + + +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+ ))} +
+ + + ); + } + + if (!data) { + return null; + } + + const { current } = data; + const isPositiveNet = current.net >= 0; + + return ( + + +
+ Cashflow + + View details + + +
+ + This month's income and expenses + +
+ +
+ {/* Income */} +
+

Income

+ +
+ + {/* Expenses */} +
+

+ Expenses +

+ +
+ + {/* Net */} +
+

Net

+
+ {isPositiveNet ? ( + + ) : ( + + )} + +
+
+
+ + {/* Savings rate footer */} +
+ + 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)}% + +
+
+
+ ); +} diff --git a/resources/js/hooks/use-cashflow-data.ts b/resources/js/hooks/use-cashflow-data.ts new file mode 100644 index 00000000..fc880947 --- /dev/null +++ b/resources/js/hooks/use-cashflow-data.ts @@ -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>({ + 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), + }; +} diff --git a/resources/js/pages/cashflow/index.tsx b/resources/js/pages/cashflow/index.tsx new file mode 100644 index 00000000..e7392e1f --- /dev/null +++ b/resources/js/pages/cashflow/index.tsx @@ -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 ( + + + +
+
+ + +
+ + {/* Summary Cards */} +
+ + +
+ + {/* Sankey Diagram */} + + + Money Flow + + + {isLoading ? ( +
+ ) : ( + + )} + + + + {/* Breakdown Cards */} +
+ + +
+ + {/* Trend Chart */} + +
+ + ); +} diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 8ea4df69..5e10ff56 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -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(); const { netWorthEvolution, accounts: accountMetrics, @@ -58,11 +60,14 @@ export default function Dashboard() { ))}
-
+
+ {props.features.cashflow && ( + + )}
diff --git a/resources/js/providers/menu-item-provider.tsx b/resources/js/providers/menu-item-provider.tsx index 28fd380e..fb427a6e 100644 --- a/resources/js/providers/menu-item-provider.tsx +++ b/resources/js/providers/menu-item-provider.tsx @@ -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[] = [ { diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 2c1e51d6..d2b1b2b0 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -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; } diff --git a/routes/api.php b/routes/api.php index 4ccb8945..cad8bd69 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ 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']); + }); }); diff --git a/routes/web.php b/routes/web.php index fcc5272d..d8e4cb7f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ 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'); diff --git a/tests/Feature/CashflowAnalyticsTest.php b/tests/Feature/CashflowAnalyticsTest.php new file mode 100644 index 00000000..84023c31 --- /dev/null +++ b/tests/Feature/CashflowAnalyticsTest.php @@ -0,0 +1,532 @@ +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% +});