Skip to content

Commit 0fb00d3

Browse files
committed
feat: Add support for investment accounts with current value and XIRR
- Implemented functionality to handle current value for investment accounts in the analytics controller. - Created a new database migration for the investment_account_value table to store current values. - Updated mock repositories to support current value for investment accounts. - Enhanced account and analytics models to include current value and related metrics. - Modified account repository to manage current value during account creation and updates. - Added logic in the analytics service to compute and return investment metrics, including percentage increase and XIRR. - Expanded unit tests for account and analytics services to cover new investment account features and metrics. Signed-off-by: Pranav <pranav10121@gmail.com>
1 parent 9243b2f commit 0fb00d3

22 files changed

+1224
-146
lines changed

frontend/components/custom/AccountAnalytics/AccountAnalytics.tsx

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,9 @@ import {
1919
TableRow,
2020
} from "@/components/ui/table";
2121
import type { AccountAnalyticsListResponse } from "@/lib/models/analytics";
22-
import { formatCurrency, getTransactionColor } from "@/lib/utils";
22+
import { formatCurrency, formatPercentage, getTransactionColor } from "@/lib/utils";
2323
import { format } from "date-fns";
2424
import { ChevronRight, Plus, Wallet } from "lucide-react";
25-
import { useTheme } from "next-themes";
2625
import Link from "next/link";
2726
import { Fragment, useEffect, useState } from "react";
2827

@@ -49,7 +48,6 @@ const accountColors = [
4948
];
5049

5150
function AccountTransactions({ accountId }: AccountTransactionsProps) {
52-
const { theme } = useTheme();
5351
const { data, isLoading, error } = useTransactions({
5452
account_id: accountId,
5553
page: 1,
@@ -126,8 +124,7 @@ function AccountTransactions({ accountId }: AccountTransactionsProps) {
126124
</div>
127125
<div
128126
className={`text-sm font-semibold ${getTransactionColor(
129-
transaction.amount,
130-
theme
127+
transaction.amount
131128
)}`}
132129
>
133130
{formatCurrency(Math.abs(transaction.amount))}
@@ -258,24 +255,33 @@ export function AccountAnalytics({ data }: AccountAnalyticsProps) {
258255
);
259256
const accountName = accountInfo?.name || `Account ${account.account_id}`;
260257
const initialBalance = accountInfo?.balance || 0;
258+
const isInvestment =
259+
accountInfo?.bank_type === "investment" &&
260+
account.current_value !== null &&
261+
account.current_value !== undefined;
261262

262263
// Calculate the actual balance including initial balance
263264
const actualBalance = account.current_balance + initialBalance;
264265
const absoluteBalance = Math.abs(actualBalance);
266+
const displayValue = isInvestment
267+
? Number(account.current_value)
268+
: absoluteBalance;
265269

266270
return {
267271
...account,
268272
accountName,
269273
initialBalance,
270274
actualBalance,
271275
absoluteBalance,
276+
displayValue,
277+
isInvestment,
272278
color: accountColors[index % accountColors.length],
273279
};
274280
});
275281

276282
// Calculate total balance from the actual balances
277283
const totalBalance = accountsWithBalances.reduce(
278-
(sum, account) => sum + account.absoluteBalance,
284+
(sum, account) => sum + account.displayValue,
279285
0
280286
);
281287

@@ -284,7 +290,7 @@ export function AccountAnalytics({ data }: AccountAnalyticsProps) {
284290
.map((account) => ({
285291
...account,
286292
percentage:
287-
totalBalance > 0 ? (account.absoluteBalance / totalBalance) * 100 : 0,
293+
totalBalance > 0 ? (account.displayValue / totalBalance) * 100 : 0,
288294
}))
289295
.sort((a, b) => b.percentage - a.percentage); // Sort by percentage descending
290296

@@ -464,9 +470,20 @@ export function AccountAnalytics({ data }: AccountAnalyticsProps) {
464470
</div>
465471
</TableCell>
466472
<TableCell className="text-right">
467-
<span className="font-medium">
468-
{formatCurrency(account.absoluteBalance)}
469-
</span>
473+
<div className="flex flex-col items-end">
474+
<span className="font-medium">
475+
{formatCurrency(account.displayValue)}
476+
</span>
477+
{account.isInvestment && (
478+
<span className="text-xs text-muted-foreground">
479+
{formatPercentage(
480+
account.percentage_increase ?? 0
481+
)}
482+
<span className="mx-1"></span>
483+
{formatPercentage(account.xirr ?? 0)}
484+
</span>
485+
)}
486+
</div>
470487
</TableCell>
471488
</TableRow>
472489
{isExpanded && (

frontend/components/custom/CategoryAnalytics/CategoryAnalytics.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import type { Category } from "@/lib/models/category";
2222
import { formatCurrency, getTransactionColor } from "@/lib/utils";
2323
import { format } from "date-fns";
2424
import { ChevronRight, FileQuestion, Plus, Tag } from "lucide-react";
25-
import { useTheme } from "next-themes";
2625
import Link from "next/link";
2726
import { Fragment, useEffect, useState } from "react";
2827

@@ -56,7 +55,6 @@ function CategoryTransactions({
5655
categoryId,
5756
isUncategorized,
5857
}: CategoryTransactionsProps) {
59-
const { theme } = useTheme();
6058
const { data, isLoading, error } = useTransactions({
6159
page: 1,
6260
page_size: 5,
@@ -137,8 +135,7 @@ function CategoryTransactions({
137135
</div>
138136
<div
139137
className={`text-sm font-semibold ${getTransactionColor(
140-
transaction.amount,
141-
theme
138+
transaction.amount
142139
)}`}
143140
>
144141
{formatCurrency(Math.abs(transaction.amount))}

frontend/components/custom/Dashboard/AccountsAnalyticsSidepanel.tsx

Lines changed: 144 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@ interface AccountData {
1616
id: number;
1717
name: string;
1818
balance: number;
19+
txnBalance: number;
1920
currency: string;
2021
percentageChange: number;
22+
bankType?: string;
23+
isInvestment: boolean;
24+
xirr: number;
2125
}
2226

2327
interface AccountsAnalyticsSidepanelProps {
@@ -48,52 +52,86 @@ export function AccountsAnalyticsSidepanel({
4852
const account = accountsData?.find(
4953
(acc) => acc.id === analytics.account_id
5054
);
51-
const percentageChange = calculatePercentageChange(
52-
analytics.current_balance,
53-
analytics.balance_one_month_ago
54-
);
55+
const isInvestment =
56+
account?.bank_type === "investment" &&
57+
analytics.current_value !== null &&
58+
analytics.current_value !== undefined;
59+
const percentageChange = isInvestment
60+
? analytics.percentage_increase ?? 0
61+
: calculatePercentageChange(
62+
analytics.current_balance,
63+
analytics.balance_one_month_ago
64+
);
5565

5666
return {
5767
id: analytics.account_id,
5868
name: account?.name || `Account ${analytics.account_id}`,
5969
currency: account?.currency || "INR",
60-
balance: analytics.current_balance + (account?.balance || 0),
70+
balance: isInvestment
71+
? Number(analytics.current_value)
72+
: analytics.current_balance + (account?.balance || 0),
73+
txnBalance: -1 * analytics.current_balance + (account?.balance || 0),
6174
percentageChange,
75+
bankType: account?.bank_type || "others",
76+
isInvestment,
77+
xirr: analytics.xirr ?? 0,
6278
};
6379
}) || [];
6480

6581
if (isLoading) {
6682
return (
6783
<div className={`w-80 flex flex-col h-full ${className}`}>
6884
{/* Monthly Analytics Card at the top */}
69-
<div className="flex-shrink-0 mb-4">
85+
<div className="shrink-0 mb-4">
7086
<MonthlyAnalyticsCard />
7187
</div>
7288

7389
<Card className="flex-1 flex flex-col">
74-
<CardHeader className="pb-4 flex-shrink-0">
90+
<CardHeader className="pb-4 shrink-0">
7591
<div className="flex items-center justify-between">
7692
<Skeleton className="h-6 w-16" />
7793
<Skeleton className="h-8 w-8" />
7894
</div>
7995
<Skeleton className="h-4 w-20" />
8096
</CardHeader>
8197
<CardContent className="flex-1 overflow-y-auto space-y-1">
82-
{Array.from({ length: 6 }).map((_, index) => (
83-
<div
84-
key={index}
85-
className="flex items-center justify-between py-3 px-2"
86-
>
87-
<div className="flex items-center space-x-3">
88-
<Skeleton className="h-4 w-4" />
89-
<Skeleton className="h-4 w-20" />
98+
<div>
99+
<Skeleton className="h-3 w-24 mb-2" />
100+
{Array.from({ length: 3 }).map((_, index) => (
101+
<div
102+
key={`inv-${index}`}
103+
className="flex items-center justify-between py-3 px-2"
104+
>
105+
<div className="flex items-center space-x-3">
106+
<Skeleton className="h-4 w-4" />
107+
<Skeleton className="h-4 w-28" />
108+
</div>
109+
<div className="text-right space-y-1">
110+
<Skeleton className="h-4 w-16" />
111+
<Skeleton className="h-3 w-10" />
112+
</div>
90113
</div>
91-
<div className="text-right space-y-1">
92-
<Skeleton className="h-4 w-16" />
93-
<Skeleton className="h-3 w-10" />
114+
))}
115+
</div>
116+
117+
<div>
118+
<Skeleton className="h-3 w-16 mb-2 mt-2" />
119+
{Array.from({ length: 3 }).map((_, index) => (
120+
<div
121+
key={`bank-${index}`}
122+
className="flex items-center justify-between py-3 px-2"
123+
>
124+
<div className="flex items-center space-x-3">
125+
<Skeleton className="h-4 w-4" />
126+
<Skeleton className="h-4 w-28" />
127+
</div>
128+
<div className="text-right space-y-1">
129+
<Skeleton className="h-4 w-16" />
130+
<Skeleton className="h-3 w-10" />
131+
</div>
94132
</div>
95-
</div>
96-
))}
133+
))}
134+
</div>
97135
</CardContent>
98136
</Card>
99137
</div>
@@ -103,12 +141,12 @@ export function AccountsAnalyticsSidepanel({
103141
return (
104142
<div className={`w-80 flex flex-col h-full ${className}`}>
105143
{/* Monthly Analytics Card at the top */}
106-
<div className="flex-shrink-0 mb-4">
144+
<div className="shrink-0 mb-4">
107145
<MonthlyAnalyticsCard />
108146
</div>
109147

110148
<Card className="flex-1 flex flex-col">
111-
<CardHeader className="pb-4 flex-shrink-0">
149+
<CardHeader className="pb-4 shrink-0">
112150
<div className="flex items-center justify-between">
113151
<CardTitle className="text-lg font-semibold">Assets</CardTitle>
114152
<Button
@@ -139,33 +177,92 @@ export function AccountsAnalyticsSidepanel({
139177
</Button>
140178
</div>
141179
) : (
142-
accounts.map((account) => (
143-
<div
144-
key={account.id}
145-
className="flex items-center justify-between py-3 px-2 rounded-md hover:bg-muted/50 cursor-pointer group"
146-
>
147-
<div className="flex items-center space-x-3">
148-
<span className="font-medium text-sm">{account.name}</span>
149-
</div>
180+
(() => {
181+
const investments = accounts.filter(
182+
(a) => a.bankType === "investment"
183+
);
184+
const banks = accounts.filter((a) => a.bankType !== "investment");
150185

151-
<div className="text-right">
152-
<div className="font-semibold text-sm">
153-
{formatCurrency(account.balance, account.currency)}
154-
</div>
155-
<div
156-
className={`text-xs ${
157-
account.percentageChange > 0
158-
? "text-green-600 dark:text-green-300"
159-
: account.percentageChange < 0
160-
? "text-red-600 dark:text-red-300"
161-
: "text-muted-foreground"
162-
}`}
163-
>
164-
{formatPercentage(account.percentageChange)}
165-
</div>
186+
return (
187+
<div className="space-y-3">
188+
{investments.length > 0 && (
189+
<div>
190+
<div className="text-xs font-medium text-muted-foreground mb-1">
191+
Investments
192+
</div>
193+
<div className="space-y-1">
194+
{investments.map((account) => (
195+
<div
196+
key={account.id}
197+
className="flex items-center justify-between py-3 px-2 rounded-md hover:bg-muted/50 cursor-pointer group"
198+
>
199+
<div className="flex items-center space-x-3">
200+
<span className="font-medium text-sm">{account.name}</span>
201+
</div>
202+
203+
<div className="text-right">
204+
<div className="font-semibold text-sm">
205+
{formatCurrency(account.balance, account.currency)}
206+
<span className="text-xs text-muted-foreground ml-2">{formatCurrency(account.txnBalance, account.currency)}</span>
207+
</div>
208+
<div className={`text-xs ${
209+
account.xirr > 0
210+
? "text-green-600 dark:text-green-300"
211+
: account.xirr < 0
212+
? "text-red-600 dark:text-red-300"
213+
: "text-muted-foreground"
214+
}`}>
215+
{formatPercentage(account.percentageChange)}
216+
<span className="mx-1"></span>
217+
<span className="text-xs font-medium">XIRR</span>
218+
<span className="ml-1">{formatPercentage(account.xirr)}</span>
219+
</div>
220+
</div>
221+
</div>
222+
))}
223+
</div>
224+
</div>
225+
)}
226+
227+
{banks.length > 0 && (
228+
<div>
229+
<div className="text-xs font-medium text-muted-foreground mb-1">
230+
Banks
231+
</div>
232+
<div className="space-y-1">
233+
{banks.map((account) => (
234+
<div
235+
key={account.id}
236+
className="flex items-center justify-between py-3 px-2 rounded-md hover:bg-muted/50 cursor-pointer group"
237+
>
238+
<div className="flex items-center space-x-3">
239+
<span className="font-medium text-sm">{account.name}</span>
240+
</div>
241+
242+
<div className="text-right">
243+
<div className="font-semibold text-sm">
244+
{formatCurrency(account.balance, account.currency)}
245+
</div>
246+
<div
247+
className={`text-xs ${
248+
account.percentageChange > 0
249+
? "text-green-600 dark:text-green-300"
250+
: account.percentageChange < 0
251+
? "text-red-600 dark:text-red-300"
252+
: "text-muted-foreground"
253+
}`}
254+
>
255+
{formatPercentage(account.percentageChange)}
256+
</div>
257+
</div>
258+
</div>
259+
))}
260+
</div>
261+
</div>
262+
)}
166263
</div>
167-
</div>
168-
))
264+
);
265+
})()
169266
)}
170267
</CardContent>
171268

0 commit comments

Comments
 (0)