feat(cashflow): track transfer categories in trends (#236)

## Summary
- add a transfer-only cashflow reporting setting so categories like
investments can appear in the monthly cashflow trend without counting as
income or expense
- extend the cashflow trend API and chart to show tracked transfer
inflows and outflows while keeping summary cards and sankey accounting
unchanged
- expose the new setting in category management and add feature coverage
for tracked transfer analytics and category persistence

## Screenshots
<img width="808" height="164" alt="image"
src="https://github.com/user-attachments/assets/336d27d4-eacb-4a40-ba60-ecee84d65340"
/>
<img width="1098" height="474" alt="image"
src="https://github.com/user-attachments/assets/ca98883a-525d-4f71-aa2f-e8d6083e4564"
/>
<img width="1114" height="713" alt="image"
src="https://github.com/user-attachments/assets/353461b6-e5c0-42cb-a6ed-87629d7b4575"
/>
This commit is contained in:
Víctor Falcón 2026-03-18 14:02:47 +00:00 committed by GitHub
parent debb47f6af
commit 272dac14b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1481 additions and 845 deletions

View File

@ -2,6 +2,7 @@
namespace App\Actions;
use App\Enums\CategoryCashflowDirection;
use App\Models\User;
class CreateDefaultCategories
@ -25,7 +26,7 @@ class CreateDefaultCategories
/**
* Get the default categories configuration for a given locale.
*
* @return array<int, array{name: string, icon: string, color: string, type: string}>
* @return array<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string}>
*/
public static function getDefaultCategories(string $locale = 'en'): array
{
@ -47,7 +48,7 @@ class CreateDefaultCategories
/**
* Get the base (English) categories configuration.
*
* @return array<int, array{name: string, icon: string, color: string, type: string}>
* @return array<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string}>
*/
private static function getBaseCategories(): array
{
@ -260,19 +261,22 @@ class CreateDefaultCategories
'name' => 'Investments',
'icon' => 'LineChart',
'color' => 'lime',
'type' => 'expense',
'type' => 'transfer',
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
],
[
'name' => 'Savings',
'icon' => 'PiggyBank',
'color' => 'lime',
'type' => 'expense',
'type' => 'transfer',
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
],
[
'name' => 'Other investments',
'icon' => 'TrendingUp',
'color' => 'lime',
'type' => 'expense',
'type' => 'transfer',
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
],
[
'name' => 'Financial services and commission',
@ -411,6 +415,7 @@ class CreateDefaultCategories
'icon' => 'Users',
'color' => 'blue',
'type' => 'transfer',
'cashflow_direction' => CategoryCashflowDirection::Inflow->value,
],
[
'name' => 'Returned payments',

View File

@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum CategoryCashflowDirection: string
{
case Hidden = 'hidden';
case Inflow = 'inflow';
case Outflow = 'outflow';
public function label(): string
{
return match ($this) {
self::Hidden => 'Do not show',
self::Inflow => 'Show as cash inflow',
self::Outflow => 'Show as cash outflow',
};
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Http\Controllers\Controller;
use App\Models\Category;
@ -64,29 +65,36 @@ class CashflowAnalyticsController extends Controller
{
$validated = $request->validate([
'months' => 'nullable|integer|min:1|max:24',
'to' => 'nullable|date',
]);
$months = $validated['months'] ?? 12;
$userId = $request->user()->id;
$end = Carbon::now()->endOfMonth();
$start = Carbon::now()->subMonthsNoOverflow($months - 1)->startOfMonth();
$end = isset($validated['to'])
? Carbon::parse($validated['to'])->endOfMonth()
: Carbon::now()->endOfMonth();
$start = $end->copy()->subMonthsNoOverflow($months - 1)->startOfMonth();
$monthlyTotals = $this->getMonthlyTrendTotals($userId, $start, $end);
$data = [];
$current = $start->copy();
while ($current->lte($end)) {
$monthStart = $current->copy()->startOfMonth();
$monthEnd = $current->copy()->endOfMonth();
$income = $this->getTransactionSum($userId, $monthStart, $monthEnd, CategoryType::Income);
$expense = $this->getTransactionSum($userId, $monthStart, $monthEnd, CategoryType::Expense);
$monthKey = $current->format('Y-m');
$totals = $monthlyTotals->get($monthKey);
$income = (int) ($totals->income ?? 0);
$expense = (int) ($totals->expense ?? 0);
$trackedTransferInflow = (int) ($totals->transfer_inflow ?? 0);
$trackedTransferOutflow = (int) ($totals->transfer_outflow ?? 0);
$data[] = [
'month' => $current->format('Y-m'),
'month' => $monthKey,
'income' => $income,
'expense' => abs($expense),
'net' => $income + $expense, // expense is negative, so this gives net
'expense' => $expense,
'net' => $income - $expense,
'transfer_inflow' => $trackedTransferInflow,
'transfer_outflow' => $trackedTransferOutflow,
];
$current->addMonth();
@ -227,6 +235,35 @@ class CashflowAnalyticsController extends Controller
return $categorized;
}
private function getMonthlyTrendTotals(string $userId, Carbon $from, Carbon $to)
{
return Transaction::query()
->where('transactions.user_id', $userId)
->whereBetween('transactions.transaction_date', [$from, $to])
->leftJoin('categories', 'transactions.category_id', '=', 'categories.id')
->selectRaw("DATE_FORMAT(transactions.transaction_date, '%Y-%m') as month")
->selectRaw(
'SUM(CASE WHEN ((categories.type = ? AND transactions.amount > 0) OR (transactions.category_id IS NULL AND transactions.amount > 0)) THEN transactions.amount ELSE 0 END) as income',
[CategoryType::Income->value]
)
->selectRaw(
'SUM(CASE WHEN ((categories.type = ? AND transactions.amount < 0) OR (transactions.category_id IS NULL AND transactions.amount < 0)) THEN -transactions.amount ELSE 0 END) as expense',
[CategoryType::Expense->value]
)
->selectRaw(
'SUM(CASE WHEN categories.type = ? AND categories.cashflow_direction = ? AND transactions.amount > 0 THEN transactions.amount ELSE 0 END) as transfer_inflow',
[CategoryType::Transfer->value, CategoryCashflowDirection::Inflow->value]
)
->selectRaw(
'SUM(CASE WHEN categories.type = ? AND categories.cashflow_direction = ? AND transactions.amount < 0 THEN -transactions.amount ELSE 0 END) as transfer_outflow',
[CategoryType::Transfer->value, CategoryCashflowDirection::Outflow->value]
)
->groupBy('month')
->orderBy('month')
->get()
->keyBy('month');
}
private function getCategoryBreakdown(string $userId, Carbon $from, Carbon $to, CategoryType $type)
{
// Get categorized transactions — filter by sign so that outgoing payments

View File

@ -18,7 +18,7 @@ class CashflowController extends Controller
$categories = Category::query()
->where('user_id', $user->id)
->orderBy('name')
->get(['id', 'name', 'type', 'icon', 'color']);
->get(['id', 'name', 'type', 'icon', 'color', 'cashflow_direction']);
$accounts = Account::query()
->where('user_id', $user->id)

View File

@ -23,7 +23,7 @@ class CategoryController extends Controller
$categories = auth()->user()
->categories()
->orderBy('name')
->get(['id', 'name', 'icon', 'color', 'type']);
->get(['id', 'name', 'icon', 'color', 'type', 'cashflow_direction']);
return Inertia::render('settings/categories', [
'categories' => $categories,

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests\Settings;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryColor;
use App\Enums\CategoryType;
use Illuminate\Contracts\Validation\ValidationRule;
@ -18,6 +19,21 @@ class StoreCategoryRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
if ($this->input('type') !== CategoryType::Transfer->value) {
$this->merge([
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
]);
return;
}
$this->merge([
'cashflow_direction' => $this->input('cashflow_direction', CategoryCashflowDirection::Hidden->value),
]);
}
/**
* Get the validation rules that apply to the request.
*
@ -38,6 +54,11 @@ class StoreCategoryRequest extends FormRequest
'string',
Rule::enum(CategoryType::class),
],
'cashflow_direction' => [
'required',
'string',
Rule::enum(CategoryCashflowDirection::class),
],
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests\Settings;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryColor;
use App\Enums\CategoryType;
use Illuminate\Contracts\Validation\ValidationRule;
@ -18,6 +19,21 @@ class UpdateCategoryRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
if ($this->input('type') !== CategoryType::Transfer->value) {
$this->merge([
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
]);
return;
}
$this->merge([
'cashflow_direction' => $this->input('cashflow_direction', CategoryCashflowDirection::Hidden->value),
]);
}
/**
* Get the validation rules that apply to the request.
*
@ -38,6 +54,11 @@ class UpdateCategoryRequest extends FormRequest
'string',
Rule::enum(CategoryType::class),
],
'cashflow_direction' => [
'required',
'string',
Rule::enum(CategoryCashflowDirection::class),
],
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -20,6 +21,7 @@ class Category extends Model
'icon',
'color',
'type',
'cashflow_direction',
'user_id',
];
@ -27,6 +29,7 @@ class Category extends Model
{
return [
'type' => CategoryType::class,
'cashflow_direction' => CategoryCashflowDirection::class,
];
}

View File

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
@ -23,6 +24,7 @@ class CategoryFactory extends Factory
'icon' => fake()->randomElement(['AlertCircle', 'AlertTriangle', 'ArrowDownCircle', 'ArrowLeftRight', 'ArrowUpCircle', 'Baby', 'Banknote', 'Briefcase', 'Building', 'Building2', 'Car', 'Clock', 'CreditCard', 'Dices', 'FileText', 'Gift', 'GraduationCap', 'HandHeart', 'Heart', 'HelpCircle', 'Home', 'Landmark', 'Mail', 'PiggyBank', 'Plane', 'Receipt', 'ReceiptText', 'Repeat', 'RotateCcw', 'Scale', 'Shield', 'ShieldCheck', 'ShoppingBag', 'TrendingUp', 'Undo2', 'Users', 'Users2', 'Utensils', 'Wallet']),
'color' => fake()->randomElement(['amber', 'blue', 'cyan', 'emerald', 'gray', 'green', 'indigo', 'orange', 'pink', 'purple', 'red', 'slate', 'teal', 'yellow']),
'type' => fake()->randomElement(CategoryType::cases()),
'cashflow_direction' => CategoryCashflowDirection::Hidden,
'user_id' => User::factory(),
];
}

View File

@ -0,0 +1,31 @@
<?php
use App\Enums\CategoryCashflowDirection;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('categories', function (Blueprint $table) {
$table->string('cashflow_direction')
->default(CategoryCashflowDirection::Hidden->value)
->after('type');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('categories', function (Blueprint $table) {
$table->dropColumn('cashflow_direction');
});
}
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,111 @@
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { CategoryCashflowDirection, CategoryType } from '@/types/category';
import { __ } from '@/utils/i18n';
import { Info } from 'lucide-react';
import { useState } from 'react';
const cashflowDirectionOptions: Array<{
value: CategoryCashflowDirection;
label: string;
description: string;
}> = [
{
value: 'hidden',
label: __('Do not show'),
description: __('Keep this transfer out of cashflow analytics.'),
},
{
value: 'outflow',
label: __('Show as cash outflow'),
description: __(
'Track money leaving your available cash, like investments or savings transfers.',
),
},
{
value: 'inflow',
label: __('Show as cash inflow'),
description: __(
'Track money entering your available cash without counting it as income.',
),
},
];
interface CategoryCashflowDirectionFieldsProps {
selectedType: CategoryType | '';
defaultValue?: CategoryCashflowDirection;
}
export function CategoryCashflowDirectionFields({
selectedType,
defaultValue = 'hidden',
}: CategoryCashflowDirectionFieldsProps) {
const [cashflowDirection, setCashflowDirection] =
useState<CategoryCashflowDirection>(defaultValue);
const selectedOption = cashflowDirectionOptions.find(
(option) => option.value === cashflowDirection,
);
if (selectedType !== 'transfer') {
return null;
}
return (
<div className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3">
<div className="space-y-1">
<Label htmlFor="cashflow_direction">
{__('Cashflow analytics')}
</Label>
<p className="text-xs text-muted-foreground">
{__(
'Transfer categories stay out of income and expense totals, but you can still track them in the monthly chart.',
)}
</p>
</div>
<Select
name="cashflow_direction"
defaultValue={defaultValue}
onValueChange={(value) =>
setCashflowDirection(value as CategoryCashflowDirection)
}
required
>
<SelectTrigger id="cashflow_direction">
<SelectValue
placeholder={__('Choose how to report this transfer')}
/>
</SelectTrigger>
<SelectContent>
{cashflowDirectionOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedOption && (
<p className="px-1 text-xs leading-relaxed text-muted-foreground">
{selectedOption.description}
</p>
)}
<Alert>
<Info className="h-4 w-4 opacity-50" />
<AlertDescription className="text-xs leading-relaxed">
{__(
'Tracked transfers appear only in the monthly cashflow trend. They are not counted as income, expenses, or shown in the money flow Sankey.',
)}
</AlertDescription>
</Alert>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { store } from '@/actions/App/Http/Controllers/Settings/CategoryController';
import { CategoryCashflowDirectionFields } from '@/components/categories/category-cashflow-direction-fields';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -188,6 +189,16 @@ export function CreateCategoryDialog({
)}
</div>
<CategoryCashflowDirectionFields
selectedType={
selectedType as
| 'income'
| 'expense'
| 'transfer'
| ''
}
/>
<div className="flex justify-end gap-2">
<Button
type="button"

View File

@ -1,4 +1,5 @@
import { update } from '@/actions/App/Http/Controllers/Settings/CategoryController';
import { CategoryCashflowDirectionFields } from '@/components/categories/category-cashflow-direction-fields';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -199,6 +200,16 @@ export function EditCategoryDialog({
)}
</div>
<CategoryCashflowDirectionFields
selectedType={
selectedType as
| 'income'
| 'expense'
| 'transfer'
}
defaultValue={category.cashflow_direction}
/>
<div className="flex justify-end gap-2">
<Button
type="button"

View File

@ -46,6 +46,8 @@ interface CustomTooltipProps {
currency?: string;
incomeColor?: string;
expenseColor?: string;
transferInflowColor?: string;
transferOutflowColor?: string;
}
function CustomTooltip({
@ -54,6 +56,8 @@ function CustomTooltip({
currency = 'USD',
incomeColor,
expenseColor,
transferInflowColor,
transferOutflowColor,
}: CustomTooltipProps) {
const locale = useLocale();
@ -100,6 +104,38 @@ function CustomTooltip({
className="font-mono font-medium tabular-nums"
/>
</div>
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-2">
<span
className="size-2 rounded-full"
style={{ background: transferInflowColor }}
/>
{__('Tracked Inflows')}
</span>
<AmountDisplay
amountInCents={data.transfer_inflow}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
className="font-mono font-medium tabular-nums"
/>
</div>
<div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-2">
<span
className="size-2 rounded-full"
style={{ background: transferOutflowColor }}
/>
{__('Tracked Outflows')}
</span>
<AmountDisplay
amountInCents={data.transfer_outflow}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
className="font-mono font-medium tabular-nums"
/>
</div>
<div className="flex items-center justify-between gap-4 border-t pt-1">
<span className="flex items-center gap-2">
<span className="size-2 rounded-full bg-[var(--color-chart-1)]" />
@ -131,6 +167,8 @@ export function CashflowTrendChart({
const scrollContainerRef = useRef<HTMLDivElement>(null);
const minChartWidth = data.length * 60;
const { cashflowIncomeColor, cashflowExpenseColor } = useChartColors();
const transferInflowColor = 'var(--color-chart-4)';
const transferOutflowColor = 'var(--color-chart-6)';
const chartConfig: ChartConfig = {
income: {
@ -141,6 +179,14 @@ export function CashflowTrendChart({
label: __('Expenses'),
color: cashflowExpenseColor,
},
transfer_inflow: {
label: __('Tracked Inflows'),
color: transferInflowColor,
},
transfer_outflow: {
label: __('Tracked Outflows'),
color: transferOutflowColor,
},
net: {
label: __('Net'),
color: 'var(--color-chart-1)',
@ -177,7 +223,7 @@ export function CashflowTrendChart({
</CardTitle>
<CardDescription>
{__(
'Monthly income, expenses, and net cashflow over the last 12 months',
'Monthly income, expenses, tracked transfers, and net cashflow over the last 12 months',
)}
</CardDescription>
</CardHeader>
@ -231,6 +277,12 @@ export function CashflowTrendChart({
currency={currency}
incomeColor={cashflowIncomeColor}
expenseColor={cashflowExpenseColor}
transferInflowColor={
transferInflowColor
}
transferOutflowColor={
transferOutflowColor
}
/>
}
cursor={{
@ -255,6 +307,22 @@ export function CashflowTrendChart({
name="Expenses"
/>
<Bar
dataKey="transfer_inflow"
fill={transferInflowColor}
radius={[4, 4, 0, 0]}
stackId="c"
name="Tracked Inflows"
/>
<Bar
dataKey="transfer_outflow"
fill={transferOutflowColor}
radius={[4, 4, 0, 0]}
stackId="d"
name="Tracked Outflows"
/>
<Line
type="monotone"
dataKey="net"
@ -286,6 +354,20 @@ export function CashflowTrendChart({
/>
<span>{__('Expenses')}</span>
</div>
<div className="flex items-center gap-2">
<span
className="size-3 rounded"
style={{ background: transferInflowColor }}
/>
<span>{__('Tracked Inflows')}</span>
</div>
<div className="flex items-center gap-2">
<span
className="size-3 rounded"
style={{ background: transferOutflowColor }}
/>
<span>{__('Tracked Outflows')}</span>
</div>
<div className="flex items-center gap-2">
<span className="size-3 rounded-full bg-[var(--color-chart-1)]" />
<span>{__('Net')}</span>

View File

@ -27,6 +27,8 @@ export interface TrendDataPoint {
income: number;
expense: number;
net: number;
transfer_inflow: number;
transfer_outflow: number;
}
export interface BreakdownItem {
@ -111,8 +113,8 @@ export function useCashflowData({
fetch(`/api/cashflow/sankey${periodQuery}`).then((r) =>
r.json(),
),
fetch('/api/cashflow/trend?months=12').then((r) =>
r.json(),
fetch(`/api/cashflow/trend?months=12&to=${toStr}`).then(
(r) => r.json(),
),
fetch(
`/api/cashflow/breakdown${periodQuery}&type=income`,

View File

@ -47,6 +47,11 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { type BreadcrumbItem } from '@/types';
@ -122,7 +127,10 @@ function CategoryRow({ row }: { row: Row<Category> }) {
{row
.getVisibleCells()
.map((cell: Cell<Category, unknown>) => (
<TableCell key={cell.id}>
<TableCell
key={cell.id}
className="align-middle"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
@ -232,6 +240,7 @@ export default function Categories() {
header: __('Type'),
cell: ({ row }) => {
const type = row.getValue('type') as Category['type'];
const cashflowDirection = row.original.cashflow_direction;
const typeConfig = {
income: {
label: __('Income'),
@ -249,13 +258,40 @@ export default function Categories() {
'bg-zinc-50 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-100',
},
};
const cashflowDirectionConfig = {
hidden: __('Do not show'),
inflow: __('Cash inflow'),
outflow: __('Cash outflow'),
};
const CashflowVisibilityIcon =
cashflowDirection === 'hidden' ? Icons.EyeOff : Icons.Eye;
const config = typeConfig[type];
return (
<Badge
className={`${config.className} text-[10px] tracking-widest`}
>
{config.label.toLocaleUpperCase()}
</Badge>
<div className="flex items-center gap-2">
<Badge
className={`${config.className} text-[10px] tracking-widest`}
>
{config.label.toLocaleUpperCase()}
</Badge>
{type === 'transfer' && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label={__('Cashflow analytics')}
>
<CashflowVisibilityIcon className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>
{cashflowDirectionConfig[cashflowDirection]}
</TooltipContent>
</Tooltip>
)}
</div>
);
},
},
@ -356,7 +392,7 @@ export default function Categories() {
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
className="h-24 text-center align-middle"
>
{__('No categories found.')}
</TableCell>

View File

@ -107,12 +107,22 @@ export const CATEGORY_TYPES = ['income', 'expense', 'transfer'] as const;
export type CategoryType = (typeof CATEGORY_TYPES)[number];
export const CATEGORY_CASHFLOW_DIRECTIONS = [
'hidden',
'inflow',
'outflow',
] as const;
export type CategoryCashflowDirection =
(typeof CATEGORY_CASHFLOW_DIRECTIONS)[number];
export interface Category {
id: UUID;
name: string;
icon: CategoryIcon;
color: CategoryColor;
type: CategoryType;
cashflow_direction: CategoryCashflowDirection;
}
export function getCategoryColorClasses(color: CategoryColor): {

View File

@ -35,6 +35,29 @@ it('shows existing categories in list', function () {
->assertNoJavascriptErrors();
});
it('vertically centers category table cells', function () {
$user = User::factory()->onboarded()->create();
Category::factory()->create([
'user_id' => $user->id,
'name' => 'Investments',
'icon' => 'ChartNoAxesColumnIncreasing',
'color' => 'lime',
'type' => 'transfer',
]);
actingAs($user);
$page = visit('/settings/categories');
$page->waitForText('Investments')
->assertAttributeContains(
'tbody tr td[data-slot="table-cell"]:first-child',
'class',
'align-middle',
)
->assertNoJavascriptErrors();
});
it('can open create category dialog', function () {
$user = User::factory()->onboarded()->create();

View File

@ -1,5 +1,6 @@
<?php
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\Category;
@ -240,7 +241,126 @@ test('cashflow trend returns monthly data for specified months', function () {
$data = $response->json();
expect($data['data'])->toHaveCount(3);
expect($data['data'][0])->toHaveKeys(['month', 'income', 'expense', 'net']);
expect($data['data'][0])->toHaveKeys(['month', 'income', 'expense', 'net', 'transfer_inflow', 'transfer_outflow']);
expect($data['data'][0]['transfer_inflow'])->toBe(0);
expect($data['data'][0]['transfer_outflow'])->toBe(0);
});
test('cashflow trend includes tracked transfer inflows and outflows without affecting net', 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,
]);
$investmentCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Transfer,
'cashflow_direction' => CategoryCashflowDirection::Outflow,
'name' => 'Investment',
]);
$rebalanceCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Transfer,
'cashflow_direction' => CategoryCashflowDirection::Inflow,
'name' => 'Investment Return Transfer',
]);
$hiddenTransferCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Transfer,
'cashflow_direction' => CategoryCashflowDirection::Hidden,
'name' => 'Own Account',
]);
$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' => $investmentCategory->id,
'amount' => -25000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $rebalanceCategory->id,
'amount' => 15000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $hiddenTransferCategory->id,
'amount' => -5000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/trend?months=1');
$response->assertOk();
$point = $response->json('data.0');
expect($point['income'])->toBe(100000);
expect($point['expense'])->toBe(40000);
expect($point['net'])->toBe(60000);
expect($point['transfer_outflow'])->toBe(25000);
expect($point['transfer_inflow'])->toBe(15000);
});
test('cashflow trend anchors the 12-month series to the requested period end month', function () {
$transferCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Transfer,
'cashflow_direction' => CategoryCashflowDirection::Outflow,
'name' => 'Investment',
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $transferCategory->id,
'amount' => -32000,
'transaction_date' => '2025-04-14',
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $transferCategory->id,
'amount' => -48000,
'transaction_date' => '2025-05-07',
]);
$response = $this->getJson('/api/cashflow/trend?months=12&to=2025-05-31');
$response->assertOk();
$data = collect($response->json('data'))->keyBy('month');
expect($data)->toHaveCount(12);
expect($data->keys()->first())->toBe('2024-06');
expect($data->keys()->last())->toBe('2025-05');
expect($data['2025-04']['transfer_outflow'])->toBe(32000);
expect($data['2025-05']['transfer_outflow'])->toBe(48000);
});
test('cashflow trend defaults to 12 months', function () {
@ -389,6 +509,7 @@ test('cashflow page returns categories with type', function () {
->component('cashflow/index')
->has('categories', 2)
->has('categories.0.type')
->has('categories.0.cashflow_direction')
);
});

View File

@ -1,5 +1,7 @@
<?php
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\Category;
use App\Models\User;
@ -20,6 +22,15 @@ test('command resets categories for a user by ID', function () {
$categoryNames = $user->categories->pluck('name')->toArray();
expect($categoryNames)->toContain('Food', 'Transportation', 'Salary', 'Insurance');
$user->refresh();
expect($user->categories()->where('name', 'Investments')->first())
->type->toBe(CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
expect($user->categories()->where('name', 'From account of relatives')->first())
->type->toBe(CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Inflow);
});
test('command resets categories for a user by email', function () {

View File

@ -1,5 +1,6 @@
<?php
use App\Enums\CategoryCashflowDirection;
use App\Models\Category;
use App\Models\User;
@ -41,6 +42,7 @@ test('authenticated users can create a category', function () {
'icon' => 'ShoppingBag',
'color' => 'blue',
'type' => 'expense',
'cashflow_direction' => 'outflow',
];
$response = $this->actingAs($user)->post(route('categories.store'), $categoryData);
@ -53,6 +55,7 @@ test('authenticated users can create a category', function () {
'icon' => 'ShoppingBag',
'color' => 'blue',
'type' => 'expense',
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
]);
});
@ -63,6 +66,7 @@ test('category name is required', function () {
'icon' => 'ShoppingBag',
'color' => 'blue',
'type' => 'expense',
'cashflow_direction' => 'hidden',
]);
$response->assertSessionHasErrors(['name']);
@ -138,7 +142,8 @@ test('authenticated users can update their own category', function () {
'name' => 'Updated Name',
'icon' => 'Home',
'color' => 'green',
'type' => 'income',
'type' => 'transfer',
'cashflow_direction' => 'outflow',
];
$response = $this->actingAs($user)->patch(
@ -153,7 +158,50 @@ test('authenticated users can update their own category', function () {
'name' => 'Updated Name',
'icon' => 'Home',
'color' => 'green',
'type' => 'transfer',
'cashflow_direction' => 'outflow',
]);
});
test('transfer categories can store a cashflow direction', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('categories.store'), [
'name' => 'Investment',
'icon' => 'TrendingUp',
'color' => 'green',
'type' => 'transfer',
'cashflow_direction' => 'outflow',
]);
$response->assertRedirect(route('categories.index'));
$this->assertDatabaseHas('categories', [
'user_id' => $user->id,
'name' => 'Investment',
'type' => 'transfer',
'cashflow_direction' => 'outflow',
]);
});
test('non-transfer categories are forced to hidden cashflow direction', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('categories.store'), [
'name' => 'Salary',
'icon' => 'Coins',
'color' => 'green',
'type' => 'income',
'cashflow_direction' => 'outflow',
]);
$response->assertRedirect(route('categories.index'));
$this->assertDatabaseHas('categories', [
'user_id' => $user->id,
'name' => 'Salary',
'type' => 'income',
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
]);
});
@ -221,6 +269,21 @@ test('default categories are created when user registers', function () {
$categoryNames = $user->categories->pluck('name')->toArray();
expect($categoryNames)->toContain('Food', 'Transportation', 'Salary', 'Insurance');
$user->refresh();
expect($user->categories()->firstWhere('name', 'Investments'))
->type->toBe(\App\Enums\CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
expect($user->categories()->firstWhere('name', 'Savings'))
->type->toBe(\App\Enums\CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
expect($user->categories()->firstWhere('name', 'Other investments'))
->type->toBe(\App\Enums\CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
expect($user->categories()->firstWhere('name', 'From account of relatives'))
->type->toBe(\App\Enums\CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Inflow);
});
test('default categories are not created twice for the same user', function () {