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:
parent
debb47f6af
commit
272dac14b8
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
1648
lang/es.json
1648
lang/es.json
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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): {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue