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:
parent
af661f72f2
commit
ed737db7b2
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 === '>';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue