feat(accounts): show mortgage data and equity on real estate account page (#243)

## Summary

- Add mortgage balance as a dashed line overlay on the property value
chart when a real estate account has a linked loan
- Display equity (market value - mortgage owed) in the chart header and
as a 3-column summary strip (Market Value | Mortgage Owed | Equity) in
the property details card
- Extend balance evolution API endpoints to include `mortgage_balance`
data points for linked loan accounts
- Add 6 new tests covering mortgage/equity display in account show page
and balance evolution endpoints
This commit is contained in:
Víctor Falcón 2026-03-24 15:56:23 +00:00 committed by GitHub
parent 8ac6ed4d83
commit 973243277a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 760 additions and 27 deletions

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Services\AccountMetricsService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
@ -45,16 +46,33 @@ class AccountController extends Controller
$realEstateDetail = $account->realEstateDetail;
if ($realEstateDetail) {
$linkedLoan = $realEstateDetail->linkedLoanAccount;
$data['real_estate_detail'] = [
...$realEstateDetail->only([
'id', 'property_type', 'address', 'purchase_price',
'purchase_date', 'area_value', 'area_unit', 'notes',
'linked_loan_account_id',
]),
'linked_loan_account' => $realEstateDetail->linkedLoanAccount
? $realEstateDetail->linkedLoanAccount->only(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank'])
'linked_loan_account' => $linkedLoan
? $linkedLoan->only(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank'])
: null,
];
// Include current balances for equity calculation
if ($linkedLoan) {
$data['real_estate_detail']['current_loan_balance'] = AccountBalance::query()
->where('account_id', $linkedLoan->id)
->where('balance_date', '<=', now()->toDateString())
->orderByDesc('balance_date')
->value('balance') ?? 0;
}
$data['real_estate_detail']['current_market_value'] = AccountBalance::query()
->where('account_id', $account->id)
->where('balance_date', '<=', now()->toDateString())
->orderByDesc('balance_date')
->value('balance') ?? 0;
}
// Provide available loan accounts for linking

View File

@ -109,7 +109,10 @@ class DashboardAnalyticsController extends Controller
$start = Carbon::parse($validated['from']);
$end = Carbon::parse($validated['to']);
$lookup = BalanceLookup::forAccounts([$account->id], $start->copy()->startOfMonth(), $end);
$linkedLoanId = $this->getLinkedLoanAccountId($account);
$accountIds = $linkedLoanId ? [$account->id, $linkedLoanId] : [$account->id];
$lookup = BalanceLookup::forAccounts($accountIds, $start->copy()->startOfMonth(), $end);
$points = [];
$current = $start->copy()->startOfMonth();
@ -127,6 +130,10 @@ class DashboardAnalyticsController extends Controller
$point['invested_amount'] = $lookup->getInvestedAmountAt($account->id, $date);
}
if ($linkedLoanId) {
$point['mortgage_balance'] = $lookup->getBalanceAt($linkedLoanId, $date);
}
$points[] = $point;
$current->addMonth();
}
@ -158,7 +165,10 @@ class DashboardAnalyticsController extends Controller
$start = Carbon::parse($validated['from']);
$end = Carbon::parse($validated['to']);
$lookup = BalanceLookup::forAccounts([$account->id], $start, $end);
$linkedLoanId = $this->getLinkedLoanAccountId($account);
$accountIds = $linkedLoanId ? [$account->id, $linkedLoanId] : [$account->id];
$lookup = BalanceLookup::forAccounts($accountIds, $start, $end);
$points = [];
$current = $start->copy();
@ -175,6 +185,10 @@ class DashboardAnalyticsController extends Controller
$point['invested_amount'] = $lookup->getInvestedAmountAt($account->id, $date);
}
if ($linkedLoanId) {
$point['mortgage_balance'] = $lookup->getBalanceAt($linkedLoanId, $date);
}
$points[] = $point;
$current->addDay();
}
@ -336,4 +350,16 @@ class DashboardAnalyticsController extends Controller
'expense' => abs($expense),
];
}
/**
* Get the linked loan account ID for a real estate account, if any.
*/
private function getLinkedLoanAccountId(Account $account): ?string
{
if ($account->type !== \App\Enums\AccountType::RealEstate) {
return null;
}
return $account->realEstateDetail?->linked_loan_account_id;
}
}

View File

@ -485,6 +485,7 @@
"Entertainment (Movies, Sports, Hobbies)": "Entretenimiento (Pel\u00edculas, Deportes, Hobbies)",
"Entertainment \u00b7 Only $8 remaining this month": "Entretenimiento \u00b7 Solo $8 restante este mes",
"Entertainment \u2192 $100": "Entretenimiento \u2192 $100",
"Equity": "Patrimonio neto",
"Error": "Error",
"Errors (": "Errores (",
"Espa\u00f1ol": "Espa\u00f1ol",
@ -763,6 +764,8 @@
"Map Columns": "Mapear Columnas",
"Market Value": "Valor de Mercado",
"Market Values": "Valores de Mercado",
"Market value": "Valor de mercado",
"Market value evolution": "Evolución del valor de mercado",
"Match your file columns to transaction fields": "Asocia las columnas de tu archivo con los campos de transacci\u00f3n",
"Max": "M\u00e1x",
"Maybe later": "Quiz\u00e1s m\u00e1s tarde",
@ -780,6 +783,8 @@
"More": "M\u00e1s",
"More actions": "M\u00e1s acciones",
"More options": "M\u00e1s opciones",
"Mortgage Owed": "Hipoteca pendiente",
"Mortgage owed": "Hipoteca pendiente",
"Mortgages and loans": "Hipotecas y pr\u00e9stamos",
"Most Popular": "M\u00e1s Popular",
"Move Up \u2014 Share Your Personal Link": "Sube puestos \u2014 Comparte Tu Enlace Personal",
@ -830,6 +835,7 @@
"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 linked loan": "Sin pr\u00e9stamo vinculado",
"No market value data available": "No hay datos de valor de mercado disponibles",
"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",
@ -1653,6 +1659,7 @@
"violet": "violeta",
"vs last month": "vs mes anterior",
"vs last period": "vs per\u00edodo anterior",
"vs start": "vs inicio",
"will be updated or created.": "ser\u00e1 actualizado o creado.",
"yellow": "amarillo",
"\u2014 $9/month": "\u2014 $9/mes",

View File

@ -28,7 +28,11 @@ import {
} from '@/hooks/use-chart-views';
import { useLocale } from '@/hooks/use-locale';
import { useIsMobile } from '@/hooks/use-mobile';
import { Account, supportsInvestedAmount } from '@/types/account';
import {
Account,
isRealEstateAccount,
supportsInvestedAmount,
} from '@/types/account';
import { formatCurrency } from '@/utils/currency';
import { formatDayFromDate, formatMonthFromYearMonth } from '@/utils/date';
import { __ } from '@/utils/i18n';
@ -120,11 +124,106 @@ function InvestmentTooltipContent({
);
}
interface BalanceDataPoint {
function RealEstateTooltipContent({
active,
payload,
valueFormatter,
}: {
active?: boolean;
payload?: Array<{
dataKey?: string | number;
name?: string;
value?: number | string;
color?: string;
payload?: Record<string, unknown>;
}>;
valueFormatter: (value: number) => string;
}) {
if (!active || !payload?.length) return null;
const marketValueItem = payload.find((p) => p.dataKey === 'value');
const mortgageItem = payload.find((p) => p.dataKey === 'mortgage_balance');
const marketValue =
typeof marketValueItem?.value === 'number'
? marketValueItem.value
: null;
const mortgageOwed =
typeof mortgageItem?.value === 'number' ? mortgageItem.value : null;
const equity =
marketValue !== null && mortgageOwed !== null
? marketValue - mortgageOwed
: null;
return (
<div className="grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<div className="grid gap-1.5">
{marketValue !== null && (
<div className="flex w-full items-center gap-2">
<div
className="size-2.5 rounded-xs"
style={{
backgroundColor:
marketValueItem?.color ??
'var(--color-chart-2)',
}}
/>
<div className="flex flex-1 justify-between gap-4">
<span className="text-muted-foreground">
{__('Market value')}
</span>
<span className="font-mono font-medium text-foreground tabular-nums">
{valueFormatter(marketValue)}
</span>
</div>
</div>
)}
{mortgageOwed !== null && (
<div className="flex w-full items-center gap-2">
<div
className="size-2.5 rounded-xs"
style={{
backgroundColor:
mortgageItem?.color ??
'var(--color-chart-5)',
}}
/>
<div className="flex flex-1 justify-between gap-4">
<span className="text-muted-foreground">
{__('Mortgage owed')}
</span>
<span className="font-mono font-medium text-foreground tabular-nums">
{valueFormatter(mortgageOwed)}
</span>
</div>
</div>
)}
{equity !== null && (
<div className="flex w-full items-center gap-2 border-t border-border/50 pt-1.5">
<div className="size-2.5" />
<div className="flex flex-1 justify-between gap-4">
<span className="text-muted-foreground">
{__('Equity')}
</span>
<span
className={`font-mono font-medium whitespace-nowrap tabular-nums ${equity >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
>
{valueFormatter(equity)}
</span>
</div>
</div>
)}
</div>
</div>
);
}
export interface BalanceDataPoint {
month: string;
timestamp: number;
value: number;
invested_amount?: number | null;
mortgage_balance?: number | null;
}
interface AccountBalanceData {
@ -143,6 +242,7 @@ interface DailyBalanceDataPoint {
timestamp: number;
value: number;
invested_amount?: number | null;
mortgage_balance?: number | null;
}
interface AccountDailyBalanceData {
@ -156,11 +256,21 @@ interface AccountDailyBalanceData {
};
}
export interface ChartComputedData {
chartData: BalanceDataPoint[];
currentBalance: number;
currentMortgageBalance: number | null;
hasMortgageData: boolean;
shortTrend: ReturnType<typeof calculateTrend>;
longTrend: ReturnType<typeof calculateTrend>;
}
interface AccountBalanceChartProps {
account: Account;
loading?: boolean;
refreshKey?: number;
onBalanceClick?: () => void;
onDataLoaded?: (data: ChartComputedData) => void;
}
function createXAxisFormatter(locale: string, granularity: ChartGranularity) {
@ -216,6 +326,7 @@ function normalizeDailyData(data: DailyBalanceDataPoint[]): BalanceDataPoint[] {
timestamp: point.timestamp,
value: point.value,
invested_amount: point.invested_amount,
mortgage_balance: point.mortgage_balance,
}));
}
@ -224,10 +335,12 @@ export function AccountBalanceChart({
loading: initialLoading,
refreshKey,
onBalanceClick,
onDataLoaded,
}: AccountBalanceChartProps) {
const locale = useLocale();
const isMobile = useIsMobile();
const isLoan = account.type === 'loan';
const isRealEstate = isRealEstateAccount(account);
const [granularity, setGranularity] = useState<ChartGranularity>('monthly');
const [balanceData, setBalanceData] = useState<AccountBalanceData | null>(
null,
@ -282,6 +395,8 @@ export function AccountBalanceChart({
chartData,
currentBalance,
currentInvestedAmount,
currentMortgageBalance,
hasMortgageData,
shortTrend,
longTrend,
} = useMemo(() => {
@ -290,6 +405,8 @@ export function AccountBalanceChart({
chartData: [],
currentBalance: 0,
currentInvestedAmount: null as number | null,
currentMortgageBalance: null as number | null,
hasMortgageData: false,
shortTrend: null,
longTrend: null,
};
@ -310,15 +427,58 @@ export function AccountBalanceChart({
}
}
// Check if any data point has mortgage data
const hasMortgage = data.some(
(d) =>
d.mortgage_balance !== null && d.mortgage_balance !== undefined,
);
// Find the most recent mortgage balance
let mortgage: number | null = null;
if (hasMortgage) {
for (let i = data.length - 1; i >= 0; i--) {
if (
data[i].mortgage_balance !== null &&
data[i].mortgage_balance !== undefined
) {
mortgage = data[i].mortgage_balance!;
break;
}
}
}
return {
chartData: data,
currentBalance: current,
currentInvestedAmount: invested,
currentMortgageBalance: mortgage,
hasMortgageData: hasMortgage,
shortTrend: calculateTrend(data, 1),
longTrend: calculateTrend(data, data.length - 1),
};
}, [balanceData]);
useEffect(() => {
if (onDataLoaded && chartData.length > 0) {
onDataLoaded({
chartData,
currentBalance,
currentMortgageBalance,
hasMortgageData,
shortTrend,
longTrend,
});
}
}, [
chartData,
currentBalance,
currentMortgageBalance,
hasMortgageData,
shortTrend,
longTrend,
onDataLoaded,
]);
// Convert data for useChartViews hook
const { data: hookData, accounts: hookAccounts } = useMemo(() => {
return convertSingleAccountData(
@ -352,6 +512,14 @@ export function AccountBalanceChart({
},
}
: {}),
...(hasMortgageData
? {
mortgage_balance: {
label: __('Mortgage owed'),
color: 'var(--color-chart-5)',
},
}
: {}),
};
const formatXAxisLabel = useMemo(
@ -381,15 +549,26 @@ export function AccountBalanceChart({
? __('for the last 30 days')
: __('for the last 12 months');
const chartTitle = isRealEstate
? __('Market value evolution')
: isLoan
? __('Owed amount evolution')
: __('Balance evolution');
const emptyMessage = isRealEstate
? __('No market value data available')
: isLoan
? __('No owed amount data available')
: __('No balance data available');
const currentEquity =
hasMortgageData && currentMortgageBalance !== null
? currentBalance - currentMortgageBalance
: null;
if (initialLoading || isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>
{isLoan
? __('Owed amount evolution')
: __('Balance evolution')}
</CardTitle>
<CardTitle>{chartTitle}</CardTitle>
<CardDescription>
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</CardDescription>
@ -405,17 +584,11 @@ export function AccountBalanceChart({
return (
<Card>
<CardHeader>
<CardTitle>
{isLoan
? __('Owed amount evolution')
: __('Balance evolution')}
</CardTitle>
<CardTitle>{chartTitle}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
{isLoan
? __('No owed amount data available')
: __('No balance data available')}
{emptyMessage}
</div>
</CardContent>
</Card>
@ -427,11 +600,7 @@ export function AccountBalanceChart({
<CardHeader>
<div className="flex flex-row items-start justify-between">
<div className="flex flex-col gap-1 sm:gap-2">
<CardTitle>
{isLoan
? __('Owed amount evolution')
: __('Balance evolution')}
</CardTitle>
<CardTitle>{chartTitle}</CardTitle>
<button
type="button"
onClick={onBalanceClick}
@ -444,6 +613,23 @@ export function AccountBalanceChart({
maximumFractionDigits={0}
/>
</button>
{currentEquity !== null && (
<div className="flex items-baseline gap-2">
<span className="text-sm text-muted-foreground">
{__('Equity')}
</span>
<span
className={`text-lg font-semibold tabular-nums ${currentEquity >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
>
<AmountDisplay
amountInCents={currentEquity}
currencyCode={account.currency_code}
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
</span>
</div>
)}
<CardDescription className="flex flex-col gap-1 text-sm">
<PercentageTrendIndicator
trend={shortTrend?.percentage ?? null}
@ -499,7 +685,67 @@ export function AccountBalanceChart({
className="h-full w-full"
style={{ minWidth: `${minChartWidth}px` }}
>
{granularity === 'daily' ? (
{hasMortgageData ? (
<ComposedChart
accessibilityLayer
data={chartData.slice(1)}
>
<defs>
<linearGradient
id="fillBalance"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="var(--color-chart-2)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="var(--color-chart-2)"
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisLabel}
/>
<ChartTooltip
content={
<RealEstateTooltipContent
valueFormatter={valueFormatter}
/>
}
/>
<Area
dataKey="value"
type="monotone"
fill="url(#fillBalance)"
stroke="var(--color-chart-2)"
strokeWidth={2}
dot={false}
activeDot={{ r: 5 }}
fillOpacity={1}
/>
<Line
dataKey="mortgage_balance"
type="monotone"
stroke="var(--color-chart-5)"
strokeWidth={1.5}
strokeDasharray="4 3"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
</ComposedChart>
) : granularity === 'daily' ? (
<AreaChart
accessibilityLayer
data={chartData.slice(1)}

View File

@ -1,12 +1,17 @@
import { index, show } from '@/actions/App/Http/Controllers/AccountController';
import { update as updateRealEstateDetail } from '@/actions/App/Http/Controllers/RealEstateDetailController';
import { AccountBalanceChart } from '@/components/accounts/account-balance-chart';
import {
AccountBalanceChart,
type BalanceDataPoint,
type ChartComputedData,
} from '@/components/accounts/account-balance-chart';
import { BalancesModal } from '@/components/accounts/balances-modal';
import { DeleteAccountDialog } from '@/components/accounts/delete-account-dialog';
import { EditAccountDialog } from '@/components/accounts/edit-account-dialog';
import { ImportBalancesDrawer } from '@/components/accounts/import-balances-drawer';
import { UpdateBalanceDialog } from '@/components/accounts/update-balance-dialog';
import { BankLogo } from '@/components/bank-logo';
import { AmountTrendIndicator } from '@/components/dashboard/amount-trend-indicator';
import HeadingSmall from '@/components/heading-small';
import { MobileBackButton } from '@/components/mobile-back-button';
import { TransactionList } from '@/components/transactions/transaction-list';
@ -32,6 +37,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { BreadcrumbItem } from '@/types';
import {
@ -54,7 +60,8 @@ import { formatDateMedium } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { Head, router } from '@inertiajs/react';
import { ChevronDown, Pencil } from 'lucide-react';
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
interface AccountWithRealEstate extends Account {
real_estate_detail?: RealEstateDetail;
@ -85,6 +92,12 @@ export default function AccountShow({
const [balancesOpen, setBalancesOpen] = useState(false);
const [chartRefreshKey, setChartRefreshKey] = useState(0);
const [editingDetails, setEditingDetails] = useState(false);
const [chartComputedData, setChartComputedData] =
useState<ChartComputedData | null>(null);
const handleChartDataLoaded = useCallback((data: ChartComputedData) => {
setChartComputedData(data);
}, []);
function handleBalanceUpdated() {
setChartRefreshKey((prev) => prev + 1);
@ -218,8 +231,22 @@ export default function AccountShow({
? undefined
: () => setUpdateBalanceOpen(true)
}
onDataLoaded={handleChartDataLoaded}
/>
{isRealEstate &&
realEstateDetail &&
chartComputedData?.hasMortgageData && (
<EquitySummaryCards
chartData={chartComputedData.chartData}
currentBalance={chartComputedData.currentBalance}
currentMortgageBalance={
chartComputedData.currentMortgageBalance
}
currencyCode={account.currency_code}
/>
)}
{isRealEstate && realEstateDetail && (
<PropertyDetailsCard
detail={realEstateDetail}
@ -289,6 +316,219 @@ export default function AccountShow({
);
}
interface EquitySummaryCardsProps {
chartData: BalanceDataPoint[];
currentBalance: number;
currentMortgageBalance: number | null;
currencyCode: string;
}
function EquitySummaryCards({
chartData,
currentBalance,
currentMortgageBalance,
currencyCode,
}: EquitySummaryCardsProps) {
const { accountMainLineColor } = useChartColors();
const { marketHistory, mortgageHistory, equityHistory, equity } =
useMemo(() => {
const market = chartData.map((d) => ({
date: d.month,
value: d.value,
}));
const mortgage = chartData
.filter(
(d) =>
d.mortgage_balance !== null &&
d.mortgage_balance !== undefined,
)
.map((d) => ({ date: d.month, value: d.mortgage_balance! }));
const equityArr = chartData
.filter(
(d) =>
d.mortgage_balance !== null &&
d.mortgage_balance !== undefined,
)
.map((d) => ({
date: d.month,
value: d.value - d.mortgage_balance!,
}));
const currentEquity =
currentMortgageBalance !== null
? currentBalance - currentMortgageBalance
: null;
return {
marketHistory: market,
mortgageHistory: mortgage,
equityHistory: equityArr,
equity: currentEquity,
};
}, [chartData, currentBalance, currentMortgageBalance]);
const marketTrend = useMemo(() => {
if (marketHistory.length < 2) return null;
const prev = marketHistory[0].value;
const curr = marketHistory[marketHistory.length - 1].value;
if (prev === 0) return null;
return { diff: curr - prev, previous: prev, current: curr };
}, [marketHistory]);
const mortgageTrend = useMemo(() => {
if (mortgageHistory.length < 2) return null;
const prev = mortgageHistory[0].value;
const curr = mortgageHistory[mortgageHistory.length - 1].value;
if (prev === 0) return null;
return { diff: curr - prev, previous: prev, current: curr };
}, [mortgageHistory]);
const equityTrend = useMemo(() => {
if (equityHistory.length < 2) return null;
const prev = equityHistory[0].value;
const curr = equityHistory[equityHistory.length - 1].value;
if (prev === 0) return null;
return { diff: curr - prev, previous: prev, current: curr };
}, [equityHistory]);
const equityLineColor = 'var(--color-emerald-500)';
const mortgageLineColor = 'var(--color-amber-500)';
return (
<div className="grid gap-4 md:grid-cols-3">
<SparklineCard
title={__('Market Value')}
amountInCents={currentBalance}
currencyCode={currencyCode}
history={marketHistory}
lineColor={accountMainLineColor}
trend={marketTrend}
/>
<SparklineCard
title={__('Mortgage Owed')}
amountInCents={currentMortgageBalance ?? 0}
currencyCode={currencyCode}
history={mortgageHistory}
lineColor={mortgageLineColor}
trend={mortgageTrend}
/>
<SparklineCard
title={__('Equity')}
amountInCents={equity ?? 0}
currencyCode={currencyCode}
history={equityHistory}
lineColor={equityLineColor}
trend={equityTrend}
/>
</div>
);
}
function SparklineCard({
title,
amountInCents,
currencyCode,
history,
lineColor,
trend,
}: {
title: string;
amountInCents: number;
currencyCode: string;
history: Array<{ date: string; value: number }>;
lineColor: string;
trend: { diff: number; previous: number; current: number } | null;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-6">
<div className="flex flex-col gap-1">
<div className="px-0 py-1">
<AmountDisplay
amountInCents={amountInCents}
currencyCode={currencyCode}
size="2xl"
weight="medium"
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
</div>
{trend && (
<AmountTrendIndicator
isPositive={trend.diff >= 0}
trend={Math.abs(trend.diff)}
label={__('vs start')}
className="text-sm"
previousAmount={trend.previous}
currentAmount={trend.current}
tooltipSide="bottom"
currencyCode={currencyCode}
/>
)}
</div>
{history.length > 1 && (
<div className="h-[70px] w-full max-w-[250px] flex-1">
<ResponsiveContainer
width="100%"
height="100%"
initialDimension={{ width: 1, height: 1 }}
>
<LineChart data={history}>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length)
return null;
const data = payload[0].payload as {
date: string;
value: number;
};
return (
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<p className="mb-1 text-muted-foreground">
{data.date}
</p>
<p className="font-mono font-medium text-foreground tabular-nums">
<AmountDisplay
amountInCents={
data.value
}
currencyCode={
currencyCode
}
minimumFractionDigits={
0
}
maximumFractionDigits={
0
}
/>
</p>
</div>
);
}}
/>
<Line
type="monotone"
dataKey="value"
stroke={lineColor}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
</CardContent>
</Card>
);
}
function PropertyDetailsCard({
detail,
account,

View File

@ -96,6 +96,8 @@ export interface RealEstateDetail {
notes: string | null;
linked_loan_account_id: UUID | null;
linked_loan_account: Account | null;
current_market_value: number | null;
current_loan_balance: number | null;
}
export function formatPropertyType(type: PropertyType): string {

View File

@ -7,6 +7,7 @@ use App\Models\AutomationRule;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Label;
use App\Models\RealEstateDetail;
use App\Models\User;
beforeEach(function () {
@ -334,3 +335,196 @@ test('account show includes bank information', function () {
->where('account.bank.name', 'Test Bank')
);
});
test('real estate account show includes real estate detail with linked loan', function () {
$loanAccount = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Loan,
]);
$realEstateAccount = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
RealEstateDetail::factory()->create([
'account_id' => $realEstateAccount->id,
'linked_loan_account_id' => $loanAccount->id,
]);
$response = $this->withoutVite()->get(route('accounts.show', $realEstateAccount));
$response->assertOk()
->assertInertia(fn ($page) => $page
->component('Accounts/Show')
->has('account.real_estate_detail')
->where('account.real_estate_detail.linked_loan_account_id', $loanAccount->id)
->has('account.real_estate_detail.linked_loan_account')
->where('account.real_estate_detail.linked_loan_account.id', $loanAccount->id)
->has('account.available_loan_accounts')
);
});
test('real estate account show includes current market value and loan balance for equity', function () {
$loanAccount = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Loan,
]);
AccountBalance::factory()->create([
'account_id' => $loanAccount->id,
'balance_date' => now(),
'balance' => 20000000, // $200,000
]);
$realEstateAccount = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
AccountBalance::factory()->create([
'account_id' => $realEstateAccount->id,
'balance_date' => now(),
'balance' => 35000000, // $350,000
]);
RealEstateDetail::factory()->create([
'account_id' => $realEstateAccount->id,
'linked_loan_account_id' => $loanAccount->id,
]);
$response = $this->withoutVite()->get(route('accounts.show', $realEstateAccount));
$response->assertOk()
->assertInertia(fn ($page) => $page
->component('Accounts/Show')
->where('account.real_estate_detail.current_market_value', 35000000)
->where('account.real_estate_detail.current_loan_balance', 20000000)
);
});
test('real estate account show includes market value without linked loan', function () {
$realEstateAccount = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
AccountBalance::factory()->create([
'account_id' => $realEstateAccount->id,
'balance_date' => now(),
'balance' => 35000000,
]);
RealEstateDetail::factory()->create([
'account_id' => $realEstateAccount->id,
]);
$response = $this->withoutVite()->get(route('accounts.show', $realEstateAccount));
$response->assertOk()
->assertInertia(fn ($page) => $page
->component('Accounts/Show')
->where('account.real_estate_detail.current_market_value', 35000000)
->missing('account.real_estate_detail.current_loan_balance')
);
});
test('real estate balance evolution includes mortgage balance data', function () {
$loanAccount = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Loan,
]);
AccountBalance::factory()->create([
'account_id' => $loanAccount->id,
'balance_date' => now()->subMonthNoOverflow()->endOfMonth(),
'balance' => 22000000,
]);
AccountBalance::factory()->create([
'account_id' => $loanAccount->id,
'balance_date' => now()->endOfMonth(),
'balance' => 21500000,
]);
$realEstateAccount = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
AccountBalance::factory()->create([
'account_id' => $realEstateAccount->id,
'balance_date' => now()->subMonthNoOverflow()->endOfMonth(),
'balance' => 35000000,
]);
AccountBalance::factory()->create([
'account_id' => $realEstateAccount->id,
'balance_date' => now()->endOfMonth(),
'balance' => 36000000,
]);
RealEstateDetail::factory()->create([
'account_id' => $realEstateAccount->id,
'linked_loan_account_id' => $loanAccount->id,
]);
$response = $this->getJson('/api/dashboard/account/'.$realEstateAccount->id.'/balance-evolution?'.http_build_query([
'from' => now()->subMonthsNoOverflow(2)->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['data'])->toHaveCount(3);
expect($data['data'][0])->toHaveKey('mortgage_balance');
// The last point should have the most recent mortgage balance
$lastPoint = end($data['data']);
expect($lastPoint['mortgage_balance'])->toBe(21500000);
expect($lastPoint['value'])->toBe(36000000);
});
test('real estate balance evolution without linked loan has no mortgage data', function () {
$realEstateAccount = Account::factory()->realEstate()->create([
'user_id' => $this->user->id,
]);
AccountBalance::factory()->create([
'account_id' => $realEstateAccount->id,
'balance_date' => now()->endOfMonth(),
'balance' => 35000000,
]);
RealEstateDetail::factory()->create([
'account_id' => $realEstateAccount->id,
]);
$response = $this->getJson('/api/dashboard/account/'.$realEstateAccount->id.'/balance-evolution?'.http_build_query([
'from' => now()->subMonthsNoOverflow(2)->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['data'][0])->not->toHaveKey('mortgage_balance');
});
test('non-real-estate account balance evolution has no mortgage data', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'type' => AccountType::Checking,
]);
AccountBalance::factory()->create([
'account_id' => $account->id,
'balance_date' => now()->endOfMonth(),
'balance' => 100000,
]);
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/balance-evolution?'.http_build_query([
'from' => now()->subMonthsNoOverflow(2)->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['data'][0])->not->toHaveKey('mortgage_balance');
});