feat(cashflow): add savings and period views (#424)

## Summary
- add savings and investment category types and migrate default EN/ES
categories
- show saved and invested amounts on cashflow summary
- add month, quarter, and year cashflow period views

## Validation
- vendor/bin/pint --dirty --format agent
- php -l changed PHP files
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
--filter='migration updates existing default saving and investment
categories'
- npm run test -- --run resources/js/lib/chart-calculations.test.ts

## Notes
<img width="1245" height="464" alt="image"
src="https://github.com/user-attachments/assets/7456e018-fb16-4387-8989-aa0b104a42d0"
/>

- Broadened migration after read-only prod check found legacy default
categories still using expense/hidden.
This commit is contained in:
Víctor Falcón 2026-05-25 16:41:00 +02:00 committed by GitHub
parent af661f72f2
commit ed737db7b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 810 additions and 129 deletions

View File

@ -3,6 +3,7 @@
namespace App\Actions;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\Category;
use App\Models\User;
use Illuminate\Support\Str;
@ -280,22 +281,19 @@ class CreateDefaultCategories
'name' => 'Investments',
'icon' => 'LineChart',
'color' => 'lime',
'type' => 'transfer',
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
'type' => CategoryType::Investment->value,
],
[
'name' => 'Savings',
'icon' => 'PiggyBank',
'color' => 'lime',
'type' => 'transfer',
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
'type' => CategoryType::Savings->value,
],
[
'name' => 'Other investments',
'icon' => 'TrendingUp',
'color' => 'lime',
'type' => 'transfer',
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
'type' => CategoryType::Investment->value,
],
[
'name' => 'Financial services and commission',

View File

@ -7,6 +7,8 @@ enum CategoryType: string
case Income = 'income';
case Expense = 'expense';
case Transfer = 'transfer';
case Savings = 'savings';
case Investment = 'investment';
public function label(): string
{
@ -14,6 +16,8 @@ enum CategoryType: string
self::Income => 'Income',
self::Expense => 'Expense',
self::Transfer => 'Transfer',
self::Savings => 'Savings',
self::Investment => 'Investment',
};
}
}

View File

@ -65,16 +65,23 @@ class CashflowAnalyticsController extends Controller
{
$validated = $request->validate([
'months' => 'nullable|integer|min:1|max:24',
'from' => 'nullable|date',
'to' => 'nullable|date',
]);
$months = $validated['months'] ?? 12;
$user = $request->user();
$end = isset($validated['to'])
? Carbon::parse($validated['to'])->endOfMonth()
: Carbon::now()->endOfMonth();
$start = $end->copy()->subMonthsNoOverflow($months - 1)->startOfMonth();
if (isset($validated['from'], $validated['to'])) {
$start = Carbon::parse($validated['from'])->startOfMonth();
$end = Carbon::parse($validated['to'])->endOfMonth();
} else {
$months = $validated['months'] ?? 12;
$end = isset($validated['to'])
? Carbon::parse($validated['to'])->endOfMonth()
: Carbon::now()->endOfMonth();
$start = $end->copy()->subMonthsNoOverflow($months - 1)->startOfMonth();
}
$monthlyTotals = $this->getMonthlyTrendTotals($user->id, $user->currency_code, $start, $end);
$data = [];
@ -174,6 +181,8 @@ class CashflowAnalyticsController extends Controller
{
$income = $this->sumTransactions($transactions, $userCurrency, CategoryType::Income);
$expense = abs($this->sumTransactions($transactions, $userCurrency, CategoryType::Expense));
$savings = $this->sumOutflowTransactions($transactions, $userCurrency, CategoryType::Savings);
$investments = $this->sumOutflowTransactions($transactions, $userCurrency, CategoryType::Investment);
$net = $income - $expense;
$savingsRate = $income > 0 ? round((($income - $expense) / $income) * 100, 1) : 0;
@ -183,6 +192,8 @@ class CashflowAnalyticsController extends Controller
'expense' => $expense,
'net' => $net,
'savings_rate' => $savingsRate,
'savings' => $savings,
'investments' => $investments,
];
}
@ -200,6 +211,14 @@ class CashflowAnalyticsController extends Controller
->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency));
}
private function sumOutflowTransactions(Collection $transactions, string $userCurrency, CategoryType $type): int
{
return abs($transactions
->filter(fn (Transaction $transaction): bool => $this->categoryType($transaction) === $type
&& $transaction->amount < 0)
->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)));
}
private function getSankeyBreakdown(string $userId, string $userCurrency, Carbon $from, Carbon $to, string $operator): Collection
{
$isIncome = $operator === '>';

View File

@ -34,16 +34,35 @@ class CashflowController extends Controller
->orderBy('name')
->get(['id', 'name', 'logo']);
$periodType = $request->query('period_type');
$validPeriodType = is_string($periodType) && in_array($periodType, ['month', 'quarter', 'year'], true)
? $periodType
: 'month';
$period = $request->query('period');
$validPeriod = is_string($period) && preg_match('/^\d{4}-\d{2}$/', $period) === 1
? $period
: null;
$validPeriod = $this->validPeriod($period, $validPeriodType);
return Inertia::render('cashflow/index', [
'categories' => $categories,
'accounts' => $accounts,
'banks' => $banks,
'period' => $validPeriod,
'periodType' => $validPeriodType,
]);
}
private function validPeriod(mixed $period, string $periodType): ?string
{
if (! is_string($period)) {
return null;
}
$pattern = match ($periodType) {
'quarter' => '/^\d{4}-Q[1-4]$/',
'year' => '/^\d{4}$/',
default => '/^\d{4}-\d{2}$/',
};
return preg_match($pattern, $period) === 1 ? $period : null;
}
}

View File

@ -19,8 +19,8 @@ class PeriodComparator
if ($this->isFullMonthRange()) {
$months = $this->from->diffInMonths($this->to->copy()->addDay());
$previousFrom = $this->from->copy()->subMonths($months);
$previousTo = $previousFrom->copy()->endOfMonth();
$previousFrom = $this->from->copy()->subMonthsNoOverflow($months);
$previousTo = $previousFrom->copy()->addMonthsNoOverflow($months - 1)->endOfMonth();
return new self($previousFrom, $previousTo);
}

View File

@ -0,0 +1,70 @@
<?php
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('categories')
->where(function (Builder $query): void {
$query->where(function (Builder $query): void {
$query->whereIn('name', ['Investments', 'Inversiones'])
->where('icon', 'LineChart');
})->orWhere(function (Builder $query): void {
$query->whereIn('name', ['Other investments', 'Otras inversiones'])
->where('icon', 'TrendingUp');
});
})
->update([
'type' => CategoryType::Investment->value,
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
]);
DB::table('categories')
->whereIn('name', ['Savings', 'Ahorros'])
->where('icon', 'PiggyBank')
->update([
'type' => CategoryType::Savings->value,
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::table('categories')
->where('type', CategoryType::Investment->value)
->where(function (Builder $query): void {
$query->where(function (Builder $query): void {
$query->whereIn('name', ['Investments', 'Inversiones'])
->where('icon', 'LineChart');
})->orWhere(function (Builder $query): void {
$query->whereIn('name', ['Other investments', 'Otras inversiones'])
->where('icon', 'TrendingUp');
});
})
->update([
'type' => CategoryType::Transfer->value,
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
]);
DB::table('categories')
->where('type', CategoryType::Savings->value)
->whereIn('name', ['Savings', 'Ahorros'])
->where('icon', 'PiggyBank')
->update([
'type' => CategoryType::Transfer->value,
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
]);
}
};

View File

@ -869,10 +869,12 @@
"Money coming into an account from a source (e.g., salary, refunds, interest). Increases your balance.": "Dinero que entra en una cuenta desde una fuente (ej., salario, reembolsos, intereses). Aumenta tu balance.",
"Money going out of an account to pay for something (e.g., groceries, rent, subscriptions). Decreases your balance.": "Dinero que sale de una cuenta para pagar algo (ej., supermercado, alquiler, suscripciones). Disminuye tu balance.",
"Month": "Mes",
"Quarter": "Trimestre",
"Monthly": "Mensual",
"Monthly Payment": "Pago Mensual",
"Monthly code": "Código mensual",
"Monthly income, expenses, and net cashflow over the last 12 months": "Ingresos, gastos y flujo de efectivo neto mensual durante los últimos 12 meses",
"Monthly income, expenses, and net cashflow for the selected period": "Ingresos, gastos y flujo de efectivo neto mensual para el período seleccionado",
"Monthly income, expenses, tracked transfers, and net cashflow over the last 12 months": "Ingresos, gastos, transferencias seguidas y flujo de caja neto mensual durante los ultimos 12 meses",
"More": "Más",
"More actions": "Más acciones",
@ -1063,6 +1065,7 @@
"Protect your privacy with no tracking or third-party analytics.": "Protege tu privacidad sin rastreo ni análisis de terceros.",
"Purchase Date": "Fecha de Compra",
"Purchase Price": "Precio de Compra",
"Q": "T",
"Quartely": "Cada 4 meses",
"Rate limit exceeded. Please wait a few minutes and try again.": "Límite de solicitudes superado. Espera unos minutos e inténtalo de nuevo.",
"Re-evaluate rules": "Reevaluar reglas",

View File

@ -1,60 +1,166 @@
import { Button } from '@/components/ui/button';
import { ButtonGroup } from '@/components/ui/button-group';
import { CashflowPeriodType } from '@/hooks/use-cashflow-data';
import { useLocale } from '@/hooks/use-locale';
import { formatMonthYear } from '@/utils/date';
import { cn } from '@/lib/utils';
import { formatDate, formatMonthYear } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { addMonths, isSameMonth, subMonths } from 'date-fns';
import {
addMonths,
addQuarters,
addYears,
getQuarter,
isSameMonth,
isSameQuarter,
isSameYear,
startOfMonth,
startOfQuarter,
startOfYear,
subMonths,
subQuarters,
subYears,
} from 'date-fns';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface PeriodNavigationProps {
currentDate: Date;
periodType: CashflowPeriodType;
onDateChange: (date: Date) => void;
onPeriodTypeChange: (periodType: CashflowPeriodType) => void;
}
const periodTypeOptions: Array<{
value: CashflowPeriodType;
label: string;
}> = [
{ value: 'month', label: __('Month') },
{ value: 'quarter', label: __('Quarter') },
{ value: 'year', label: __('Year') },
];
export function PeriodNavigation({
currentDate,
periodType,
onDateChange,
onPeriodTypeChange,
}: PeriodNavigationProps) {
const locale = useLocale();
const now = new Date();
const isCurrentMonth = isSameMonth(currentDate, now);
const isCurrentPeriod = samePeriod(currentDate, now, periodType);
const handlePrevMonth = () => {
onDateChange(subMonths(currentDate, 1));
const handlePreviousPeriod = () => {
onDateChange(shiftPeriod(currentDate, periodType, -1));
};
const handleNextMonth = () => {
onDateChange(addMonths(currentDate, 1));
const handleNextPeriod = () => {
onDateChange(shiftPeriod(currentDate, periodType, 1));
};
const handleCurrentMonth = () => {
const handleCurrentPeriod = () => {
onDateChange(now);
};
return (
<ButtonGroup>
<Button
variant="outline"
size="icon"
onClick={handlePrevMonth}
aria-label={__('Previous month')}
>
<ChevronLeft className="size-4" />
</Button>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<ButtonGroup>
{periodTypeOptions.map((option) => (
<Button
key={option.value}
type="button"
variant={
periodType === option.value ? 'default' : 'outline'
}
onClick={() => onPeriodTypeChange(option.value)}
className={cn(
periodType === option.value &&
'border-primary bg-primary text-primary-foreground',
)}
>
{option.label}
</Button>
))}
</ButtonGroup>
<Button onClick={handleCurrentMonth} variant="outline">
{formatMonthYear(currentDate, locale)}
</Button>
<ButtonGroup>
<Button
variant="outline"
size="icon"
onClick={handlePreviousPeriod}
aria-label={__('Previous period')}
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={handleNextMonth}
disabled={isCurrentMonth}
aria-label={__('Next month')}
>
<ChevronRight className="size-4" />
</Button>
</ButtonGroup>
<Button onClick={handleCurrentPeriod} variant="outline">
{formatPeriodLabel(currentDate, periodType, locale)}
</Button>
<Button
variant="outline"
size="icon"
onClick={handleNextPeriod}
disabled={isCurrentPeriod}
aria-label={__('Next period')}
>
<ChevronRight className="size-4" />
</Button>
</ButtonGroup>
</div>
);
}
function shiftPeriod(
date: Date,
periodType: CashflowPeriodType,
amount: 1 | -1,
): Date {
if (periodType === 'quarter') {
const quarterStart = startOfQuarter(date);
return amount > 0
? addQuarters(quarterStart, 1)
: subQuarters(quarterStart, 1);
}
if (periodType === 'year') {
const yearStart = startOfYear(date);
return amount > 0 ? addYears(yearStart, 1) : subYears(yearStart, 1);
}
const monthStart = startOfMonth(date);
return amount > 0 ? addMonths(monthStart, 1) : subMonths(monthStart, 1);
}
function samePeriod(
left: Date,
right: Date,
periodType: CashflowPeriodType,
): boolean {
if (periodType === 'quarter') {
return isSameQuarter(left, right);
}
if (periodType === 'year') {
return isSameYear(left, right);
}
return isSameMonth(left, right);
}
function formatPeriodLabel(
date: Date,
periodType: CashflowPeriodType,
locale: string,
): string {
if (periodType === 'quarter') {
return `${__('Q')}${getQuarter(date)} ${formatDate(date, 'yyyy', locale)}`;
}
if (periodType === 'year') {
return formatDate(date, 'yyyy', locale);
}
return formatMonthYear(date, locale);
}

View File

@ -1,3 +1,4 @@
import { AmountDisplay } from '@/components/ui/amount-display';
import {
Card,
CardContent,
@ -14,12 +15,14 @@ interface SavingsRateCardProps {
current: CashflowSummary;
previous: CashflowSummary;
loading?: boolean;
currency?: string;
}
export function SavingsRateCard({
current,
previous,
loading,
currency = 'USD',
}: SavingsRateCardProps) {
if (loading) {
return (
@ -84,15 +87,34 @@ export function SavingsRateCard({
</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>
<div className="mt-3 grid grid-cols-2 gap-4 border-t pt-3">
<div>
<p className="text-xs text-muted-foreground">
{__('Saved')}
</p>
<AmountDisplay
amountInCents={current.savings}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
weight="medium"
highlightPositive
/>
</div>
<div>
<p className="text-xs text-muted-foreground">
{__('Invested')}
</p>
<AmountDisplay
amountInCents={current.investments}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
weight="medium"
highlightPositive
/>
</div>
</div>
</CardContent>
</Card>
);

View File

@ -27,6 +27,7 @@ import {
CATEGORY_TYPES,
getCategoryColorClasses,
getCategoryTypeLabel,
type CategoryType,
} from '@/types/category';
import { __ } from '@/utils/i18n';
import { Form } from '@inertiajs/react';
@ -40,7 +41,7 @@ export function CreateCategoryDialog({
onSuccess?: () => void;
}) {
const [open, setOpen] = useState(false);
const [selectedType, setSelectedType] = useState<string>('');
const [selectedType, setSelectedType] = useState<CategoryType | ''>('');
return (
<Dialog open={open} onOpenChange={setOpen}>
@ -157,7 +158,9 @@ export function CreateCategoryDialog({
<Select
name="type"
required
onValueChange={setSelectedType}
onValueChange={(value) =>
setSelectedType(value as CategoryType)
}
>
<SelectTrigger>
<SelectValue
@ -190,13 +193,7 @@ export function CreateCategoryDialog({
</div>
<CategoryCashflowDirectionFields
selectedType={
selectedType as
| 'income'
| 'expense'
| 'transfer'
| ''
}
selectedType={selectedType}
/>
<div className="flex justify-end gap-2">

View File

@ -26,6 +26,7 @@ import {
getCategoryColorClasses,
getCategoryTypeLabel,
type Category,
type CategoryType,
} from '@/types/category';
import { __ } from '@/utils/i18n';
import { Form } from '@inertiajs/react';
@ -46,7 +47,9 @@ export function EditCategoryDialog({
onOpenChange,
onSuccess,
}: EditCategoryDialogProps) {
const [selectedType, setSelectedType] = useState<string>(category.type);
const [selectedType, setSelectedType] = useState<CategoryType>(
category.type,
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -168,7 +171,9 @@ export function EditCategoryDialog({
name="type"
defaultValue={category.type}
required
onValueChange={setSelectedType}
onValueChange={(value) =>
setSelectedType(value as CategoryType)
}
>
<SelectTrigger>
<SelectValue
@ -201,12 +206,7 @@ export function EditCategoryDialog({
</div>
<CategoryCashflowDirectionFields
selectedType={
selectedType as
| 'income'
| 'expense'
| 'transfer'
}
selectedType={selectedType}
defaultValue={category.cashflow_direction}
/>

View File

@ -7,7 +7,7 @@ import {
CardTitle,
} from '@/components/ui/card';
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
import { TrendDataPoint } from '@/hooks/use-cashflow-data';
import { CashflowPeriodType, TrendDataPoint } from '@/hooks/use-cashflow-data';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
@ -30,6 +30,7 @@ interface CashflowTrendChartProps {
loading?: boolean;
className?: string;
currency?: string;
periodType?: CashflowPeriodType;
}
interface TooltipPayloadItem {
@ -126,6 +127,7 @@ export function CashflowTrendChart({
loading,
className,
currency = 'USD',
periodType = 'month',
}: CashflowTrendChartProps) {
const locale = useLocale();
const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -176,9 +178,13 @@ export function CashflowTrendChart({
{__('Cashflow Trend')}
</CardTitle>
<CardDescription>
{__(
'Monthly income, expenses, and net cashflow over the last 12 months',
)}
{periodType === 'month'
? __(
'Monthly income, expenses, and net cashflow over the last 12 months',
)
: __(
'Monthly income, expenses, and net cashflow for the selected period',
)}
</CardDescription>
</CardHeader>
<CardContent>

View File

@ -19,7 +19,7 @@ import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { format } from 'date-fns';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
interface SankeyChartProps {
data: SankeyData;
@ -55,6 +55,8 @@ const COLUMN_POSITIONS = [0.25, 0.5, 0.75];
const NODE_WIDTH = 8;
const NODE_PADDING = 6;
const MIN_NODE_HEIGHT = 20;
const MIN_RENDERED_WIDTH = 400;
const MAX_RENDERED_WIDTH = 800;
interface OtherCategoriesBreakdownProps {
categories: SankeyCategory[];
@ -150,6 +152,8 @@ export function SankeyChart({
}: SankeyChartProps) {
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const [hoveredLink, setHoveredLink] = useState<string | null>(null);
const [renderedWidth, setRenderedWidth] = useState(MAX_RENDERED_WIDTH);
const containerRef = useRef<HTMLDivElement>(null);
const locale = useLocale();
const { isPrivacyModeEnabled } = usePrivacyMode();
@ -158,6 +162,38 @@ export function SankeyChart({
return isPrivacyModeEnabled ? formatted.replace(/\d/g, '*') : formatted;
};
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const updateWidth = () => {
setRenderedWidth(
Math.round(
Math.min(
MAX_RENDERED_WIDTH,
Math.max(MIN_RENDERED_WIDTH, container.clientWidth),
),
),
);
};
updateWidth();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateWidth);
return () => window.removeEventListener('resize', updateWidth);
}
const observer = new ResizeObserver(updateWidth);
observer.observe(container);
return () => observer.disconnect();
}, []);
const { nodes, links, isEmpty, otherGroups } = useMemo(() => {
const {
income_categories,
@ -357,14 +393,17 @@ export function SankeyChart({
);
}
const width = 600; // SVG viewBox width
const width = renderedWidth;
return (
<div className={cn('w-full overflow-x-auto', className)}>
<div
ref={containerRef}
className={cn('w-full overflow-x-auto', className)}
>
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full"
style={{ minWidth: 400, maxWidth: '100%' }}
className="mx-auto block"
style={{ width: renderedWidth, minWidth: MIN_RENDERED_WIDTH }}
preserveAspectRatio="xMidYMid meet"
>
{/* Links */}
@ -536,7 +575,7 @@ export function SankeyChart({
: 'middle'
}
dominantBaseline="middle"
className="fill-foreground text-[9px] font-medium"
className="fill-foreground text-[11px] font-medium"
>
{node.label}
{isOtherNode && (
@ -564,7 +603,7 @@ export function SankeyChart({
: 'middle'
}
dominantBaseline="middle"
className="fill-muted-foreground text-[9px]"
className="fill-muted-foreground text-[11px]"
>
{maskIfPrivate(node.value)}
</text>

View File

@ -2,11 +2,15 @@ import { Category } from '@/types/category';
import { endOfMonth, format, startOfMonth } from 'date-fns';
import { useCallback, useEffect, useState } from 'react';
export type CashflowPeriodType = 'month' | 'quarter' | 'year';
export interface CashflowSummary {
income: number;
expense: number;
net: number;
savings_rate: number;
savings: number;
investments: number;
}
export interface SankeyCategory {
@ -58,6 +62,7 @@ export interface CashflowData {
interface UseCashflowDataOptions {
from: Date;
to: Date;
periodType: CashflowPeriodType;
}
const emptyBreakdown: BreakdownData = {
@ -71,11 +76,14 @@ const emptySummary: CashflowSummary = {
expense: 0,
net: 0,
savings_rate: 0,
savings: 0,
investments: 0,
};
export function useCashflowData({
from,
to,
periodType,
}: UseCashflowDataOptions): CashflowData & { refetch: () => void } {
const [data, setData] = useState<Omit<CashflowData, 'isLoading'>>({
summary: { current: emptySummary, previous: emptySummary },
@ -102,6 +110,8 @@ export function useCashflowData({
to: toStr,
});
const periodQuery = `?${periodParams.toString()}`;
const trendQuery =
periodType === 'month' ? `?months=12&to=${toStr}` : periodQuery;
const [summary, sankey, trend, incomeBreakdown, expenseBreakdown] =
await Promise.all([
@ -111,8 +121,8 @@ export function useCashflowData({
fetch(`/api/cashflow/sankey${periodQuery}`).then((r) =>
r.json(),
),
fetch(`/api/cashflow/trend?months=12&to=${toStr}`).then(
(r) => r.json(),
fetch(`/api/cashflow/trend${trendQuery}`).then((r) =>
r.json(),
),
fetch(
`/api/cashflow/breakdown${periodQuery}&type=income`,
@ -134,7 +144,7 @@ export function useCashflowData({
} finally {
setIsLoading(false);
}
}, [from, to]);
}, [from, periodType, to]);
useEffect(() => {
fetchData();

View File

@ -5,13 +5,23 @@ 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 { CashflowPeriodType, useCashflowData } from '@/hooks/use-cashflow-data';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { cashflow } from '@/routes';
import { BreadcrumbItem } from '@/types';
import { __ } from '@/utils/i18n';
import { Head, router, usePage } from '@inertiajs/react';
import { endOfMonth, format, parse, startOfMonth } from 'date-fns';
import {
endOfMonth,
endOfQuarter,
endOfYear,
format,
getQuarter,
parse,
startOfMonth,
startOfQuarter,
startOfYear,
} from 'date-fns';
import { useEffect, useState } from 'react';
const breadcrumbs: BreadcrumbItem[] = [
@ -21,34 +31,101 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
export default function CashflowPage() {
const { auth, period: initialPeriod } = usePage<{
auth: { user: { currency_code: string } };
period: string | null;
}>().props;
function parsePeriodParam(
period: string | null,
periodType: CashflowPeriodType,
): Date {
if (!period) {
return new Date();
}
// Initialize currentDate from server-provided period prop or default to current month
const [currentDate, setCurrentDate] = useState<Date>(() => {
if (initialPeriod) {
try {
// Parse YYYY-MM format
const parsedDate = parse(initialPeriod, 'yyyy-MM', new Date());
// Validate it's a valid date
if (!isNaN(parsedDate.getTime())) {
return parsedDate;
}
} catch {
// Fall through to default
try {
if (periodType === 'quarter') {
const match = /^(\d{4})-Q([1-4])$/.exec(period);
if (match) {
return new Date(Number(match[1]), (Number(match[2]) - 1) * 3);
}
}
return new Date();
});
if (periodType === 'year') {
const match = /^(\d{4})$/.exec(period);
const period = {
if (match) {
return new Date(Number(match[1]), 0);
}
}
const parsedDate = parse(period, 'yyyy-MM', new Date());
if (!isNaN(parsedDate.getTime())) {
return parsedDate;
}
} catch {
return new Date();
}
return new Date();
}
function getPeriodRange(
currentDate: Date,
periodType: CashflowPeriodType,
): { from: Date; to: Date } {
if (periodType === 'quarter') {
return {
from: startOfQuarter(currentDate),
to: endOfQuarter(currentDate),
};
}
if (periodType === 'year') {
return {
from: startOfYear(currentDate),
to: endOfYear(currentDate),
};
}
return {
from: startOfMonth(currentDate),
to: endOfMonth(currentDate),
};
}
function formatPeriodParam(
currentDate: Date,
periodType: CashflowPeriodType,
): string {
if (periodType === 'quarter') {
return `${format(currentDate, 'yyyy')}-Q${getQuarter(currentDate)}`;
}
if (periodType === 'year') {
return format(currentDate, 'yyyy');
}
return format(currentDate, 'yyyy-MM');
}
export default function CashflowPage() {
const {
auth,
period: initialPeriod,
periodType: initialPeriodType,
} = usePage<{
auth: { user: { currency_code: string } };
period: string | null;
periodType: CashflowPeriodType;
}>().props;
const [periodType, setPeriodType] =
useState<CashflowPeriodType>(initialPeriodType);
const [currentDate, setCurrentDate] = useState<Date>(() =>
parsePeriodParam(initialPeriod, initialPeriodType),
);
const period = getPeriodRange(currentDate, periodType);
const {
summary,
@ -57,21 +134,24 @@ export default function CashflowPage() {
incomeBreakdown,
expenseBreakdown,
isLoading,
} = useCashflowData(period);
} = useCashflowData({ ...period, periodType });
// Update URL when currentDate changes
useEffect(() => {
const periodParam = format(currentDate, 'yyyy-MM');
const periodParam = formatPeriodParam(currentDate, periodType);
// Only update if the period has changed
if (initialPeriod !== periodParam) {
router.visit(cashflow({ query: { period: periodParam } }).url, {
preserveScroll: true,
preserveState: true,
replace: true,
});
if (initialPeriod !== periodParam || initialPeriodType !== periodType) {
router.visit(
cashflow({
query: { period: periodParam, period_type: periodType },
}).url,
{
preserveScroll: true,
preserveState: true,
replace: true,
},
);
}
}, [currentDate, initialPeriod]);
}, [currentDate, initialPeriod, initialPeriodType, periodType]);
return (
<AppSidebarLayout breadcrumbs={breadcrumbs}>
@ -88,7 +168,9 @@ export default function CashflowPage() {
<PeriodNavigation
currentDate={currentDate}
periodType={periodType}
onDateChange={setCurrentDate}
onPeriodTypeChange={setPeriodType}
/>
</div>
@ -105,6 +187,7 @@ export default function CashflowPage() {
current={summary.current}
previous={summary.previous}
loading={isLoading}
currency={auth.user.currency_code}
/>
</div>
@ -113,6 +196,7 @@ export default function CashflowPage() {
data={trend}
loading={isLoading}
currency={auth.user.currency_code}
periodType={periodType}
/>
{/* Sankey Diagram */}

View File

@ -257,6 +257,16 @@ export default function Categories() {
className:
'bg-zinc-50 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-100',
},
savings: {
label: __('Savings'),
className:
'bg-lime-50 text-lime-700 dark:bg-lime-700 dark:text-lime-100',
},
investment: {
label: __('Investment'),
className:
'bg-emerald-50 text-emerald-700 dark:bg-emerald-700 dark:text-emerald-100',
},
};
const cashflowDirectionConfig = {
hidden: __('Do not show'),

View File

@ -105,7 +105,13 @@ export const CATEGORY_COLORS = [
export type CategoryColor = (typeof CATEGORY_COLORS)[number];
export const CATEGORY_TYPES = ['income', 'expense', 'transfer'] as const;
export const CATEGORY_TYPES = [
'income',
'expense',
'transfer',
'savings',
'investment',
] as const;
export type CategoryType = (typeof CATEGORY_TYPES)[number];
@ -113,6 +119,8 @@ const CATEGORY_TYPE_LABELS: Record<CategoryType, string> = {
income: 'Income',
expense: 'Expense',
transfer: 'Transfer',
savings: 'Savings',
investment: 'Investment',
};
export function getCategoryTypeLabel(type: CategoryType): string {

View File

@ -187,6 +187,123 @@ test('cashflow analytics convert foreign currency transactions to user currency'
Http::assertNothingSent();
});
test('cashflow summary includes actual saved and invested amounts', 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,
]);
$savingsCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Savings,
]);
$investmentCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Investment,
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 100000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory->id,
'amount' => -40000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $savingsCategory->id,
'amount' => -25000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $savingsCategory->id,
'amount' => 5000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $investmentCategory->id,
'amount' => -15000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk()
->assertJsonPath('current.income', 100000)
->assertJsonPath('current.expense', 40000)
->assertJsonPath('current.net', 60000)
->assertJsonPath('current.savings_rate', 60)
->assertJsonPath('current.savings', 25000)
->assertJsonPath('current.investments', 15000);
});
test('cashflow summary compares full quarter against previous full quarter', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 100000,
'transaction_date' => '2025-04-15',
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 30000,
'transaction_date' => '2025-01-15',
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 40000,
'transaction_date' => '2025-02-15',
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 50000,
'transaction_date' => '2025-03-15',
]);
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => '2025-04-01',
'to' => '2025-06-30',
]));
$response->assertOk()
->assertJsonPath('current.income', 100000)
->assertJsonPath('previous.income', 120000);
});
test('cashflow summary handles zero income for savings rate', function () {
$expenseCategory = Category::factory()->create([
'user_id' => $this->user->id,
@ -477,6 +594,44 @@ test('cashflow trend anchors the 12-month series to the requested period end mon
expect($data['2025-05']['income'])->toBe(48000);
});
test('cashflow trend can use explicit period bounds', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 32000,
'transaction_date' => '2025-04-14',
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 48000,
'transaction_date' => '2025-05-07',
]);
$response = $this->getJson('/api/cashflow/trend?'.http_build_query([
'from' => '2025-04-01',
'to' => '2025-06-30',
]));
$response->assertOk();
$data = collect($response->json('data'))->keyBy('month');
expect($data)->toHaveCount(3);
expect($data->keys()->all())->toBe(['2025-04', '2025-05', '2025-06']);
expect($data['2025-04']['income'])->toBe(32000);
expect($data['2025-05']['income'])->toBe(48000);
});
test('cashflow trend defaults to 12 months', function () {
$response = $this->getJson('/api/cashflow/trend');

View File

@ -20,10 +20,11 @@ test('period prop is null when no query param given', function () {
fn (AssertableInertia $page) => $page
->component('cashflow/index')
->where('period', null)
->where('periodType', 'month')
);
});
test('valid period query param is passed to page props', function () {
test('valid month period query param is passed to page props', function () {
$this->actingAs(User::factory()->onboarded()->create());
$this->get(route('cashflow', ['period' => '2025-03']))
@ -32,6 +33,33 @@ test('valid period query param is passed to page props', function () {
fn (AssertableInertia $page) => $page
->component('cashflow/index')
->where('period', '2025-03')
->where('periodType', 'month')
);
});
test('valid quarter period query param is passed to page props', function () {
$this->actingAs(User::factory()->onboarded()->create());
$this->get(route('cashflow', ['period_type' => 'quarter', 'period' => '2025-Q3']))
->assertOk()
->assertInertia(
fn (AssertableInertia $page) => $page
->component('cashflow/index')
->where('period', '2025-Q3')
->where('periodType', 'quarter')
);
});
test('valid year period query param is passed to page props', function () {
$this->actingAs(User::factory()->onboarded()->create());
$this->get(route('cashflow', ['period_type' => 'year', 'period' => '2025']))
->assertOk()
->assertInertia(
fn (AssertableInertia $page) => $page
->component('cashflow/index')
->where('period', '2025')
->where('periodType', 'year')
);
});
@ -44,6 +72,7 @@ test('invalid period query param is sanitized to null', function () {
fn (AssertableInertia $page) => $page
->component('cashflow/index')
->where('period', null)
->where('periodType', 'month')
);
});
@ -56,5 +85,19 @@ test('malformed period format is rejected', function () {
fn (AssertableInertia $page) => $page
->component('cashflow/index')
->where('period', null)
->where('periodType', 'month')
);
});
test('period must match selected period type', function () {
$this->actingAs(User::factory()->onboarded()->create());
$this->get(route('cashflow', ['period_type' => 'quarter', 'period' => '2025-03']))
->assertOk()
->assertInertia(
fn (AssertableInertia $page) => $page
->component('cashflow/index')
->where('period', null)
->where('periodType', 'quarter')
);
});

View File

@ -26,8 +26,8 @@ test('command resets categories for a user by ID', function () {
$user->refresh();
expect($user->categories()->where('name', 'Investments')->first())
->type->toBe(CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
->type->toBe(CategoryType::Investment)
->cashflow_direction->toBe(CategoryCashflowDirection::Hidden);
expect($user->categories()->where('name', 'From account of relatives')->first())
->type->toBe(CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Inflow);

View File

@ -340,6 +340,94 @@ test('non-transfer categories are forced to hidden cashflow direction', function
]);
});
test('users can create savings and investment categories', function (string $type) {
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('categories.store'), [
'name' => ucfirst($type),
'icon' => $type === CategoryType::Savings->value ? 'PiggyBank' : 'TrendingUp',
'color' => 'lime',
'type' => $type,
'cashflow_direction' => 'outflow',
]);
$response->assertRedirect(route('categories.index'));
$this->assertDatabaseHas('categories', [
'user_id' => $user->id,
'name' => ucfirst($type),
'type' => $type,
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
]);
})->with([
CategoryType::Savings->value,
CategoryType::Investment->value,
]);
test('migration updates existing default saving and investment categories', function () {
$user = User::factory()->create();
$investments = Category::factory()->create([
'user_id' => $user->id,
'name' => 'Investments',
'icon' => 'LineChart',
'color' => 'lime',
'type' => CategoryType::Transfer,
'cashflow_direction' => CategoryCashflowDirection::Outflow,
]);
$savings = Category::factory()->create([
'user_id' => $user->id,
'name' => 'Savings',
'icon' => 'PiggyBank',
'color' => 'lime',
'type' => CategoryType::Transfer,
'cashflow_direction' => CategoryCashflowDirection::Outflow,
]);
$legacyExpenseInvestment = Category::factory()->create([
'user_id' => $user->id,
'name' => 'Other investments',
'icon' => 'TrendingUp',
'color' => 'lime',
'type' => CategoryType::Expense,
'cashflow_direction' => CategoryCashflowDirection::Hidden,
]);
$legacySpanishSavings = Category::factory()->create([
'user_id' => $user->id,
'name' => 'Ahorros',
'icon' => 'PiggyBank',
'color' => 'lime',
'type' => CategoryType::Expense,
'cashflow_direction' => CategoryCashflowDirection::Hidden,
]);
$customTransfer = Category::factory()->create([
'user_id' => $user->id,
'name' => 'Investment transfer',
'icon' => 'LineChart',
'color' => 'lime',
'type' => CategoryType::Transfer,
'cashflow_direction' => CategoryCashflowDirection::Outflow,
]);
$migration = require database_path('migrations/2026_05_25_115100_update_default_saving_and_investment_category_types.php');
$migration->up();
expect($investments->refresh())
->type->toBe(CategoryType::Investment)
->cashflow_direction->toBe(CategoryCashflowDirection::Hidden);
expect($savings->refresh())
->type->toBe(CategoryType::Savings)
->cashflow_direction->toBe(CategoryCashflowDirection::Hidden);
expect($legacyExpenseInvestment->refresh())
->type->toBe(CategoryType::Investment)
->cashflow_direction->toBe(CategoryCashflowDirection::Hidden);
expect($legacySpanishSavings->refresh())
->type->toBe(CategoryType::Savings)
->cashflow_direction->toBe(CategoryCashflowDirection::Hidden);
expect($customTransfer->refresh())
->type->toBe(CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
});
test('users cannot update categories they do not own', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
@ -408,14 +496,14 @@ test('default categories are created when user registers', function () {
$user->refresh();
expect($user->categories()->firstWhere('name', 'Investments'))
->type->toBe(CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
->type->toBe(CategoryType::Investment)
->cashflow_direction->toBe(CategoryCashflowDirection::Hidden);
expect($user->categories()->firstWhere('name', 'Savings'))
->type->toBe(CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
->type->toBe(CategoryType::Savings)
->cashflow_direction->toBe(CategoryCashflowDirection::Hidden);
expect($user->categories()->firstWhere('name', 'Other investments'))
->type->toBe(CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
->type->toBe(CategoryType::Investment)
->cashflow_direction->toBe(CategoryCashflowDirection::Hidden);
expect($user->categories()->firstWhere('name', 'From account of relatives'))
->type->toBe(CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Inflow);