fix(dashboard): treat loans as debt in net worth (#238)

## Summary
- fix dashboard net worth math so loan balances reduce totals and trends
instead of showing as positive assets
- add a per-user toggle in the net worth chart settings to include or
exclude loans from the dashboard net worth card
- cover the new preference and liability handling with feature and
frontend tests

## Screenshots
<img width="1121" height="509" alt="image"
src="https://github.com/user-attachments/assets/ab6a7cde-1052-4dab-aa14-34f6d9528829"
/>
<img width="1122" height="506" alt="image"
src="https://github.com/user-attachments/assets/e48a0369-16c4-4294-ae00-4eba56480264"
/>
This commit is contained in:
Víctor Falcón 2026-03-20 09:55:53 +00:00 committed by GitHub
parent 6dda5f56ad
commit f140b5df7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 966 additions and 172 deletions

View File

@ -19,4 +19,9 @@ enum AccountType: string
{
return in_array($this, [self::Investment, self::Retirement, self::Savings], true);
}
public function reducesNetWorth(): bool
{
return in_array($this, [self::CreditCard, self::Loan], true);
}
}

View File

@ -282,12 +282,16 @@ class DashboardAnalyticsController extends Controller
->orderBy('balance_date', 'desc')
->value('balance') ?? 0;
$total += $this->exchangeRateService->convert(
$convertedBalance = $this->exchangeRateService->convert(
$account->currency_code,
$userCurrency,
$balance,
$date->toDateString(),
);
$total += $account->type->reducesNetWorth()
? -abs($convertedBalance)
: $convertedBalance;
}
return $total;

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\UpdateNetWorthChartLoanPreferenceRequest;
use Illuminate\Http\RedirectResponse;
class NetWorthChartLoanPreferenceController extends Controller
{
public function update(UpdateNetWorthChartLoanPreferenceRequest $request): RedirectResponse
{
$request->user()->setting()->updateOrCreate(
['user_id' => $request->user()->id],
['include_loans_in_net_worth_chart' => $request->boolean('include_loans_in_net_worth_chart')]
);
return back();
}
}

View File

@ -85,6 +85,7 @@ class HandleInertiaRequests extends Middleware
'currency' => strtoupper(config('cashier.currency', 'eur')),
],
'chartColorScheme' => $user?->setting?->chart_color_scheme->value ?? 'colorful',
'includeLoansInNetWorthChart' => $user?->setting->include_loans_in_net_worth_chart ?? true,
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'features' => [
'cashflow' => true,

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNetWorthChartLoanPreferenceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'include_loans_in_net_worth_chart' => ['required', 'boolean'],
];
}
}

View File

@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property \App\Enums\ChartColorScheme $chart_color_scheme
* @property bool $include_loans_in_net_worth_chart
*/
class UserSetting extends Model
{
@ -19,12 +20,14 @@ class UserSetting extends Model
protected $fillable = [
'user_id',
'chart_color_scheme',
'include_loans_in_net_worth_chart',
];
protected function casts(): array
{
return [
'chart_color_scheme' => ChartColorScheme::class,
'include_loans_in_net_worth_chart' => 'boolean',
];
}

View File

@ -21,6 +21,7 @@ class UserSettingFactory extends Factory
return [
'user_id' => User::factory(),
'chart_color_scheme' => ChartColorScheme::Colorful,
'include_loans_in_net_worth_chart' => true,
];
}

View File

@ -0,0 +1,28 @@
<?php
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('user_settings', function (Blueprint $table) {
$table->boolean('include_loans_in_net_worth_chart')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('user_settings', function (Blueprint $table) {
$table->dropColumn('include_loans_in_net_worth_chart');
});
}
};

View File

@ -163,6 +163,7 @@
"Are you overspending? Know exactly where you stand.": "\u00bfEst\u00e1s gastando de m\u00e1s? Conoce exactamente d\u00f3nde est\u00e1s parado.",
"Are you sure you want to delete \"": "\u00bfEst\u00e1s seguro de que deseas eliminar \"",
"Are you sure you want to delete this balance record? This action cannot be undone.": "\u00bfEst\u00e1s seguro de que deseas eliminar este registro de balance? Esta acci\u00f3n no se puede deshacer.",
"Are you sure you want to delete this owed amount record? This action cannot be undone.": "\u00bfEst\u00e1s seguro de que deseas eliminar este registro de monto adeudado? Esta acci\u00f3n no se puede deshacer.",
"Are you sure you want to delete this transaction? This action cannot be undone.": "\u00bfEst\u00e1s seguro de que quieres eliminar esta transacci\u00f3n? Esta acci\u00f3n no se puede deshacer.",
"Are you sure you want to delete your account?": "\u00bfEst\u00e1s seguro de que deseas eliminar tu cuenta?",
"As a developer, I appreciate the security architecture. This is how finance apps should be built.": "Como desarrollador, aprecio la arquitectura de seguridad. As\u00ed es como deber\u00edan construirse las apps de finanzas.",
@ -196,6 +197,7 @@
"Balance Date": "Fecha del Balance",
"Balance History": "Historial de Balance",
"Balance Tracking": "Seguimiento de Balance",
"Balances": "Balances",
"Balance evolution": "Evoluci\u00f3n del balance",
"Bank": "Banco",
"Bank Account": "Cuenta Bancaria",
@ -394,6 +396,7 @@
"Delete account": "Eliminar cuenta",
"Delete accounts": "Eliminar cuentas",
"Delete balance": "Eliminar balance",
"Delete owed amount": "Eliminar monto adeudado",
"Delete budget": "Eliminar presupuesto",
"Delete your account and all of its resources": "Elimina tu cuenta y todos sus recursos",
"Deleting...": "Eliminando...",
@ -426,6 +429,7 @@
"Edit Balance": "Editar Balance",
"Edit Budget": "Editar Presupuesto",
"Edit Category": "Editar Categor\u00eda",
"Edit Owed Amount": "Editar Monto Adeudado",
"Edit Label": "Editar Etiqueta",
"Edit Transaction": "Editar transacci\u00f3n",
"Edit account": "Editar cuenta",
@ -623,6 +627,7 @@
"Import a year's worth of transactions in under 10 seconds. Simply export a CSV or XLS file from your bank and drag it into Whisper Money. All data is encrypted locally before upload.": "Importa transacciones de un a\u00f1o completo en menos de 10 segundos. Simplemente exporta un archivo CSV o XLS desde tu banco y arr\u00e1stralo a Whisper Money. Todos los datos se encriptan localmente antes de subirse.",
"Import balance history from CSV files": "Importar historial de balances desde archivos CSV",
"Import balances": "Importar balances",
"Import owed amounts": "Importar montos adeudados",
"Import in Seconds": "Importa en Segundos",
"Import in seconds": "Importa en segundos",
"Import transactions": "Importar transacciones",
@ -635,6 +640,8 @@
"Importing Transactions": "Importando Transacciones",
"Importing your balances...": "Importando tus saldos...",
"Imported": "Importado",
"Include loan balances in the net worth totals and chart": "Incluir los saldos de pr\u00e9stamos en los totales y el gr\u00e1fico del patrimonio neto",
"Include loans": "Incluir pr\u00e9stamos",
"Income": "Ingresos",
"Income (Salary, Freelance, Investments)": "Ingresos (Salario, Freelance, Inversiones)",
"Income Sources": "Fuentes de Ingresos",
@ -697,9 +704,11 @@
"Load more": "Cargar m\u00e1s",
"Loading": "Cargando",
"Loading last balance...": "Cargando \u00faltimo balance...",
"Loading last owed amount...": "Cargando \u00faltimo monto adeudado...",
"Loading recovery codes": "Cargando c\u00f3digos de recuperaci\u00f3n",
"Loading...": "Cargando...",
"Loan": "Pr\u00e9stamo",
"Loans": "Pr\u00e9stamos",
"Lock encryption key": "Bloquear clave de encriptaci\u00f3n",
"Log in": "Iniciar sesi\u00f3n",
"Log in to your account": "Inicia sesi\u00f3n en tu cuenta",
@ -802,11 +811,15 @@
"No income this period": "Sin ingresos en este per\u00edodo",
"No labels found.": "No se encontraron etiquetas.",
"No limits on bank accounts, transactions, or categories.": "Sin l\u00edmites en cuentas bancarias, transacciones o categor\u00edas.",
"No owed amount data available": "No hay datos de monto adeudado disponibles",
"No owed amount records found.": "No se encontraron registros de monto adeudado.",
"No results found": "No se encontraron resultados",
"No spending data this month": "Sin datos de gasto este mes",
"No third-party sharing, no AI snooping. Your financial data belongs to you and only you.": "Sin compartir con terceros, sin espionaje de IA. Tus datos financieros te pertenecen a ti y solo a ti.",
"No tracking": "Sin seguimiento",
"No transactions found.": "No se encontraron transacciones.",
"No valid balances found": "No se encontraron balances v\u00e1lidos",
"No valid owed amounts found": "No se encontraron montos adeudados v\u00e1lidos",
"No worries! Your encryption key is automatically generated and stored securely on your device. You don't need to remember anything - I've made it as simple as possible.": "\u00a1No te preocupes! Tu clave de encriptaci\u00f3n se genera autom\u00e1ticamente y se almacena de forma segura en tu dispositivo. No necesitas recordar nada, lo he hecho lo m\u00e1s simple posible.",
"No. We never ask for your bank credentials. You import transactions by exporting a CSV or XLS file from your bank and uploading it to Whisper Money. This keeps your bank account secure.": "No. Nunca pedimos tus credenciales bancarias. Importas transacciones exportando un archivo CSV o XLS de tu banco y subi\u00e9ndolo a Whisper Money. Esto mantiene tu cuenta bancaria segura.",
"None": "Ninguno",
@ -835,6 +848,10 @@
"Our service is not intended for users under the age of 16. We do not knowingly collect personal information from children. If you believe we have collected information from a child, please contact us immediately, and we will delete it.": "Nuestro servicio no est\u00e1 destinado a usuarios menores de 16 a\u00f1os. No recopilamos intencionalmente informaci\u00f3n personal de ni\u00f1os. Si crees que hemos recopilado informaci\u00f3n de un ni\u00f1o, por favor cont\u00e1ctanos inmediatamente y la eliminaremos.",
"Our service is not intended for users under the\\n age of 16. We do not knowingly collect personal\\n information from children. If you believe we\\n have collected information from a child, please\\n contact us immediately, and we will delete it.": "Nuestro servicio no est\u00e1 destinado a usuarios menores de 16 a\u00f1os. No recopilamos intencionalmente informaci\u00f3n personal de ni\u00f1os. Si crees que hemos recopilado informaci\u00f3n de un ni\u00f1o, cont\u00e1ctanos de inmediato y la eliminaremos.",
"Overview of your financial health": "Vista general de tu salud financiera",
"Owed Amount": "Monto Adeudado",
"Owed Amount History": "Historial de Monto Adeudado",
"Owed Amounts": "Montos Adeudados",
"Owed amount evolution": "Evoluci\u00f3n del monto adeudado",
"Page": "P\u00e1gina",
"Password": "Contrase\u00f1a",
"Password changes are disabled on the demo account.": "Los cambios de contrase\u00f1a est\u00e1n deshabilitados en la cuenta demo.",
@ -879,6 +896,7 @@
"Prev": "Ant",
"Preview (first 3 rows)": "Vista previa (primeras 3 filas)",
"Preview Balances": "Vista Previa de Balances",
"Preview Owed Amounts": "Vista Previa de Montos Adeudados",
"Preview Transactions": "Vista Previa de Transacciones",
"Previous": "Anterior",
"Previous month": "Mes anterior",
@ -980,6 +998,7 @@
"Secure upload": "Carga segura",
"See balances": "Ver balances",
"See every account in one place. Track balances, monitor changes, and always know where you stand.": "Ve todas tus cuentas en un solo lugar. Sigue los saldos, monitoriza los cambios y sabe siempre c\u00f3mo est\u00e1s.",
"See owed amounts": "Ver montos adeudados",
"See where you spend": "Ve en qu\u00e9 gastas",
"See where you stand in real-time. Visual progress bars show spending vs. budget.": "Ve tu situaci\u00f3n en tiempo real. Las barras de progreso visuales muestran el gasto frente al presupuesto.",
"Select Account": "Seleccionar Cuenta",
@ -1000,6 +1019,7 @@
"Select balance column": "Seleccionar columna de balance",
"Select balance column (optional)": "Seleccionar columna de balance (opcional)",
"Select bank...": "Seleccionar banco...",
"Select owed amount column": "Seleccionar columna de monto adeudado",
"Select categories...": "Seleccionar categor\u00edas...",
"Select country": "Seleccionar pa\u00eds",
"Select currency": "Seleccionar moneda",
@ -1027,6 +1047,7 @@
"Set spending limits and get real-time updates on your financial goals.": "Establece l\u00edmites de gasto y obt\u00e9n actualizaciones en tiempo real sobre tus metas financieras.",
"Set the balance for this account on a specific date.": "Actualiza el balance de esta cuenta.",
"Set the current balance for this account to start tracking.": "Establece el balance actual de esta cuenta para empezar a rastrear.",
"Set the owed amount for this account on a specific date.": "Establece el monto adeudado de esta cuenta en una fecha espec\u00edfica.",
"Set up a spending limit for a category or label.": "Configura un l\u00edmite de gasto para un categor\u00eda o etiqueta.",
"Set your budget goals": "Establece tus objetivos de presupuesto",
"Setting things up...": "Configurando...",
@ -1245,12 +1266,14 @@
"Update account balance": "Actualizar balance de cuenta",
"Update balance": "Actualizar balance",
"Update balances periodically to track growth": "Actualiza los balances peri\u00f3dicamente para rastrear el crecimiento",
"Update owed amount": "Actualizar monto adeudado",
"Update password": "Actualizar contrase\u00f1a",
"Update the account information.": "Actualiza la informaci\u00f3n de la cuenta.",
"Update the balance record.": "Actualiza el registro de balance.",
"Update the category and notes for this transaction.": "Actualiza la categor\u00eda y notas de esta transacci\u00f3n.",
"Update the category information.": "Actualiza la informaci\u00f3n de la categor\u00eda.",
"Update the label information.": "Actualiza la informaci\u00f3n de la etiqueta.",
"Update the owed amount record.": "Actualiza el registro de monto adeudado.",
"Update the rule to automatically categorize transactions and add labels.": "Actualiza la regla para categorizar autom\u00e1ticamente transacciones y agregar etiquetas.",
"Update the rule to automatically categorize transactions.": "Actualiza la regla para categorizar transacciones autom\u00e1ticamente.",
"Update your account's appearance settings": "Actualiza la configuraci\u00f3n de apariencia de tu cuenta",
@ -1282,6 +1305,7 @@
"View Details": "Ver Detalles",
"View Transactions": "Ver Transacciones",
"View and manage balance records for this account.": "Ver y gestionar registros de balance para esta cuenta.",
"View and manage owed amount records for this account.": "Ver y gestionar registros de monto adeudado para esta cuenta.",
"View and manage your bank accounts": "Ver y administrar tus cuentas bancarias",
"View and manage your transactions": "Ver y gestionar tus transacciones",
"View balance evolution over time": "Ver evoluci\u00f3n del balance a lo largo del tiempo",
@ -1504,8 +1528,10 @@
"accounts": "cuentas",
"after 3 months with Whisper Money": "despu\u00e9s de 3 meses con Whisper Money",
"amber": "\u00e1mbar",
"balance": "balance",
"balance record": "registro de balance",
"balance records": "registros de balance",
"balances": "balances",
"balances imported": "balances importados",
"blue": "azul",
"button in your browser toolbar": "en la barra de herramientas de tu navegador",
@ -1542,6 +1568,11 @@
"or you can": "o puedes",
"or, enter the code manually": "o, ingresa el c\u00f3digo manualmente",
"orange": "naranja",
"owed amount": "monto adeudado",
"owed amount record": "registro de monto adeudado",
"owed amount records": "registros de monto adeudado",
"owed amounts": "montos adeudados",
"owed amounts imported": "montos adeudados importados",
"per month": "por mes",
"per year": "por a\u00f1o",
"pink": "rosa",
@ -1570,7 +1601,6 @@
"vs last month": "vs mes anterior",
"vs last period": "vs per\u00edodo anterior",
"will be updated or created.": "ser\u00e1 actualizado o creado.",
"will be updated or\\n created.": "ser\u00e1n actualizadas o creadas.",
"yellow": "amarillo",
"\u2014 $9/month": "\u2014 $9/mes",
"\u2190 Back to home": "\u2190 Volver al inicio",

View File

@ -227,6 +227,7 @@ export function AccountBalanceChart({
}: AccountBalanceChartProps) {
const locale = useLocale();
const isMobile = useIsMobile();
const isLoan = account.type === 'loan';
const [granularity, setGranularity] = useState<ChartGranularity>('monthly');
const [balanceData, setBalanceData] = useState<AccountBalanceData | null>(
null,
@ -384,7 +385,11 @@ export function AccountBalanceChart({
return (
<Card>
<CardHeader>
<CardTitle>{__('Balance evolution')}</CardTitle>
<CardTitle>
{isLoan
? __('Owed amount evolution')
: __('Balance evolution')}
</CardTitle>
<CardDescription>
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</CardDescription>
@ -400,11 +405,17 @@ export function AccountBalanceChart({
return (
<Card>
<CardHeader>
<CardTitle>{__('Balance evolution')}</CardTitle>
<CardTitle>
{isLoan
? __('Owed amount evolution')
: __('Balance evolution')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
{__('No balance data available')}
{isLoan
? __('No owed amount data available')
: __('No balance data available')}
</div>
</CardContent>
</Card>
@ -416,7 +427,11 @@ export function AccountBalanceChart({
<CardHeader>
<div className="flex flex-row items-start justify-between">
<div className="flex flex-col gap-1 sm:gap-2">
<CardTitle>{__('Balance evolution')}</CardTitle>
<CardTitle>
{isLoan
? __('Owed amount evolution')
: __('Balance evolution')}
</CardTitle>
<button
type="button"
onClick={onBalanceClick}

View File

@ -252,7 +252,9 @@ export function AccountListCard({
variant="secondary"
onClick={() => setUpdateBalanceOpen(true)}
>
{__('Update balance')}
{account.type === 'loan'
? __('Update owed amount')
: __('Update balance')}
</Button>
)}

View File

@ -10,9 +10,6 @@ interface AccountNameProps {
length?: Length;
}
export function AccountName({
account,
className = '',
}: Omit<AccountNameProps, 'length'>) {
export function AccountName({ account, className = '' }: AccountNameProps) {
return <span className={className}>{account.name}</span>;
}

View File

@ -35,7 +35,10 @@ import {
} from '@/components/ui/table';
import { useLocale } from '@/hooks/use-locale';
import type { Account, AccountBalance } from '@/types/account';
import { supportsInvestedAmount } from '@/types/account';
import {
balanceTermCapitalized,
supportsInvestedAmount,
} from '@/types/account';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { Pencil, Trash2 } from 'lucide-react';
@ -87,6 +90,7 @@ export function BalancesModal({
formatCurrency(valueInCents, account.currency_code, locale);
const showInvestedAmount = supportsInvestedAmount(account);
const isLoan = account.type === 'loan';
const fetchBalances = useCallback(
async (page: number) => {
@ -236,11 +240,19 @@ export function BalancesModal({
}
>
<DialogHeader>
<DialogTitle>{__('Balance History')}</DialogTitle>
<DialogTitle>
{isLoan
? __('Owed Amount History')
: __('Balance History')}
</DialogTitle>
<DialogDescription>
{__(
'View and manage balance records for this account.',
)}
{isLoan
? __(
'View and manage owed amount records for this account.',
)
: __(
'View and manage balance records for this account.',
)}
</DialogDescription>
</DialogHeader>
@ -257,7 +269,9 @@ export function BalancesModal({
<TableRow>
<TableHead>{__('Date')}</TableHead>
<TableHead className="text-right">
{__('Balance')}
{balanceTermCapitalized(
account.type,
)}
</TableHead>
{showInvestedAmount && (
<TableHead className="text-right">
@ -289,9 +303,13 @@ export function BalancesModal({
}
className="h-24 text-center"
>
{__(
'No balance records found.',
)}
{isLoan
? __(
'No owed amount records found.',
)
: __(
'No balance records found.',
)}
</TableCell>
</TableRow>
) : (
@ -356,8 +374,12 @@ export function BalancesModal({
<span className="text-sm text-muted-foreground">
{total}{' '}
{total === 1
? __('balance record')
: __('balance records')}
? isLoan
? __('owed amount record')
: __('balance record')
: isLoan
? __('owed amount records')
: __('balance records')}
</span>
<div className="flex items-center gap-2">
<Button
@ -398,15 +420,23 @@ export function BalancesModal({
>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{__('Edit Balance')}</DialogTitle>
<DialogTitle>
{isLoan
? __('Edit Owed Amount')
: __('Edit Balance')}
</DialogTitle>
<DialogDescription>
{__('Update the balance record.')}
{isLoan
? __('Update the owed amount record.')
: __('Update the balance record.')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleEditSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-amount">{__('Balance')}</Label>
<Label htmlFor="edit-amount">
{balanceTermCapitalized(account.type)}
</Label>
<AmountInput
id="edit-amount"
className="mt-1"
@ -472,12 +502,18 @@ export function BalancesModal({
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{__('Delete balance')}
{isLoan
? __('Delete owed amount')
: __('Delete balance')}
</AlertDialogTitle>
<AlertDialogDescription>
{__(
'Are you sure you want to delete this balance record? This action cannot be undone.',
)}
{isLoan
? __(
'Are you sure you want to delete this owed amount record? This action cannot be undone.',
)
: __(
'Are you sure you want to delete this balance record? This action cannot be undone.',
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@ -42,6 +42,7 @@ interface ImportBalancesDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
accounts?: Account[];
account?: Account;
accountId?: UUID;
onSuccess?: () => void;
}
@ -59,6 +60,7 @@ export function ImportBalancesDrawer({
open,
onOpenChange,
accounts = [],
account: providedAccount,
accountId,
onSuccess,
}: ImportBalancesDrawerProps) {
@ -94,15 +96,17 @@ export function ImportBalancesDrawer({
});
useEffect(() => {
if (state.selectedAccountId) {
const account = accounts.find(
(a) => a.id === state.selectedAccountId,
);
if (open && state.selectedAccountId) {
const account =
(providedAccount?.id === state.selectedAccountId
? providedAccount
: undefined) ??
accounts.find((a) => a.id === state.selectedAccountId);
if (account) {
setSelectedAccount(account);
}
}
}, [state.selectedAccountId, accounts]);
}, [open, state.selectedAccountId, accounts, providedAccount]);
useEffect(() => {
if (!open) {
@ -504,10 +508,13 @@ export function ImportBalancesDrawer({
const successCount = createdBalances.length;
const errorCount = errors.length;
const isLoan = selectedAccount?.type === 'loan';
const term = isLoan ? 'owed amount' : 'balance';
const termPlural = isLoan ? 'owed amounts' : 'balances';
if (errorCount === 0 && successCount > 0) {
toast.success(
`${successCount} balance${successCount !== 1 ? 's' : ''} imported successfully`,
`${successCount} ${successCount !== 1 ? termPlural : term} imported successfully`,
{
icon: <Check className="h-4 w-4" />,
},
@ -516,12 +523,12 @@ export function ImportBalancesDrawer({
onOpenChange(false);
} else if (successCount > 0 && errorCount > 0) {
toast.warning(
`${successCount} balance${successCount !== 1 ? 's' : ''} imported, ${errorCount} failed`,
`${successCount} ${successCount !== 1 ? termPlural : term} imported, ${errorCount} failed`,
);
onSuccess?.();
} else if (successCount > 0) {
toast.success(
`${successCount} balance${successCount !== 1 ? 's' : ''} imported successfully`,
`${successCount} ${successCount !== 1 ? termPlural : term} imported successfully`,
{
icon: <Check className="h-4 w-4" />,
},
@ -529,7 +536,11 @@ export function ImportBalancesDrawer({
onSuccess?.();
onOpenChange(false);
} else {
toast.error('All balances failed to import');
toast.error(
isLoan
? 'All owed amounts failed to import'
: 'All balances failed to import',
);
}
};
@ -538,12 +549,15 @@ export function ImportBalancesDrawer({
};
const getStepInfo = () => {
const isLoan = selectedAccount?.type === 'loan';
switch (state.step) {
case BalanceImportStep.SelectAccount:
return {
title: 'Select Account',
description:
'Choose the account where balances will be imported',
description: isLoan
? 'Choose the account where owed amounts will be imported'
: 'Choose the account where balances will be imported',
};
case BalanceImportStep.UploadFile:
return {
@ -554,25 +568,34 @@ export function ImportBalancesDrawer({
case BalanceImportStep.MapColumns:
return {
title: 'Map Columns',
description: 'Match your file columns to balance fields',
description: isLoan
? 'Match your file columns to owed amount fields'
: 'Match your file columns to balance fields',
};
case BalanceImportStep.Preview:
return {
title: 'Preview Balances',
description: 'Review balances before importing',
title: isLoan ? 'Preview Owed Amounts' : 'Preview Balances',
description: isLoan
? 'Review owed amounts before importing'
: 'Review balances before importing',
};
default:
if (isImporting) {
return {
title: 'Importing Balances',
description:
'Please wait while we import your balances',
title: isLoan
? 'Importing Owed Amounts'
: 'Importing Balances',
description: isLoan
? 'Please wait while we import your owed amounts'
: 'Please wait while we import your balances',
};
}
return {
title: 'Import Balances',
description: 'Import balances from CSV or Excel files',
title: isLoan ? 'Import Owed Amounts' : 'Import Balances',
description: isLoan
? 'Import owed amounts from CSV or Excel files'
: 'Import balances from CSV or Excel files',
};
}
};
@ -624,6 +647,7 @@ export function ImportBalancesDrawer({
parsedData={state.parsedData}
currencyCode={selectedAccount?.currency_code || 'USD'}
showInvestedAmount={showInvestedAmount}
isLoan={selectedAccount?.type === 'loan'}
onMappingChange={handleMappingChange}
onDateFormatChange={handleDateFormatChange}
onNext={handlePreviewBalances}
@ -637,6 +661,7 @@ export function ImportBalancesDrawer({
balances={state.balances}
currencyCode={selectedAccount?.currency_code || 'USD'}
showInvestedAmount={showInvestedAmount}
isLoan={selectedAccount?.type === 'loan'}
onConfirm={handleConfirmImport}
onBack={handleBack}
isImporting={isImporting}
@ -651,6 +676,7 @@ export function ImportBalancesDrawer({
const renderImportProgress = () => {
const percentage =
importTotal > 0 ? (importProgress / importTotal) * 100 : 0;
const isLoan = selectedAccount?.type === 'loan';
return (
<div className="flex flex-col gap-6">
@ -658,7 +684,9 @@ export function ImportBalancesDrawer({
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{importProgress} of {importTotal}
{__('balances imported')}
{isLoan
? __('owed amounts imported')
: __('balances imported')}
</span>
<span>{Math.round(percentage)}%</span>
</div>
@ -684,7 +712,9 @@ export function ImportBalancesDrawer({
{__('Date')}
</th>
<th className="px-4 py-2 text-left font-medium">
{__('Balance')}
{isLoan
? __('Owed Amount')
: __('Balance')}
</th>
<th className="px-4 py-2 text-left font-medium">
{__('Error')}

View File

@ -10,6 +10,7 @@ import {
} from '@/components/ui/select';
import { useLocale } from '@/hooks/use-locale';
import { parseAmount, parseDate } from '@/lib/file-parser';
import { balanceTermCapitalized } from '@/types/account';
import type {
BalanceColumnMapping,
ColumnOption,
@ -26,6 +27,7 @@ interface ImportBalanceStepMappingProps {
parsedData: ParsedRow[];
currencyCode: string;
showInvestedAmount: boolean;
isLoan?: boolean;
onMappingChange: (field: keyof BalanceColumnMapping, value: string) => void;
onDateFormatChange: (format: DateFormat) => void;
onNext: () => void;
@ -40,6 +42,7 @@ export function ImportBalanceStepMapping({
parsedData,
currencyCode,
showInvestedAmount,
isLoan = false,
onMappingChange,
onDateFormatChange,
onNext,
@ -128,7 +131,7 @@ export function ImportBalanceStepMapping({
<div className="space-y-2">
<Label htmlFor="balance-column">
{__('Balance')}
{balanceTermCapitalized(isLoan ? 'loan' : 'checking')}
<span className="text-destructive">*</span>
</Label>
<Select
@ -139,7 +142,11 @@ export function ImportBalanceStepMapping({
>
<SelectTrigger id="balance-column">
<SelectValue
placeholder={__('Select balance column')}
placeholder={
isLoan
? __('Select owed amount column')
: __('Select balance column')
}
/>
</SelectTrigger>
<SelectContent>
@ -275,7 +282,9 @@ export function ImportBalanceStepMapping({
{__('Back')}
</Button>
<Button onClick={onNext} disabled={!isValid}>
{__('Preview Balances')}
{isLoan
? __('Preview Owed Amounts')
: __('Preview Balances')}
</Button>
</div>
</div>

View File

@ -8,6 +8,7 @@ import {
TableRow,
} from '@/components/ui/table';
import { useLocale } from '@/hooks/use-locale';
import { balanceTermCapitalized } from '@/types/account';
import { type ParsedBalance } from '@/types/balance-import';
import { formatCurrency } from '@/utils/currency';
import { formatDateMedium } from '@/utils/date';
@ -17,6 +18,7 @@ interface ImportBalanceStepPreviewProps {
balances: ParsedBalance[];
currencyCode: string;
showInvestedAmount: boolean;
isLoan?: boolean;
onConfirm: () => void;
onBack: () => void;
isImporting: boolean;
@ -26,6 +28,7 @@ export function ImportBalanceStepPreview({
balances,
currencyCode,
showInvestedAmount,
isLoan = false,
onConfirm,
onBack,
isImporting,
@ -44,8 +47,15 @@ export function ImportBalanceStepPreview({
<div className="flex flex-col gap-6">
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-sm text-muted-foreground">
{total} balance{total !== 1 ? 's' : ''}
{__('will be updated or\n created.')}
{total}{' '}
{isLoan
? total !== 1
? __('owed amounts')
: __('owed amount')
: total !== 1
? __('balances')
: __('balance')}{' '}
{__('will be updated or created.')}
</p>
</div>
@ -60,7 +70,9 @@ export function ImportBalanceStepPreview({
</TableHead>
)}
<TableHead className="text-right">
{__('Balance')}
{balanceTermCapitalized(
isLoan ? 'loan' : 'checking',
)}
</TableHead>
</TableRow>
</TableHeader>
@ -71,7 +83,9 @@ export function ImportBalanceStepPreview({
colSpan={colSpan}
className="text-center text-muted-foreground"
>
No valid balances found
{isLoan
? __('No valid owed amounts found')
: __('No valid balances found')}
</TableCell>
</TableRow>
) : (
@ -116,7 +130,9 @@ export function ImportBalanceStepPreview({
>
{isImporting
? 'Importing...'
: `Import ${total} Balance${total !== 1 ? 's' : ''}`}
: isLoan
? `Import ${total} Owed Amount${total !== 1 ? 's' : ''}`
: `Import ${total} Balance${total !== 1 ? 's' : ''}`}
</Button>
</div>
</div>

View File

@ -17,6 +17,7 @@ import { Label } from '@/components/ui/label';
import {
type Account,
type AccountBalance,
balanceTermCapitalized,
supportsInvestedAmount,
} from '@/types/account';
import { __ } from '@/utils/i18n';
@ -52,6 +53,7 @@ export function UpdateBalanceDialog({
const [isLoadingLastBalance, setIsLoadingLastBalance] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const showInvestedAmount = supportsInvestedAmount(account);
const isLoan = account.type === 'loan';
useEffect(() => {
async function fetchLastBalance() {
@ -162,20 +164,32 @@ export function UpdateBalanceDialog({
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent hasKeyboard className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{__('Update balance')}</DialogTitle>
<DialogTitle>
{isLoan
? __('Update owed amount')
: __('Update balance')}
</DialogTitle>
<DialogDescription>
{__(
'Set the balance for this account on a specific date.',
)}
{isLoan
? __(
'Set the owed amount for this account on a specific date.',
)
: __(
'Set the balance for this account on a specific date.',
)}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="balance-amount">{__('Balance')}</Label>
<Label htmlFor="balance-amount">
{balanceTermCapitalized(account.type)}
</Label>
{isLoadingLastBalance ? (
<div className="flex h-10 items-center rounded-md border border-input bg-muted px-3 text-sm text-muted-foreground">
{__('Loading last balance...')}
{isLoan
? __('Loading last owed amount...')
: __('Loading last balance...')}
</div>
) : (
<AmountInput

View File

@ -1,4 +1,6 @@
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
@ -7,6 +9,7 @@ import {
import { ChartViewType } from '@/hooks/use-chart-views';
import { __ } from '@/utils/i18n';
import { Settings2 } from 'lucide-react';
import { Separator } from '../ui/separator';
import {
type ChartGranularity,
ChartGranularityToggle,
@ -19,6 +22,10 @@ interface ChartSettingsPopoverProps {
currentView: ChartViewType;
onViewChange: (value: ChartViewType) => void;
availableViews: ChartViewType[];
showChartControls?: boolean;
includeLoansLabel?: string;
includeLoans?: boolean;
onIncludeLoansChange?: (value: boolean) => void;
}
export function ChartSettingsPopover({
@ -27,6 +34,10 @@ export function ChartSettingsPopover({
currentView,
onViewChange,
availableViews,
showChartControls = true,
includeLoansLabel,
includeLoans,
onIncludeLoansChange,
}: ChartSettingsPopoverProps) {
return (
<Popover>
@ -35,30 +46,64 @@ export function ChartSettingsPopover({
<Settings2 className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-auto">
<PopoverContent align="end" className="w-72">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">
{__('Period')}
</span>
<ChartGranularityToggle
value={granularity}
onValueChange={onGranularityChange}
showTooltip={false}
/>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">
{__('Chart type')}
</span>
<ChartViewToggle
value={currentView}
onValueChange={onViewChange}
availableViews={availableViews}
granularity={granularity}
showTooltip={false}
/>
</div>
{showChartControls ? (
<>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">
{__('Period')}
</span>
<ChartGranularityToggle
value={granularity}
onValueChange={onGranularityChange}
showTooltip={false}
/>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">
{__('Chart type')}
</span>
<ChartViewToggle
value={currentView}
onValueChange={onViewChange}
availableViews={availableViews}
granularity={granularity}
showTooltip={false}
/>
</div>
<Separator />
</>
) : null}
{onIncludeLoansChange &&
typeof includeLoans === 'boolean' &&
includeLoansLabel ? (
<>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label
htmlFor="include-loans-in-net-worth-chart"
className="text-sm leading-5 font-medium"
>
{includeLoansLabel}
</Label>
<p className="text-xs text-muted-foreground">
{__(
'Include loan balances in the net worth totals and chart',
)}
</p>
</div>
<Checkbox
id="include-loans-in-net-worth-chart"
checked={includeLoans}
onCheckedChange={(checked) =>
onIncludeLoansChange(checked === true)
}
className="mt-0.5"
/>
</div>
</>
) : null}
</div>
</PopoverContent>
</Popover>

View File

@ -25,9 +25,15 @@ import {
} from '@/hooks/use-dashboard-data';
import { useLocale } from '@/hooks/use-locale';
import { useIsMobile } from '@/hooks/use-mobile';
import { AccountInfo } from '@/lib/chart-calculations';
import {
AccountInfo,
getAccountSign,
isLiabilityType,
} from '@/lib/chart-calculations';
import { SharedData } from '@/types';
import { formatDayFromDate } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { router, usePage } from '@inertiajs/react';
import { format, subDays } from 'date-fns';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { PercentageTrendIndicator } from './percentage-trend-indicator';
@ -69,7 +75,7 @@ function formatXAxisLabel(
function calculateTrend(
data: Array<Record<string, string | number | OriginalAmount>>,
accountIds: string[],
accounts: Record<string, AccountInfo>,
periodsBack: number,
): TrendData | null {
if (data.length < 2) return null;
@ -79,14 +85,28 @@ function calculateTrend(
if (currentIndex === previousIndex) return null;
const accountIds = Object.keys(accounts);
const currentTotal = accountIds.reduce((sum, id) => {
const value = data[currentIndex][id];
return sum + (typeof value === 'number' ? value : 0);
if (typeof value !== 'number') {
return sum;
}
const account = accounts[id];
return sum + getAccountSign(account.type) * Math.abs(value);
}, 0);
const previousTotal = accountIds.reduce((sum, id) => {
const value = data[previousIndex][id];
return sum + (typeof value === 'number' ? value : 0);
if (typeof value !== 'number') {
return sum;
}
const account = accounts[id];
return sum + getAccountSign(account.type) * Math.abs(value);
}, 0);
if (previousTotal === 0) return null;
@ -112,6 +132,7 @@ export function NetWorthChart({
loading,
showLegend = false,
}: NetWorthChartProps) {
const { props } = usePage<SharedData>();
const locale = useLocale();
const isMobile = useIsMobile();
const [granularity, setGranularity] = useState<ChartGranularity>('monthly');
@ -119,6 +140,8 @@ export function NetWorthChart({
null,
);
const [isDailyLoading, setIsDailyLoading] = useState(false);
const includeLoansInNetWorthChart =
props.includeLoansInNetWorthChart ?? true;
const fetchDailyData = useCallback(async () => {
setIsDailyLoading(true);
@ -165,7 +188,8 @@ export function NetWorthChart({
const userCurrency = activeData.currency_code || 'USD';
const {
chartData,
scaledChartData,
rawChartData,
dataKeys,
chartConfig,
shortTrend,
@ -173,15 +197,32 @@ export function NetWorthChart({
totalAmount,
accountCurrencies,
accountsForHook,
hasLiabilities,
} = useMemo(() => {
const accounts = activeData.accounts || {};
const chartDataArray = activeData.data || [];
// Sort accounts by descending average balance so the largest accounts
// are at the bottom of the stacked chart and smallest are on top.
// Using the average across all data points ensures a consistent order
// across every period (month or day).
const accountIds = Object.keys(accounts).sort((a, b) => {
// All accounts included based on the loan toggle used for totals & trends.
const includedAccounts = Object.fromEntries(
Object.entries(accounts).filter(([, account]) => {
return includeLoansInNetWorthChart || account.type !== 'loan';
}),
);
// Split into assets (chart segments) and liabilities (affect totals only).
const assetAccounts = Object.fromEntries(
Object.entries(includedAccounts).filter(
([, account]) => !isLiabilityType(account.type),
),
);
const liabilityAccountIds = Object.keys(includedAccounts).filter((id) =>
isLiabilityType(includedAccounts[id].type),
);
const hasLiabs = liabilityAccountIds.length > 0;
// Sort asset accounts by descending average balance so the largest
// accounts are at the bottom of the stacked chart and smallest on top.
const chartAccountIds = Object.keys(assetAccounts).sort((a, b) => {
const valuesA = chartDataArray
.map((p) => p[a])
.filter((v): v is number => typeof v === 'number');
@ -205,8 +246,9 @@ export function NetWorthChart({
const currencies: Record<string, string> = {};
const hookAccounts: Record<string, AccountInfo> = {};
accountIds.forEach((id) => {
const account = accounts[id];
// Build config and currencies only for asset accounts (chart segments).
chartAccountIds.forEach((id) => {
const account = assetAccounts[id];
config[id] = {
label: account ? <EncryptedLabel account={account} /> : id,
};
@ -222,41 +264,156 @@ export function NetWorthChart({
}
});
// All values are now in the user's currency, so compute a single total
// Also register liability accounts so the useChartViews MoM/percent
// series and calculateTrend include them in net worth.
const allIncludedIds = Object.keys(includedAccounts);
const allHookAccounts: Record<string, AccountInfo> = {
...hookAccounts,
};
allIncludedIds.forEach((id) => {
if (!allHookAccounts[id]) {
const account = includedAccounts[id];
if (account) {
allHookAccounts[id] = {
id: account.id,
type: account.type,
currency_code: account.currency_code,
};
}
}
});
// Build scaled chart data:
// - Each asset value is proportionally scaled so total bar height = net worth
// - Original values stored as `${id}_display` for tooltip display
// - Liability total and net worth stored as metadata
const scaled = chartDataArray.map((point) => {
const newPoint: Record<string, string | number | OriginalAmount> =
{};
// Copy non-account fields
if (point.month !== undefined) newPoint.month = point.month;
if (point.timestamp !== undefined)
newPoint.timestamp = point.timestamp;
// Compute totals for this data point
let totalAssets = 0;
let totalLiabilities = 0;
chartAccountIds.forEach((id) => {
const value = point[id];
if (typeof value === 'number') {
totalAssets += Math.abs(value);
}
});
liabilityAccountIds.forEach((id) => {
const value = point[id];
if (typeof value === 'number') {
totalLiabilities += Math.abs(value);
}
});
const netWorth = totalAssets - totalLiabilities;
const scaleFactor =
hasLiabs && totalAssets > 0
? Math.max(0, netWorth / totalAssets)
: 1;
// Asset values: scaled for rendering, original for tooltip
chartAccountIds.forEach((id) => {
const value = point[id];
if (typeof value === 'number') {
newPoint[id] = value * scaleFactor;
if (hasLiabs) {
newPoint[`${id}_display`] = value;
}
}
// Copy _original entries for multi-currency display
const originalKey = `${id}_original`;
if (point[originalKey] !== undefined) {
newPoint[originalKey] = point[originalKey];
}
});
// Per-liability account data for tooltip (one row per loan)
if (hasLiabs) {
const liabilities: Array<{ name: string; amount: number }> = [];
liabilityAccountIds.forEach((id) => {
const value = point[id];
if (typeof value === 'number' && Math.abs(value) > 0) {
const account = includedAccounts[id];
liabilities.push({
name: account.name,
amount: Math.abs(value),
});
}
});
// Store as JSON string since data points only hold primitives
newPoint.__liabilities = JSON.stringify(liabilities);
newPoint.__liabilities_total = totalLiabilities;
newPoint.__net_worth = netWorth;
}
return newPoint;
});
// Compute the signed total across ALL included accounts.
let total = 0;
if (chartDataArray.length > 0) {
const lastDataPoint = chartDataArray[chartDataArray.length - 1];
accountIds.forEach((id) => {
allIncludedIds.forEach((id) => {
const value = lastDataPoint[id];
if (typeof value === 'number') {
total += value;
const account = includedAccounts[id];
total += getAccountSign(account.type) * Math.abs(value);
}
});
}
return {
chartData: chartDataArray,
dataKeys: accountIds,
scaledChartData: scaled,
rawChartData: chartDataArray,
dataKeys: chartAccountIds,
chartConfig: config,
shortTrend: calculateTrend(chartDataArray, accountIds, 1),
shortTrend: calculateTrend(chartDataArray, allHookAccounts, 1),
longTrend: calculateTrend(
chartDataArray,
accountIds,
allHookAccounts,
chartDataArray.length - 1,
),
totalAmount: total,
accountCurrencies: currencies,
accountsForHook: hookAccounts,
accountsForHook: allHookAccounts,
hasLiabilities: hasLiabs,
};
}, [activeData]);
}, [activeData, includeLoansInNetWorthChart]);
const chartViews = useChartViews({
data: chartData as Array<Record<string, string | number>>,
data: rawChartData as Array<Record<string, string | number>>,
accounts: accountsForHook,
initialView: 'stacked',
hasStackedView: true,
netWorthOptions: {
includeLoanAccounts: includeLoansInNetWorthChart,
},
});
const handleIncludeLoansChange = useCallback((includeLoans: boolean) => {
router.patch(
'/settings/net-worth-chart-loan-preference',
{
include_loans_in_net_worth_chart: includeLoans,
},
{
preserveScroll: true,
preserveState: true,
only: ['includeLoansInNetWorthChart'],
},
);
}, []);
const valueFormatter = useMemo(() => {
return (value: number): React.ReactNode => {
return (
@ -353,6 +510,9 @@ export function NetWorthChart({
currentView={chartViews.currentView}
onViewChange={chartViews.setCurrentView}
availableViews={chartViews.availableViews}
includeLoansLabel={__('Include loans')}
includeLoans={includeLoansInNetWorthChart}
onIncludeLoansChange={handleIncludeLoansChange}
/>
) : (
<>
@ -366,6 +526,19 @@ export function NetWorthChart({
availableViews={chartViews.availableViews}
granularity={granularity}
/>
<ChartSettingsPopover
granularity={granularity}
onGranularityChange={setGranularity}
currentView={chartViews.currentView}
onViewChange={chartViews.setCurrentView}
availableViews={chartViews.availableViews}
showChartControls={false}
includeLoansLabel={__('Include loans')}
includeLoans={includeLoansInNetWorthChart}
onIncludeLoansChange={
handleIncludeLoansChange
}
/>
</>
)}
</div>
@ -375,7 +548,7 @@ export function NetWorthChart({
{chartViews.currentView === 'stacked' &&
(granularity === 'daily' ? (
<StackedAreaChart
data={chartData.slice(1)}
data={scaledChartData.slice(1)}
dataKeys={dataKeys}
config={chartConfig}
xAxisKey="month"
@ -385,10 +558,15 @@ export function NetWorthChart({
displayCurrency={userCurrency}
className="h-[300px] w-full"
showLegend={showLegend}
netWorthMode={
hasLiabilities
? { liabilityTypeLabel: __('Loan') }
: undefined
}
/>
) : (
<StackedBarChart
data={chartData.slice(1)}
data={scaledChartData.slice(1)}
dataKeys={dataKeys}
config={chartConfig}
xAxisKey="month"
@ -398,6 +576,11 @@ export function NetWorthChart({
displayCurrency={userCurrency}
className="h-[300px] w-full"
showLegend={showLegend}
netWorthMode={
hasLiabilities
? { liabilityTypeLabel: __('Loan') }
: undefined
}
/>
))}
{chartViews.currentView === 'mom' && (

View File

@ -134,6 +134,10 @@ interface ChartTooltipContentProps {
valueFormatter?: (value: number, accountId?: string) => React.ReactNode;
accountCurrencies?: Record<string, string>;
displayCurrency?: string;
/** When set, tooltip shows liability rows and net-worth total instead of simple sum. */
netWorthMode?: {
liabilityTypeLabel: string;
};
}
function formatCurrencyWithCode(value: number, currencyCode: string, locale: string): string {
@ -160,6 +164,7 @@ const ChartTooltipContent = React.forwardRef<
valueFormatter,
accountCurrencies,
displayCurrency,
netWorthMode,
},
ref,
) => {
@ -210,6 +215,14 @@ const ChartTooltipContent = React.forwardRef<
return null;
}
// In net worth mode, use pre-computed net worth from data point
if (netWorthMode && displayCurrency) {
const netWorth = payload[0]?.payload?.__net_worth as number | undefined;
if (netWorth !== undefined) {
return [[displayCurrency, netWorth] as [string, number]];
}
}
// When displayCurrency is set, all values are in a single currency
if (displayCurrency) {
const total = payload.reduce((sum, item) => {
@ -232,7 +245,7 @@ const ChartTooltipContent = React.forwardRef<
});
return Object.entries(totals).sort((a, b) => b[1] - a[1]);
}, [payload, accountCurrencies, displayCurrency]);
}, [payload, accountCurrencies, displayCurrency, netWorthMode]);
if (!active || !payload?.length) {
return null;
@ -264,6 +277,12 @@ const ChartTooltipContent = React.forwardRef<
item.dataKey || item.name || '',
);
// In net worth mode, use the original unscaled
// value stored in the data point for display.
const displayValue = netWorthMode
? ((item.payload?.[`${accountId}_display`] as number | undefined) ?? item.value)
: item.value;
return (
<div
key={String(item.dataKey)}
@ -320,13 +339,13 @@ const ChartTooltipContent = React.forwardRef<
})()}
{valueFormatter
? valueFormatter(
item.value as number,
displayValue as number,
accountId,
)
: typeof item.value ===
: typeof displayValue ===
'number'
? item.value.toLocaleString(locale)
: item.value}
? displayValue.toLocaleString(locale)
: displayValue}
</span>
)}
</div>
@ -336,58 +355,91 @@ const ChartTooltipContent = React.forwardRef<
);
},
)}
{payload.length > 1 && (
<div className="border-border/50 flex flex-col gap-1 border-t pt-1.5">
{hasMultipleCurrencies ? (
currencyTotals.map(([currency, total]) => (
<div
key={currency}
className="flex justify-between"
>
<span className="text-muted-foreground font-medium">
Total {currency}
</span>
{(() => {
const liabilitiesTotal = netWorthMode
? (payload[0]?.payload?.__liabilities_total as number | undefined)
: undefined;
const liabilitiesJson = netWorthMode
? (payload[0]?.payload?.__liabilities as string | undefined)
: undefined;
const liabilities: Array<{ name: string; amount: number }> = liabilitiesJson
? (JSON.parse(liabilitiesJson) as Array<{ name: string; amount: number }>)
: [];
const hasLiabilities = typeof liabilitiesTotal === 'number' && liabilitiesTotal > 0;
const showTotalSection = payload.length > 1 || hasLiabilities;
if (!showTotalSection) return null;
const totalLabel = hasLiabilities ? 'Net Worth' : 'Total';
return (
<div className="border-border/50 flex flex-col gap-1 border-t pt-1.5">
{hasLiabilities && displayCurrency && liabilities.map((liability, index) => (
<div key={index} className="flex justify-between gap-2">
<div className="flex items-center gap-2">
<div className="size-2.5 rounded-xs bg-destructive" />
<span className="text-muted-foreground font-medium">
{netWorthMode?.liabilityTypeLabel}: {liability.name}
</span>
</div>
<span className="text-foreground font-mono font-medium tabular-nums">
{isPrivacyModeEnabled
? formatCurrencyWithCode(total, currency, locale).replace(/\d/g, '*')
: formatCurrencyWithCode(total, currency, locale)}
? formatCurrencyWithCode(-liability.amount, displayCurrency, locale).replace(/\d/g, '*')
: formatCurrencyWithCode(-liability.amount, displayCurrency, locale)}
</span>
</div>
))
) : (
<div className="flex justify-between">
<span className="text-muted-foreground font-medium">
Total
</span>
<span className="text-foreground font-mono font-medium tabular-nums">
{currencyTotals && currencyTotals[0]
? isPrivacyModeEnabled
? formatCurrencyWithCode(currencyTotals[0][1], currencyTotals[0][0], locale).replace(/\d/g, '*')
: formatCurrencyWithCode(currencyTotals[0][1], currencyTotals[0][0], locale)
: payload
.reduce(
(
sum: number,
item: TooltipPayloadItem,
) => {
const value =
item.value;
return (
sum +
(typeof value ===
'number'
? value
: 0)
);
},
0,
)
.toLocaleString(locale)}
</span>
</div>
)}
</div>
)}
))}
{hasMultipleCurrencies ? (
currencyTotals.map(([currency, total]) => (
<div
key={currency}
className="flex justify-between"
>
<span className="text-muted-foreground font-medium">
{totalLabel} {currency}
</span>
<span className="text-foreground font-mono font-medium tabular-nums">
{isPrivacyModeEnabled
? formatCurrencyWithCode(total, currency, locale).replace(/\d/g, '*')
: formatCurrencyWithCode(total, currency, locale)}
</span>
</div>
))
) : (
<div className="flex justify-between">
<span className="text-muted-foreground font-medium">
{totalLabel}
</span>
<span className="text-foreground font-mono font-medium tabular-nums">
{currencyTotals && currencyTotals[0]
? isPrivacyModeEnabled
? formatCurrencyWithCode(currencyTotals[0][1], currencyTotals[0][0], locale).replace(/\d/g, '*')
: formatCurrencyWithCode(currencyTotals[0][1], currencyTotals[0][0], locale)
: payload
.reduce(
(
sum: number,
item: TooltipPayloadItem,
) => {
const value =
item.value;
return (
sum +
(typeof value ===
'number'
? value
: 0)
);
},
0,
)
.toLocaleString(locale)}
</span>
</div>
)}
</div>
);
})()}
</div>
</div>
);

View File

@ -36,6 +36,7 @@ export interface StackedAreaChartProps<T extends Record<string, unknown>> {
className?: string;
showLegend?: boolean;
minBarWidth?: number;
netWorthMode?: { liabilityTypeLabel: string };
}
export function StackedAreaChart<T extends Record<string, unknown>>({
@ -50,6 +51,7 @@ export function StackedAreaChart<T extends Record<string, unknown>>({
className,
showLegend = true,
minBarWidth = 20,
netWorthMode,
}: StackedAreaChartProps<T>) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -124,6 +126,7 @@ export function StackedAreaChart<T extends Record<string, unknown>>({
valueFormatter={valueFormatter}
accountCurrencies={accountCurrencies}
displayCurrency={displayCurrency}
netWorthMode={netWorthMode}
/>
}
/>

View File

@ -128,6 +128,7 @@ export interface StackedBarChartProps<T extends Record<string, unknown>> {
className?: string;
showLegend?: boolean;
minBarWidth?: number;
netWorthMode?: { liabilityTypeLabel: string };
}
export function StackedBarChart<T extends Record<string, unknown>>({
@ -142,6 +143,7 @@ export function StackedBarChart<T extends Record<string, unknown>>({
className,
showLegend = true,
minBarWidth = 50,
netWorthMode,
}: StackedBarChartProps<T>) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -206,6 +208,7 @@ export function StackedBarChart<T extends Record<string, unknown>>({
valueFormatter={valueFormatter}
accountCurrencies={accountCurrencies}
displayCurrency={displayCurrency}
netWorthMode={netWorthMode}
/>
}
/>

View File

@ -5,6 +5,7 @@ import {
computeMoMPercent,
computeNetWorthSeries,
MonthDataPoint,
NetWorthSeriesOptions,
PercentDataPoint,
} from '@/lib/chart-calculations';
import { useCallback, useMemo, useState } from 'react';
@ -20,6 +21,8 @@ export interface UseChartViewsOptions {
initialView?: ChartViewType;
/** Whether to show stacked view option (false for single account charts) */
hasStackedView?: boolean;
/** Options for computing net worth series */
netWorthOptions?: NetWorthSeriesOptions;
}
export interface UseChartViewsReturn {
@ -43,6 +46,7 @@ export function useChartViews({
accounts,
initialView = 'stacked',
hasStackedView = true,
netWorthOptions,
}: UseChartViewsOptions): UseChartViewsReturn {
// View state
const [currentView, setCurrentViewState] = useState<ChartViewType>(
@ -75,7 +79,11 @@ export function useChartViews({
// The first month is needed for MoM calculations but shouldn't be displayed,
// so we slice(1) to show only 12 bars
const { netWorthSeries, deltaSeries, momPercentSeries } = useMemo(() => {
const fullNetWorth = computeNetWorthSeries(data, accounts);
const fullNetWorth = computeNetWorthSeries(
data,
accounts,
netWorthOptions,
);
const fullDelta = computeDeltaSeries(fullNetWorth);
const fullMomPercent = computeMoMPercent(fullNetWorth);
@ -84,7 +92,7 @@ export function useChartViews({
deltaSeries: fullDelta.slice(1),
momPercentSeries: fullMomPercent.slice(1),
};
}, [data, accounts]);
}, [data, accounts, netWorthOptions]);
return {
// View state

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import {
deriveAccountMetrics,
type NetWorthEvolutionData,
} from './use-dashboard-data';
describe('deriveAccountMetrics', () => {
it('returns loan balances and diffs as negative net worth contributions', () => {
const netWorthEvolution: NetWorthEvolutionData = {
currency_code: 'EUR',
accounts: {
loan_1: {
id: 'loan_1',
name: 'Mortgage',
name_iv: null,
encrypted: false,
type: 'loan',
currency_code: 'EUR',
bank: {
id: 'bank_1',
user_id: null,
name: 'Bank',
logo: null,
},
banking_connection_id: null,
},
},
data: [
{ month: '2025-01', loan_1: 120000 },
{ month: '2025-02', loan_1: 100000 },
],
};
const [account] = deriveAccountMetrics(netWorthEvolution, 'en-US');
expect(account.currentBalance).toBe(-100000);
expect(account.previousBalance).toBe(-120000);
expect(account.diff).toBe(20000);
expect(account.history).toEqual([
expect.objectContaining({ value: -120000 }),
expect.objectContaining({ value: -100000 }),
]);
});
});

View File

@ -1,4 +1,5 @@
import { useLocale } from '@/hooks/use-locale';
import { getAccountSign } from '@/lib/chart-calculations';
import { Account, AccountType, Bank } from '@/types/account';
import { Category } from '@/types/category';
import { format, subDays, subMonths } from 'date-fns';
@ -65,7 +66,11 @@ export function deriveAccountMetrics(
const investedKey = account.id + '_invested';
const history = data.map((point) => ({
date: formatMonth(point.month as string, locale),
value: (point[account.id] as number) ?? 0,
value:
typeof point[account.id] === 'number'
? getAccountSign(account.type) *
Math.abs(point[account.id] as number)
: 0,
investedAmount:
investedKey in point
? (point[investedKey] as number | null)

View File

@ -131,6 +131,26 @@ describe('computeNetWorthSeries', () => {
expect(result[0].value).toBe(-40000); // 10000 - 50000
});
it('normalizes negative liability balances before subtracting them', () => {
const data = [{ month: '2025-01', checking: 10000, loan: -50000 }];
const accounts = createAccounts({ checking: 'checking', loan: 'loan' });
const result = computeNetWorthSeries(data, accounts);
expect(result[0].value).toBe(-40000);
});
it('can exclude loan accounts from net worth calculations', () => {
const data = [{ month: '2025-01', checking: 10000, loan: 50000 }];
const accounts = createAccounts({ checking: 'checking', loan: 'loan' });
const result = computeNetWorthSeries(data, accounts, {
includeLoanAccounts: false,
});
expect(result[0].value).toBe(10000);
});
it('preserves timestamp if present', () => {
const data = [{ month: '2025-01', timestamp: 1704067200, acc1: 10000 }];
const accounts = createAccounts({ acc1: 'checking' });

View File

@ -52,6 +52,10 @@ export interface AccountInfo {
currency_code: string;
}
export interface NetWorthSeriesOptions {
includeLoanAccounts?: boolean;
}
/**
* Compute net worth series from raw balance data
* Handles sign correctly: assets add to NW, liabilities subtract
@ -59,12 +63,17 @@ export interface AccountInfo {
export function computeNetWorthSeries(
data: Array<Record<string, string | number>>,
accounts: Record<string, AccountInfo>,
options: NetWorthSeriesOptions = {},
): MonthDataPoint[] {
if (!data || data.length === 0) {
return [];
}
const accountIds = Object.keys(accounts);
const includeLoanAccounts = options.includeLoanAccounts ?? true;
const accountIds = Object.entries(accounts)
.filter(([, account]) => includeLoanAccounts || account.type !== 'loan')
.map(([id]) => id);
return data.map((point) => {
const month = point.month as string;
@ -78,7 +87,9 @@ export function computeNetWorthSeries(
if (!account) return sum + balance;
const sign = getAccountSign(account.type);
return sum + sign * balance;
const normalizedBalance = sign === -1 ? Math.abs(balance) : balance;
return sum + sign * normalizedBalance;
}, 0);
return {

View File

@ -63,6 +63,7 @@ export default function AccountShow({
}
const isConnected = !!account.banking_connection_id;
const isLoan = account.type === 'loan';
const breadcrumbs: BreadcrumbItem[] = [
{
@ -112,7 +113,9 @@ export default function AccountShow({
variant="outline"
onClick={() => setUpdateBalanceOpen(true)}
>
{__('Update balance')}
{isLoan
? __('Update owed amount')
: __('Update balance')}
</Button>
</ButtonGroup>
<ButtonGroup>
@ -120,7 +123,9 @@ export default function AccountShow({
variant="outline"
onClick={() => setImportBalancesOpen(true)}
>
{__('Import balances')}
{isLoan
? __('Import owed amounts')
: __('Import balances')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -138,7 +143,9 @@ export default function AccountShow({
setBalancesOpen(true)
}
>
{__('See balances')}
{isLoan
? __('See owed amounts')
: __('See balances')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEditOpen(true)}
@ -218,6 +225,7 @@ export default function AccountShow({
open={importBalancesOpen}
onOpenChange={setImportBalancesOpen}
accounts={accounts}
account={account}
accountId={account.id}
onSuccess={handleBalanceUpdated}
/>

View File

@ -124,3 +124,35 @@ export function filterTransactionalAccounts<T extends { type: AccountType }>(
(account) => !NON_TRANSACTIONAL_ACCOUNT_TYPES.includes(account.type),
);
}
export function isLoanAccount(account: Pick<Account, 'type'>): boolean {
return account.type === 'loan';
}
/**
* Returns the appropriate term for "balance" based on account type.
* Loan accounts use "owed amount" instead of "balance".
*/
export function balanceTerm(
type: AccountType,
variant: 'singular' | 'plural' = 'singular',
): string {
if (type === 'loan') {
return variant === 'plural' ? __('owed amounts') : __('owed amount');
}
return variant === 'plural' ? __('balances') : __('balance');
}
/**
* Returns the appropriate capitalized term for "Balance" based on account type.
* Loan accounts use "Owed Amount" instead of "Balance".
*/
export function balanceTermCapitalized(
type: AccountType,
variant: 'singular' | 'plural' = 'singular',
): string {
if (type === 'loan') {
return variant === 'plural' ? __('Owed Amounts') : __('Owed Amount');
}
return variant === 'plural' ? __('Balances') : __('Balance');
}

View File

@ -60,6 +60,7 @@ export interface SharedData {
auth: Auth;
flash: Flash;
chartColorScheme: ChartColorScheme;
includeLoansInNetWorthChart: boolean;
subscriptionsEnabled: boolean;
pricing: PricingConfig;
sidebarOpen: boolean;

View File

@ -6,6 +6,7 @@ use App\Http\Controllers\Settings\BankController;
use App\Http\Controllers\Settings\CategoryController;
use App\Http\Controllers\Settings\ChartColorSchemeController;
use App\Http\Controllers\Settings\LabelController;
use App\Http\Controllers\Settings\NetWorthChartLoanPreferenceController;
use App\Http\Controllers\Settings\PasswordController;
use App\Http\Controllers\Settings\ProfileController;
use App\Http\Controllers\Settings\TwoFactorAuthenticationController;
@ -61,6 +62,9 @@ Route::middleware('auth')->group(function () {
Route::patch('settings/chart-color-scheme', [ChartColorSchemeController::class, 'update'])
->name('chart-color-scheme.update');
Route::patch('settings/net-worth-chart-loan-preference', [NetWorthChartLoanPreferenceController::class, 'update'])
->name('net-worth-chart-loan-preference.update');
Route::get('settings/billing', [SubscriptionController::class, 'billing'])->name('settings.billing');
Route::get('settings/billing/portal', [SubscriptionController::class, 'billingPortal'])->name('settings.billing.portal');

View File

@ -79,6 +79,56 @@ test('net worth response includes currency_code', function () {
->assertJson(['currency_code' => 'USD']);
});
test('net worth treats positive loan balances as liabilities', function () {
$checking = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
'currency_code' => 'USD',
]);
$loan = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Loan,
'currency_code' => 'USD',
]);
AccountBalance::factory()->create([
'account_id' => $checking->id,
'balance_date' => now(),
'balance' => 500000,
]);
AccountBalance::factory()->create([
'account_id' => $loan->id,
'balance_date' => now(),
'balance' => 100000,
]);
AccountBalance::factory()->create([
'account_id' => $checking->id,
'balance_date' => now()->subDays(30),
'balance' => 450000,
]);
AccountBalance::factory()->create([
'account_id' => $loan->id,
'balance_date' => now()->subDays(30),
'balance' => 150000,
]);
$response = $this->getJson('/api/dashboard/net-worth?'.http_build_query([
'from' => now()->subDays(29)->toDateString(),
'to' => now()->toDateString(),
]));
$response->assertOk()
->assertJson([
'current' => 400000,
'previous' => 300000,
'currency_code' => 'USD',
]);
});
test('monthly spending calculates expenses correctly', function () {
$category = Category::factory()->create([
'user_id' => $this->user->id,

View File

@ -0,0 +1,85 @@
<?php
use App\Models\User;
use App\Models\UserSetting;
test('net worth chart loan preference can be updated', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('net-worth-chart-loan-preference.update'), [
'include_loans_in_net_worth_chart' => false,
]);
$response->assertSessionHasNoErrors()->assertRedirect();
expect($user->fresh()->setting->include_loans_in_net_worth_chart)->toBeFalse();
});
test('net worth chart loan preference rejects invalid values', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch(route('net-worth-chart-loan-preference.update'), [
'include_loans_in_net_worth_chart' => 'sometimes',
]);
$response->assertSessionHasErrors('include_loans_in_net_worth_chart');
});
test('net worth chart loan preference requires authentication', function () {
$response = $this->patch(route('net-worth-chart-loan-preference.update'), [
'include_loans_in_net_worth_chart' => false,
]);
$response->assertRedirect(route('login'));
});
test('net worth chart loan preference creates setting when none exists', function () {
$user = User::factory()->create();
expect(UserSetting::where('user_id', $user->id)->exists())->toBeFalse();
$this->actingAs($user)
->patch(route('net-worth-chart-loan-preference.update'), [
'include_loans_in_net_worth_chart' => false,
]);
expect(UserSetting::where('user_id', $user->id)->exists())->toBeTrue();
expect($user->fresh()->setting->include_loans_in_net_worth_chart)->toBeFalse();
});
test('net worth chart loan preference updates existing setting', function () {
$user = User::factory()->create();
UserSetting::factory()->for($user)->create([
'include_loans_in_net_worth_chart' => false,
]);
$this->actingAs($user)
->patch(route('net-worth-chart-loan-preference.update'), [
'include_loans_in_net_worth_chart' => true,
]);
expect($user->fresh()->setting->include_loans_in_net_worth_chart)->toBeTrue();
});
test('net worth chart loan preference defaults to true when no setting exists', function () {
$user = User::factory()->create();
expect($user->setting)->toBeNull();
expect($user->setting?->include_loans_in_net_worth_chart ?? true)->toBeTrue();
});
test('net worth chart loan preference is shared via inertia', function () {
$user = User::factory()->create();
UserSetting::factory()->for($user)->create([
'include_loans_in_net_worth_chart' => false,
]);
$response = $this->actingAs($user)->get(route('appearance.edit'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page->where('includeLoansInNetWorthChart', false));
});