From ed737db7b28442ea3674290eb7eb3f15744dd5b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Mon, 25 May 2026 16:41:00 +0200 Subject: [PATCH] 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 image - Broadened migration after read-only prod check found legacy default categories still using expense/hidden. --- app/Actions/CreateDefaultCategories.php | 10 +- app/Enums/CategoryType.php | 4 + .../Api/CashflowAnalyticsController.php | 29 ++- app/Http/Controllers/CashflowController.php | 25 ++- app/Services/PeriodComparator.php | 4 +- ...t_saving_and_investment_category_types.php | 70 ++++++++ lang/es.json | 3 + .../components/cashflow/period-navigation.tsx | 166 ++++++++++++++---- .../components/cashflow/savings-rate-card.tsx | 40 ++++- .../categories/create-category-dialog.tsx | 15 +- .../categories/edit-category-dialog.tsx | 16 +- .../charts/cashflow-trend-chart.tsx | 14 +- .../js/components/charts/sankey-chart.tsx | 53 +++++- resources/js/hooks/use-cashflow-data.ts | 16 +- resources/js/pages/cashflow/index.tsx | 150 ++++++++++++---- resources/js/pages/settings/categories.tsx | 10 ++ resources/js/types/category.ts | 10 +- tests/Feature/CashflowAnalyticsTest.php | 155 ++++++++++++++++ tests/Feature/CashflowPageTest.php | 45 ++++- .../ResetUserCategoriesCommandTest.php | 4 +- tests/Feature/Settings/CategoryTest.php | 100 ++++++++++- 21 files changed, 810 insertions(+), 129 deletions(-) create mode 100644 database/migrations/2026_05_25_115100_update_default_saving_and_investment_category_types.php diff --git a/app/Actions/CreateDefaultCategories.php b/app/Actions/CreateDefaultCategories.php index 3493fa57..0119862f 100644 --- a/app/Actions/CreateDefaultCategories.php +++ b/app/Actions/CreateDefaultCategories.php @@ -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', diff --git a/app/Enums/CategoryType.php b/app/Enums/CategoryType.php index 9ece3209..1f158232 100644 --- a/app/Enums/CategoryType.php +++ b/app/Enums/CategoryType.php @@ -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', }; } } diff --git a/app/Http/Controllers/Api/CashflowAnalyticsController.php b/app/Http/Controllers/Api/CashflowAnalyticsController.php index 1fd847ca..7e2658bc 100644 --- a/app/Http/Controllers/Api/CashflowAnalyticsController.php +++ b/app/Http/Controllers/Api/CashflowAnalyticsController.php @@ -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 === '>'; diff --git a/app/Http/Controllers/CashflowController.php b/app/Http/Controllers/CashflowController.php index 5a162696..5dee091b 100644 --- a/app/Http/Controllers/CashflowController.php +++ b/app/Http/Controllers/CashflowController.php @@ -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; + } } diff --git a/app/Services/PeriodComparator.php b/app/Services/PeriodComparator.php index bd7f32a8..3b019eca 100644 --- a/app/Services/PeriodComparator.php +++ b/app/Services/PeriodComparator.php @@ -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); } diff --git a/database/migrations/2026_05_25_115100_update_default_saving_and_investment_category_types.php b/database/migrations/2026_05_25_115100_update_default_saving_and_investment_category_types.php new file mode 100644 index 00000000..20e628c8 --- /dev/null +++ b/database/migrations/2026_05_25_115100_update_default_saving_and_investment_category_types.php @@ -0,0 +1,70 @@ +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, + ]); + } +}; diff --git a/lang/es.json b/lang/es.json index db452eeb..d6717b9e 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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", diff --git a/resources/js/components/cashflow/period-navigation.tsx b/resources/js/components/cashflow/period-navigation.tsx index 9e22aa94..f253a3ba 100644 --- a/resources/js/components/cashflow/period-navigation.tsx +++ b/resources/js/components/cashflow/period-navigation.tsx @@ -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 ( - - +
+ + {periodTypeOptions.map((option) => ( + + ))} + - + + - - + + + + +
); } + +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); +} diff --git a/resources/js/components/cashflow/savings-rate-card.tsx b/resources/js/components/cashflow/savings-rate-card.tsx index b5938d76..30ecc673 100644 --- a/resources/js/components/cashflow/savings-rate-card.tsx +++ b/resources/js/components/cashflow/savings-rate-card.tsx @@ -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({ )} -

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

+
+
+

+ {__('Saved')} +

+ +
+
+

+ {__('Invested')} +

+ +
+
); diff --git a/resources/js/components/categories/create-category-dialog.tsx b/resources/js/components/categories/create-category-dialog.tsx index 3306540e..f77d04e5 100644 --- a/resources/js/components/categories/create-category-dialog.tsx +++ b/resources/js/components/categories/create-category-dialog.tsx @@ -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(''); + const [selectedType, setSelectedType] = useState(''); return ( @@ -157,7 +158,9 @@ export function CreateCategoryDialog({