You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

249 lines
7.6 KiB

import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { CreateOrUpdateJournalEntryDto } from '@ghostfolio/common/dtos';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import {
JournalCalendarDataItem,
JournalResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Put,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import {
endOfMonth,
format,
parseISO,
startOfMonth,
subMonths
} from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { JournalService } from './journal.service';
@Controller('journal')
export class JournalController {
public constructor(
private readonly journalService: JournalService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@HasPermission(permissions.readJournalEntry)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getJournal(
@Query('month') month: string,
@Query('year') year: string
): Promise<JournalResponse> {
const userId = this.request.user.id;
const monthNum = parseInt(month, 10) - 1;
const yearNum = parseInt(year, 10);
if (
isNaN(monthNum) ||
isNaN(yearNum) ||
monthNum < 0 ||
monthNum > 11 ||
yearNum < 1970 ||
yearNum > 2100
) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
const userCurrency =
this.request.user.settings?.settings?.baseCurrency ?? DEFAULT_CURRENCY;
const startDate = startOfMonth(new Date(yearNum, monthNum));
const endDate = endOfMonth(new Date(yearNum, monthNum));
// Fetch performance with a baseline that includes the previous month's
// last day, so January deltas are computed correctly
const performanceFetchStart = subMonths(startDate, 1);
const performanceYear = performanceFetchStart.getFullYear();
const [journalEntries, performanceResponse, activitiesResponse] =
await Promise.all([
this.journalService.getJournalEntries({
startDate,
endDate,
userId
}),
this.portfolioService.getPerformance({
dateRange:
`${performanceYear}` === `${yearNum}` ? `${yearNum}` : 'max',
filters: [],
impersonationId: undefined,
userId
}),
this.orderService.getOrders({
endDate,
userId,
userCurrency,
includeDrafts: false,
startDate: startDate,
withExcludedAccountsAndActivities: false
})
]);
const chart = performanceResponse?.chart ?? [];
const activities = activitiesResponse?.activities ?? [];
const daysMap = new Map<string, JournalCalendarDataItem>();
// Build days from performance chart data
for (const item of chart) {
const itemDate = parseISO(item.date);
if (itemDate >= startDate && itemDate <= endDate) {
daysMap.set(item.date, {
activitiesCount: 0,
date: item.date,
hasNote: false,
netPerformance: item.netPerformance ?? 0,
netPerformanceInPercentage: item.netPerformanceInPercentage ?? 0,
realizedProfit: 0,
value: item.value ?? 0
});
}
}
// Calculate daily performance deltas (both absolute and percentage)
const sortedDates = Array.from(daysMap.keys()).sort();
let previousNetPerformance = 0;
let previousNetPerformanceInPercentage = 0;
// Find the chart item just before our month starts to get the baseline
for (const item of chart) {
const itemDate = parseISO(item.date);
if (itemDate < startDate) {
previousNetPerformance = item.netPerformance ?? 0;
previousNetPerformanceInPercentage =
item.netPerformanceInPercentage ?? 0;
}
}
for (const dateKey of sortedDates) {
const day = daysMap.get(dateKey);
const currentNetPerformance = day.netPerformance;
day.netPerformance = currentNetPerformance - previousNetPerformance;
previousNetPerformance = currentNetPerformance;
const currentNetPerformanceInPercentage = day.netPerformanceInPercentage;
day.netPerformanceInPercentage =
currentNetPerformanceInPercentage - previousNetPerformanceInPercentage;
previousNetPerformanceInPercentage = currentNetPerformanceInPercentage;
}
// Count activities per day and track dividend/interest income
for (const activity of activities) {
const dateKey = format(activity.date, DATE_FORMAT);
let day = daysMap.get(dateKey);
if (!day) {
day = {
activitiesCount: 0,
date: dateKey,
hasNote: false,
netPerformance: 0,
netPerformanceInPercentage: 0,
realizedProfit: 0,
value: 0
};
daysMap.set(dateKey, day);
}
day.activitiesCount++;
// Track dividend and interest income as realized profit.
// SELL activities use gross proceeds (not actual realized gain),
// so we exclude them to avoid misleading values.
if (activity.type === 'DIVIDEND' || activity.type === 'INTEREST') {
day.realizedProfit +=
activity.valueInBaseCurrency ?? activity.value ?? 0;
}
}
// Mark days with journal notes
for (const entry of journalEntries) {
const dateKey = format(entry.date, DATE_FORMAT);
const day = daysMap.get(dateKey);
if (day) {
day.hasNote = true;
}
}
const days = Array.from(daysMap.values()).sort((a, b) =>
a.date.localeCompare(b.date)
);
const stats = this.journalService.buildJournalStats(days);
return { days, stats };
}
@Get(':date')
@HasPermission(permissions.readJournalEntry)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getJournalEntry(@Param('date') dateString: string) {
const userId = this.request.user.id;
const date = resetHours(parseISO(dateString));
return this.journalService.getJournalEntry({ date, userId });
}
@HasPermission(permissions.createJournalEntry)
@Put(':date')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createOrUpdateJournalEntry(
@Param('date') dateString: string,
@Body() data: CreateOrUpdateJournalEntryDto
) {
const userId = this.request.user.id;
return this.journalService.createOrUpdateJournalEntry({
date: dateString,
note: data.note,
userId
});
}
@HasPermission(permissions.deleteJournalEntry)
@Delete(':date')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteJournalEntry(@Param('date') dateString: string) {
const userId = this.request.user.id;
const date = resetHours(parseISO(dateString));
try {
return await this.journalService.deleteJournalEntry({ date, userId });
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
}
}