fix(categories): expose cashflow setting on create (#448)

## Summary
- Always show a simplified Cashflow and charts explanation in the
category form
- Explain how each category type affects cashflow, income/spending
charts, top spending categories, savings, and investments
- For transfer categories, keep a cashflow chart visibility selector so
users can choose hidden/inflow/outflow
- Show savings and investment categories as outflows in the cashflow
chart and store default/new categories as outflow
- Add a new migration to update existing production savings/investment
categories to outflow
- Avoid user-facing Sankey terminology and update Spanish copy to use
dinero/saldo instead of efectivo
- Add missing Spanish translations and browser/feature coverage

## Tests
- php artisan test --compact tests/Feature/LocalizationTest.php
- php artisan test --compact
tests/Feature/Console/ResetUserCategoriesCommandTest.php
- php artisan test --compact tests/Feature/Settings/CategoryTest.php
--filter="migration updates existing default saving and investment
categories|migration sets saving and investment categories to cashflow
outflow|users can create savings and investment categories|default
categories are created when user registers"
- php artisan test --compact tests/Feature/CashflowAnalyticsTest.php
--filter="sankey includes savings and investment categories on the
expense side|sankey includes outflow transfer categories on the expense
side"
- php artisan test --compact tests/Browser/CategoriesTest.php
--filter="explains savings and investment category cashflow impact in
the create dialog|can create a new transfer category with a cashflow
analytics direction"
- php artisan test --compact tests/Browser/CategoriesTest.php
- ./vendor/bin/pest --testsuite=Performance --filter="account show page
does not exceed query threshold"
- vendor/bin/pint --dirty --format agent
- npx prettier --write
resources/js/components/categories/category-cashflow-direction-fields.tsx
- npm run build (assets emitted; Sentry sourcemap upload reports local
SSL error)

## Video
- screenshots/category-create-cashflow-setting.webm

## Notes
- npm run types still fails on existing unrelated TypeScript errors.
This commit is contained in:
Víctor Falcón 2026-05-29 11:05:04 +02:00 committed by GitHub
parent 0b9406714e
commit 5119528149
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 410 additions and 106 deletions

View File

@ -282,18 +282,21 @@ class CreateDefaultCategories
'icon' => 'LineChart',
'color' => 'lime',
'type' => CategoryType::Investment->value,
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
],
[
'name' => 'Savings',
'icon' => 'PiggyBank',
'color' => 'lime',
'type' => CategoryType::Savings->value,
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
],
[
'name' => 'Other investments',
'icon' => 'TrendingUp',
'color' => 'lime',
'type' => CategoryType::Investment->value,
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
],
[
'name' => 'Financial services and commission',

View File

@ -233,8 +233,12 @@ class CashflowAnalyticsController extends Controller
$regularCategories = $transactions
->filter(function (Transaction $transaction) use ($type): bool {
$categoryType = $this->categoryType($transaction);
return $transaction->category_id !== null
&& $this->categoryType($transaction) === $type;
&& ($categoryType === $type
|| ($type === CategoryType::Expense
&& in_array($categoryType, [CategoryType::Savings, CategoryType::Investment], true)));
})
->groupBy('category_id')
->map(function (Collection $transactions) use ($userCurrency): array {

View File

@ -21,7 +21,17 @@ class StoreCategoryRequest extends FormRequest
protected function prepareForValidation(): void
{
if ($this->input('type') !== CategoryType::Transfer->value) {
$type = CategoryType::tryFrom((string) $this->input('type'));
if (in_array($type, [CategoryType::Savings, CategoryType::Investment], true)) {
$this->merge([
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
]);
return;
}
if ($type !== CategoryType::Transfer) {
$this->merge([
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
]);

View File

@ -21,7 +21,17 @@ class UpdateCategoryRequest extends FormRequest
protected function prepareForValidation(): void
{
if ($this->input('type') !== CategoryType::Transfer->value) {
$type = CategoryType::tryFrom((string) $this->input('type'));
if (in_array($type, [CategoryType::Savings, CategoryType::Investment], true)) {
$this->merge([
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
]);
return;
}
if ($type !== CategoryType::Transfer) {
$this->merge([
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
]);

View File

@ -0,0 +1,40 @@
<?php
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('categories')
->whereIn('type', [
CategoryType::Savings->value,
CategoryType::Investment->value,
])
->update([
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::table('categories')
->whereIn('type', [
CategoryType::Savings->value,
CategoryType::Investment->value,
])
->where('cashflow_direction', CategoryCashflowDirection::Outflow->value)
->update([
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
]);
}
};

View File

@ -286,11 +286,12 @@
"Canadian Dollar": "Dólar canadiense",
"Cancel": "Cancelar",
"Carry Over": "Acumular",
"Cash inflow": "Entrada de efectivo",
"Cash outflow": "Salida de efectivo",
"Cash inflow": "Entrada de dinero",
"Cash outflow": "Salida de dinero",
"Cashflow": "Flujo de Caja",
"Cashflow Trend": "Tendencia del Flujo de Caja",
"Cashflow analytics": "Analitica de flujo de caja",
"Cashflow and charts": "Flujo de caja y gráficos",
"Cashflow at a Glance": "Flujo de Caja de un Vistazo",
"Cashflow income vs expenses bar chart": "Gráfico de barras de ingresos frente a gastos",
"Cashflow periods and analysis will start on this day.": "Los períodos y análisis de flujo de caja empezarán este día.",
@ -306,6 +307,7 @@
"Category name": "Nombre de categoría",
"Change": "Cambiar",
"Change category": "Cambiar categoría",
"Change the category type above to change how this category appears in cashflow analytics.": "Cambia el tipo de categoría arriba para cambiar cómo aparece esta categoría en la analítica de flujo de caja.",
"Chart color scheme": "Esquema de colores del gráfico",
"Chart type": "Tipo de gráfico",
"Check Demo": "Ver Demo",
@ -315,7 +317,12 @@
"Checking": "Cuenta Corriente",
"Chilean Peso": "Peso chileno",
"Chinese Yuan": "Yuan chino",
"Choose Sankey chart visibility": "Elige la visibilidad en el gráfico Sankey",
"Choose a category type to see how it affects cashflow and charts.": "Elige un tipo de categoría para ver cómo afecta al flujo de caja y a los gráficos.",
"Choose a category type to see how this category will affect cashflow analytics.": "Elige un tipo de categoría para ver cómo afectará esta categoría a la analítica de flujo de caja.",
"Choose cashflow chart visibility": "Elige la visibilidad en el gráfico de flujo de caja",
"Choose how to handle each account from :bank. You can create new accounts, link to existing ones, or skip.": "Elige cómo gestionar cada cuenta de :bank. Puedes crear nuevas cuentas, vincularlas a las existentes u omitirlas.",
"Choose how to report this category": "Elige cómo reportar esta categoría",
"Choose how to report this transfer": "Elige como mostrar esta transferencia",
"Choose the account where transactions will be imported": "Elige la cuenta donde se importarán las transacciones",
"Choose the color palette for your charts": "Elige la paleta de colores para tus gráficos",
@ -494,6 +501,8 @@
"Discord": "Discord",
"Discord for 80% off": "Discord con 80% de descuento",
"Do not show": "No mostrar",
"Do not show in Sankey": "No mostrar en Sankey",
"Do not show in cashflow chart": "No mostrar en el gráfico de flujo de caja",
"Don't have an account?": "¿No tienes una cuenta?",
"Download as CSV or Excel format": "Descargar en formato CSV o Excel",
"Drop your CSV or Excel file here, or click to browse": "Arrastra tu archivo CSV o Excel aquí, o haz clic para buscar",
@ -578,6 +587,8 @@
"Everything you need. Nothing you don't.": "Todo lo que necesitas. Nada que no.",
"Expense": "Gasto",
"Expense Categories": "Categorías de Gastos",
"Expense categories automatically appear as cash outflows in cashflow analytics.": "Las categorías de gasto aparecen automáticamente como salidas de dinero en la analítica de flujo de caja.",
"Expense categories count as cash outflow and appear in spending charts, including top spending categories.": "Las categorías de gasto cuentan como salida de dinero y aparecen en los gráficos de gasto, incluidas las categorías con más gasto.",
"Expense tracking": "Seguimiento de gastos",
"Expenses": "Gastos",
"Expires": "Vence",
@ -747,6 +758,8 @@
"Income": "Ingresos",
"Income (Salary, Freelance, Investments)": "Ingresos (Salario, Freelance, Inversiones)",
"Income Sources": "Fuentes de Ingresos",
"Income categories automatically appear as cash inflows in cashflow analytics.": "Las categorías de ingreso aparecen automáticamente como entradas de dinero en la analítica de flujo de caja.",
"Income categories count as cash inflow and appear in income charts. They do not appear in top spending categories.": "Las categorías de ingreso cuentan como entrada de dinero y aparecen en los gráficos de ingresos. No aparecen en las categorías con más gasto.",
"Income minus expenses": "Ingresos menos gastos",
"Indian Rupee": "Rupia india",
"Information about how you use our service, including access times and features used": "Información sobre cómo usas nuestro servicio, incluyendo horarios de acceso y funciones utilizadas",
@ -761,6 +774,9 @@
"Invested amount": "Cantidad invertida",
"Investment": "Inversión",
"Investment Portfolio": "Cartera de Inversión",
"Investment categories appear as invested money at the top of cashflow and as cash outflow in the cashflow chart. They stay out of income, expenses, and top spending categories.": "Las categorías de inversión aparecen como dinero invertido en la parte superior del flujo de caja y como salida de dinero en el gráfico de flujo de caja. Quedan fuera de ingresos, gastos y categorías con más gasto.",
"Investment categories stay out of income and expense totals in cashflow analytics.": "Las categorías de inversión quedan fuera de los totales de ingresos y gastos en la analítica de flujo de caja.",
"Investment categories stay out of income, expenses, cashflow totals, and top spending categories.": "Las categorías de inversión quedan fuera de ingresos, gastos, totales de flujo de caja y categorías con más gasto.",
"Is Whisper Money open source?": "¿Es Whisper Money de código abierto?",
"Is the app meeting your expectations?": "¿La app cumple con tus expectativas?",
"Is the encryption confusing?": "¿La encriptación es confusa?",
@ -781,6 +797,7 @@
"Keep me logged in": "Mantenerme conectado",
"Keep me logged in (convenient)": "Mantenerme conectado (conveniente)",
"Keep me logged in (less secure)": "Mantenerme conectado (menos seguro)",
"Keep this category out of cashflow analytics.": "Mantén esta categoría fuera de la analítica de flujo de caja.",
"Keep this transfer out of cashflow analytics.": "Mantiene esta transferencia fuera de la analitica de flujo de caja.",
"Key Storage": "Almacenamiento de Clave",
"Label (Optional)": "Etiqueta (Opcional)",
@ -885,8 +902,8 @@
"Monthly": "Mensual",
"Monthly Payment": "Pago Mensual",
"Monthly code": "Código mensual",
"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, 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 dinero neto mensual para el período seleccionado",
"Monthly income, expenses, and net cashflow over the last 12 months": "Ingresos, gastos y flujo de dinero neto mensual durante los últimos 12 meses",
"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",
@ -937,7 +954,7 @@
"No bank connections yet. Connect a bank to automatically sync your transactions.": "Aún no hay conexiones bancarias. Conecta un banco para sincronizar automáticamente tus transacciones.",
"No bank found.": "No se encontró ningún banco.",
"No banks found.": "No se encontraron bancos.",
"No cashflow data for this period": "Sin datos de flujo de efectivo para este período",
"No cashflow data for this period": "Sin datos de flujo de dinero para este período",
"No categories found.": "No se encontraron categorías.",
"No category found.": "No se encontró categoría.",
"No category set": "Sin categoría definida",
@ -1152,6 +1169,9 @@
"Savings": "Ahorros",
"Savings Rate": "Tasa de Ahorro",
"Savings Transfer": "Transferencia a Ahorros",
"Savings categories appear as saved money at the top of cashflow and as cash outflow in the cashflow chart. They stay out of income, expenses, and top spending categories.": "Las categorías de ahorro aparecen como dinero ahorrado en la parte superior del flujo de caja y como salida de dinero en el gráfico de flujo de caja. Quedan fuera de ingresos, gastos y categorías con más gasto.",
"Savings categories stay out of income and expense totals in cashflow analytics.": "Las categorías de ahorro quedan fuera de los totales de ingresos y gastos en la analítica de flujo de caja.",
"Savings categories stay out of income, expenses, cashflow totals, and top spending categories.": "Las categorías de ahorro quedan fuera de ingresos, gastos, totales de flujo de caja y categorías con más gasto.",
"Savings rate": "Tasa de ahorro",
"Search": "Buscar",
"Search bank...": "Buscar banco...",
@ -1244,10 +1264,14 @@
"Shared": "Compartido",
"Shared with": "Compartido con",
"Shopping (Clothing, Electronics, Gifts)": "Compras (Ropa, Electrónica, Regalos)",
"Show as cash inflow": "Mostrar como entrada de efectivo",
"Show as cash outflow": "Mostrar como salida de efectivo",
"Show as cash inflow": "Mostrar como entrada de dinero",
"Show as cash outflow": "Mostrar como salida de dinero",
"Show in account currency (:currency)": "Mostrar en moneda de la cuenta (:currency)",
"Show in cashflow chart as inflow": "Mostrar en el gráfico de flujo de caja como entrada",
"Show in cashflow chart as outflow": "Mostrar en el gráfico de flujo de caja como salida",
"Show in your currency (:currency)": "Mostrar en tu moneda (:currency)",
"Show this transfer category as money entering available cash.": "Muestra esta categoría de transferencia como dinero que entra en el saldo disponible.",
"Show this transfer category as money leaving available cash.": "Muestra esta categoría de transferencia como dinero que sale del saldo disponible.",
"Sign up": "Crear cuenta",
"Simple, transparent pricing": "Precios simples y transparentes",
"Single-use. Works on monthly or yearly plans. Tied to your email — please keep it private.": "Un solo uso. Funciona con el plan mensual o anual. Está vinculado a tu email — guárdalo en privado.",
@ -1372,8 +1396,11 @@
"This is your exclusive invitation to get full access to everything:": "Esta es tu invitación exclusiva para obtener acceso completo a todo:",
"This month's income and expenses": "Ingresos y gastos de este mes",
"This password will encrypt all your financial data. Make it strong and memorable — we can't recover it for you.": "Esta contraseña encriptará todos tus datos financieros. Hazla fuerte y memorable: no podemos recuperarla por ti.",
"This setting follows the selected category type and cannot be changed here.": "Esta configuración sigue el tipo de categoría seleccionado y no se puede cambiar aquí.",
"This transaction was imported from a file. The description cannot be modified.": "Esta transacción fue importada de un archivo. La descripción no se puede modificar.",
"This transaction was imported from a\\n file. The description cannot be\\n modified.": "Esta transacción fue importada desde un archivo. La descripción no se puede modificar.",
"This transfer category stays out of the Sankey money flow chart.": "Esta categoría de transferencia queda fuera del gráfico Sankey de flujo de dinero.",
"This transfer category stays out of the cashflow chart.": "Esta categoría de transferencia queda fuera del gráfico de flujo de caja.",
"This will only take a moment.": "Esto solo tardará un momento.",
"This will remove your encryption key from this browser session. You'll need to enter your password again to unlock encrypted content.": "Esto eliminará tu clave de encriptación de esta sesión del navegador. Necesitarás ingresar tu contraseña nuevamente para desbloquear el contenido encriptado.",
"This will revoke access to your bank account data from :bank.": "Esto revocará el acceso a los datos de tu cuenta bancaria de :bank.",
@ -1413,8 +1440,8 @@
"Track all your finances in one place \\u2014 checking,\\n savings, credit cards, investments, and more.": "Realiza un seguimiento de todas tus finanzas en un solo lugar: cuentas corrientes, ahorros, tarjetas de crédito, inversiones y más.",
"Track all your finances in one place — checking, savings, credit cards, investments, and more.": "Rastrea todas tus finanzas en un solo lugar: cuenta corriente, ahorros, tarjetas de crédito, inversiones y más.",
"Track credit card spending": "Rastrea los gastos de tarjeta de crédito",
"Track money entering your available cash without counting it as income.": "Sigue el dinero que entra en tu efectivo disponible sin contarlo como ingreso.",
"Track money leaving your available cash, like investments or savings transfers.": "Sigue el dinero que sale de tu efectivo disponible, como inversiones o transferencias a ahorros.",
"Track money entering your available cash without counting it as income.": "Sigue el dinero que entra en tu saldo disponible sin contarlo como ingreso.",
"Track money leaving your available cash, like investments or savings transfers.": "Sigue el dinero que sale de tu saldo disponible, como inversiones o transferencias a ahorros.",
"Track your budget progress": "Sigue el progreso de tu presupuesto",
"Track your income, expenses, and savings": "Rastrea tus ingresos, gastos y ahorros",
"Track your property market value over time. Transactions are not supported.": "Rastrea el valor de mercado de tu propiedad a lo largo del tiempo. Las transacciones no están soportadas.",
@ -1437,9 +1464,11 @@
"Transaction\\n details, budgets, categories, and other\\n financial information you manually enter\\n into the application": "Detalles de transacciones, presupuestos, categorías y otra información financiera que introduces manualmente en la aplicación",
"Transactions": "Transacciones",
"Transactions + Balance": "Transacciones + Balance",
"Transactions in this category will not be counted in top expenses or income. Transfer categories are mainly used for transactions between accounts.": "Estas transacciones no se contará como gasto ni como ingreso.",
"Transactions in this category will not be counted in top expenses or income. Transfer categories are mainly used for transactions between accounts.": "Las transacciones en esta categoría no se contarán entre los principales gastos o ingresos. Las categorías de transferencia se usan principalmente para transacciones entre cuentas.",
"Transactions in this category will\\n not be counted in top expenses or\\n income. Transfer categories are\\n mainly used for transactions between\\n accounts.": "Las transacciones en esta categoría no se contarán entre los principales gastos o ingresos. Las categorías de transferencia se usan principalmente para transacciones entre cuentas.",
"Transfer": "Transferencia",
"Transfer categories are excluded from income, expenses, and top spending categories. Choose whether to show them in the Sankey money flow chart.": "Las categorías de transferencia se excluyen de ingresos, gastos y categorías con más gasto. Elige si quieres mostrarlas en el gráfico Sankey de flujo de dinero.",
"Transfer categories are excluded from income, expenses, and top spending categories. Choose whether to show them in the cashflow chart.": "Las categorías de transferencia se excluyen de ingresos, gastos y categorías con más gasto. Elige si quieres mostrarlas en el gráfico de flujo de caja.",
"Transfer categories stay out of income and expense totals, but you can still track them in the money flow chart.": "Las categorías de transferencia quedan fuera de los totales de ingresos y gastos, pero aún puedes seguirlas en el gráfico de flujo de dinero.",
"Transfer categories stay out of income and expense totals, but you can still track them in the monthly chart.": "Las categorias de transferencia quedan fuera de los totales de ingresos y gastos, pero aun puedes seguirlas en el grafico mensual.",
"Transfers (Between accounts, Savings)": "Transferencias (Entre cuentas, Ahorros)",

View File

@ -1,4 +1,3 @@
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Label } from '@/components/ui/label';
import {
Select,
@ -9,32 +8,29 @@ import {
} 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<{
const transferCashflowOptions: Array<{
value: CategoryCashflowDirection;
label: string;
description: string;
}> = [
{
value: 'hidden',
label: __('Do not show'),
description: __('Keep this transfer out of cashflow analytics.'),
label: 'Do not show in cashflow chart',
description: 'This transfer category stays out of the cashflow chart.',
},
{
value: 'outflow',
label: __('Show as cash outflow'),
description: __(
'Track money leaving your available cash, like investments or savings transfers.',
),
label: 'Show in cashflow chart as outflow',
description:
'Show this transfer category as money leaving available cash.',
},
{
value: 'inflow',
label: __('Show as cash inflow'),
description: __(
'Track money entering your available cash without counting it as income.',
),
label: 'Show in cashflow chart as inflow',
description:
'Show this transfer category as money entering available cash.',
},
];
@ -43,69 +39,111 @@ interface CategoryCashflowDirectionFieldsProps {
defaultValue?: CategoryCashflowDirection;
}
function getTypeEffectDescription(selectedType: CategoryType | ''): string {
switch (selectedType) {
case 'income':
return __(
'Income categories count as cash inflow and appear in income charts. They do not appear in top spending categories.',
);
case 'expense':
return __(
'Expense categories count as cash outflow and appear in spending charts, including top spending categories.',
);
case 'transfer':
return __(
'Transfer categories are excluded from income, expenses, and top spending categories. Choose whether to show them in the cashflow chart.',
);
case 'savings':
return __(
'Savings categories appear as saved money at the top of cashflow and as cash outflow in the cashflow chart. They stay out of income, expenses, and top spending categories.',
);
case 'investment':
return __(
'Investment categories appear as invested money at the top of cashflow and as cash outflow in the cashflow chart. They stay out of income, expenses, and top spending categories.',
);
default:
return __(
'Choose a category type to see how it affects cashflow and charts.',
);
}
}
export function CategoryCashflowDirectionFields({
selectedType,
defaultValue = 'hidden',
}: CategoryCashflowDirectionFieldsProps) {
const [cashflowDirection, setCashflowDirection] =
useState<CategoryCashflowDirection>(defaultValue);
const selectedOption = cashflowDirectionOptions.find(
const isTransfer = selectedType === 'transfer';
const selectedOption = transferCashflowOptions.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')}
{__('Cashflow and charts')}
</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 money flow chart.',
)}
<p className="text-xs leading-relaxed text-muted-foreground">
{getTypeEffectDescription(selectedType)}
</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>
{!isTransfer && (
<input
type="hidden"
name="cashflow_direction"
value={
selectedType === 'savings' ||
selectedType === 'investment'
? 'outflow'
: 'hidden'
}
/>
)}
<Alert>
<Info className="h-4 w-4 opacity-50" />
<AlertDescription className="text-xs leading-relaxed">
{__(
'Tracked transfers appear in the money flow Sankey chart. They are not counted as income or expenses.',
{isTransfer && (
<>
<Select
name="cashflow_direction"
value={cashflowDirection}
onValueChange={(value) =>
setCashflowDirection(
value as CategoryCashflowDirection,
)
}
required
>
<SelectTrigger
id="cashflow_direction"
data-testid="cashflow-direction-trigger"
>
<SelectValue
placeholder={__(
'Choose cashflow chart visibility',
)}
/>
</SelectTrigger>
<SelectContent>
{transferCashflowOptions.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>
)}
</AlertDescription>
</Alert>
</>
)}
</div>
);
}

View File

@ -1,6 +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';
import { CreateButton } from '@/components/ui/create-button';
@ -32,7 +31,6 @@ import {
import { __ } from '@/utils/i18n';
import { Form } from '@inertiajs/react';
import * as Icons from 'lucide-react';
import { Info } from 'lucide-react';
import { useState } from 'react';
export function CreateCategoryDialog({
@ -180,20 +178,11 @@ export function CreateCategoryDialog({
{errors.type}
</p>
)}
{selectedType === 'transfer' && (
<Alert>
<Info className="h-4 w-4 opacity-50" />
<AlertDescription className="text-sm">
{__(
'Transactions in this category will\n not be counted in top expenses or\n income. Transfer categories are\n mainly used for transactions between\n accounts.',
)}
</AlertDescription>
</Alert>
)}
</div>
<CategoryCashflowDirectionFields
selectedType={selectedType}
defaultValue="hidden"
/>
<div className="flex justify-end gap-2">
@ -206,9 +195,7 @@ export function CreateCategoryDialog({
{__('Cancel')}
</Button>
<Button type="submit" disabled={processing}>
{processing
? __('Creating...')
: __('Create')}
{processing ? __('Saving...') : __('Save')}
</Button>
</div>
</>

View File

@ -1,6 +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';
import {
@ -31,7 +30,6 @@ import {
import { __ } from '@/utils/i18n';
import { Form } from '@inertiajs/react';
import * as Icons from 'lucide-react';
import { Info } from 'lucide-react';
import { useState } from 'react';
interface EditCategoryDialogProps {
@ -193,16 +191,6 @@ export function EditCategoryDialog({
{errors.type}
</p>
)}
{selectedType === 'transfer' && (
<Alert>
<Info className="h-4 w-4 opacity-50" />
<AlertDescription className="text-sm">
{__(
'Transactions in this category will\n not be counted in top expenses or\n income. Transfer categories are\n mainly used for transactions between\n accounts.',
)}
</AlertDescription>
</Alert>
)}
</div>
<CategoryCashflowDirectionFields

View File

@ -95,6 +95,8 @@ it('can create a new category', function () {
->wait(0.5)
->click('//div[@role="option"][contains(., "Expense")]')
->wait(0.3)
->assertSee('Cashflow and charts')
->assertSee('Expense categories count as cash outflow and appear in spending charts, including top spending categories')
->click('button[type="submit"]')
->wait(2)
->assertSee('Entertainment')
@ -105,6 +107,94 @@ it('can create a new category', function () {
'name' => 'Entertainment',
'color' => 'purple',
'type' => 'expense',
'cashflow_direction' => 'hidden',
]);
});
it('explains savings and investment category cashflow impact in the create dialog', function (string $type, string $expectedText) {
$user = User::factory()->onboarded()->create();
actingAs($user);
$page = visit('/settings/categories');
$page->assertSee('Categories settings')
->click('Create Category')
->wait(0.5)
->click('Select a type')
->wait(0.5)
->click("//div[@role=\"option\"][contains(., \"{$type}\")]")
->wait(0.3)
->assertSee('Cashflow and charts')
->assertSee($expectedText)
->assertSee('stay out of income, expenses, and top spending categories')
->assertNoJavascriptErrors();
})->with([
['Savings', 'Savings categories appear as saved money at the top of cashflow and as cash outflow in the cashflow chart'],
['Investment', 'Investment categories appear as invested money at the top of cashflow and as cash outflow in the cashflow chart'],
]);
it('shows locked income cashflow direction in the create dialog', function () {
$user = User::factory()->onboarded()->create();
actingAs($user);
$page = visit('/settings/categories');
$page->assertSee('Categories settings')
->click('Create Category')
->wait(0.5)
->assertSee('Cashflow and charts')
->assertSee('Choose a category type to see how it affects cashflow and charts')
->click('Select a type')
->wait(0.5)
->click('//div[@role="option"][contains(., "Income")]')
->wait(0.3)
->assertSee('Income categories count as cash inflow and appear in income charts')
->assertSee('They do not appear in top spending categories')
->assertNoJavascriptErrors();
});
it('can create a new transfer category with a cashflow analytics direction', function () {
$user = User::factory()->onboarded()->create();
actingAs($user);
$page = visit('/settings/categories');
$page->assertSee('Categories settings')
->click('Create Category')
->wait(0.5)
->fill('name', 'Emergency Fund')
->click('Select an icon')
->wait(0.5)
->click('//div[@role="option"][contains(., "PiggyBank")]')
->wait(0.3)
->click('Select a color')
->wait(0.5)
->click('//div[@role="option"][contains(., "lime")]')
->wait(0.3)
->click('Select a type')
->wait(0.5)
->click('//div[@role="option"][contains(., "Transfer")]')
->wait(0.3)
->assertSee('Cashflow and charts')
->assertSee('Choose whether to show them in the cashflow chart')
->click('Do not show in cashflow chart')
->wait(0.5)
->click('//div[@role="option"][contains(., "Show in cashflow chart as outflow")]')
->wait(0.3)
->click('Save')
->wait(2)
->assertSee('Emergency Fund')
->assertNoJavascriptErrors();
$this->assertDatabaseHas('categories', [
'user_id' => $user->id,
'name' => 'Emergency Fund',
'color' => 'lime',
'type' => 'transfer',
'cashflow_direction' => 'outflow',
]);
});
@ -190,8 +280,8 @@ it('shows transfer type description when transfer type is selected in create dia
->wait(0.5)
->click('//div[@role="option"][contains(., "Transfer")]')
->wait(0.5)
->assertSee('Transactions in this category will not be counted in top expenses or income')
->assertSee('Transfer categories are mainly used for transactions between accounts')
->assertSee('Transfer categories are excluded from income, expenses, and top spending categories')
->assertSee('Choose whether to show them in the cashflow chart')
->assertNoJavascriptErrors();
});
@ -219,7 +309,7 @@ it('shows transfer type description when transfer type is selected in edit dialo
->wait(0.5)
->click('//div[@role="option"][contains(., "Transfer")]')
->wait(0.5)
->assertSee('Transactions in this category will not be counted in top expenses or income')
->assertSee('Transfer categories are mainly used for transactions between accounts')
->assertSee('Transfer categories are excluded from income, expenses, and top spending categories')
->assertSee('Choose whether to show them in the cashflow chart')
->assertNoJavascriptErrors();
});

View File

@ -1095,6 +1095,73 @@ test('sankey includes outflow transfer categories on the expense side', function
expect(collect($data['income_categories'])->pluck('category.name'))->not->toContain('Investments');
});
test('sankey includes savings and investment categories on the expense side', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
'name' => 'Salary',
]);
$savingsCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Savings,
'cashflow_direction' => CategoryCashflowDirection::Hidden,
'name' => 'Emergency Savings',
]);
$investmentCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Investment,
'cashflow_direction' => CategoryCashflowDirection::Hidden,
'name' => 'Brokerage',
]);
$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' => 300000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $savingsCategory->id,
'amount' => -40000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $investmentCategory->id,
'amount' => -60000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['total_income'])->toBe(300000);
expect($data['total_expense'])->toBe(100000);
$savingsExpense = collect($data['expense_categories'])->firstWhere('category.name', 'Emergency Savings');
expect($savingsExpense)->not->toBeNull();
expect($savingsExpense['amount'])->toBe(40000);
$investmentExpense = collect($data['expense_categories'])->firstWhere('category.name', 'Brokerage');
expect($investmentExpense)->not->toBeNull();
expect($investmentExpense['amount'])->toBe(60000);
expect(collect($data['income_categories'])->pluck('category.name'))
->not->toContain('Emergency Savings')
->not->toContain('Brokerage');
});
test('sankey includes inflow transfer categories on the income side', function () {
$expenseCategory = Category::factory()->create([
'user_id' => $this->user->id,

View File

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

View File

@ -360,7 +360,7 @@ test('users can create savings and investment categories', function (string $typ
'user_id' => $user->id,
'name' => ucfirst($type),
'type' => $type,
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
'cashflow_direction' => CategoryCashflowDirection::Outflow->value,
]);
})->with([
CategoryType::Savings->value,
@ -431,6 +431,44 @@ test('migration updates existing default saving and investment categories', func
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
});
test('migration sets saving and investment categories to cashflow outflow', function () {
$user = User::factory()->create();
$savings = Category::factory()->create([
'user_id' => $user->id,
'type' => CategoryType::Savings,
'cashflow_direction' => CategoryCashflowDirection::Hidden,
]);
$investment = Category::factory()->create([
'user_id' => $user->id,
'type' => CategoryType::Investment,
'cashflow_direction' => CategoryCashflowDirection::Hidden,
]);
$transfer = Category::factory()->create([
'user_id' => $user->id,
'type' => CategoryType::Transfer,
'cashflow_direction' => CategoryCashflowDirection::Inflow,
]);
$expense = Category::factory()->create([
'user_id' => $user->id,
'type' => CategoryType::Expense,
'cashflow_direction' => CategoryCashflowDirection::Hidden,
]);
$migration = require database_path('migrations/2026_05_29_085835_update_saving_and_investment_category_cashflow_direction.php');
$migration->up();
expect($savings->refresh()->cashflow_direction)->toBe(CategoryCashflowDirection::Outflow);
expect($investment->refresh()->cashflow_direction)->toBe(CategoryCashflowDirection::Outflow);
expect($transfer->refresh()->cashflow_direction)->toBe(CategoryCashflowDirection::Inflow);
expect($expense->refresh()->cashflow_direction)->toBe(CategoryCashflowDirection::Hidden);
$migration->down();
expect($savings->refresh()->cashflow_direction)->toBe(CategoryCashflowDirection::Hidden);
expect($investment->refresh()->cashflow_direction)->toBe(CategoryCashflowDirection::Hidden);
});
test('users cannot update categories they do not own', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
@ -500,13 +538,13 @@ test('default categories are created when user registers', function () {
expect($user->categories()->firstWhere('name', 'Investments'))
->type->toBe(CategoryType::Investment)
->cashflow_direction->toBe(CategoryCashflowDirection::Hidden);
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
expect($user->categories()->firstWhere('name', 'Savings'))
->type->toBe(CategoryType::Savings)
->cashflow_direction->toBe(CategoryCashflowDirection::Hidden);
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
expect($user->categories()->firstWhere('name', 'Other investments'))
->type->toBe(CategoryType::Investment)
->cashflow_direction->toBe(CategoryCashflowDirection::Hidden);
->cashflow_direction->toBe(CategoryCashflowDirection::Outflow);
expect($user->categories()->firstWhere('name', 'From account of relatives'))
->type->toBe(CategoryType::Transfer)
->cashflow_direction->toBe(CategoryCashflowDirection::Inflow);

View File

@ -46,7 +46,7 @@ test('accounts index page does not exceed query threshold', function () {
test('account show page does not exceed query threshold', function () {
$account = $this->user->accounts()->first();
assertMaxQueries(17, function () use ($account) {
assertMaxQueries(18, function () use ($account) {
$this->get(route('accounts.show', $account))->assertOk();
}, 'Account Show');
});