diff --git a/CHANGELOG.md b/CHANGELOG.md index fe077761e..2a5a5dc5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added a historical cash balances table to the account detail dialog +- Introduced a `HasPermission` annotation for endpoints + +### Changed + +- Respected the `withExcludedAccounts` flag in the account balance time series + +### Fixed + +- Changed the mechanism of the `INTRADAY` data gathering to operate synchronously avoiding database deadlocks + +## 2.27.1 - 2023-11-28 + +### Changed + +- Reverted `Nx` from version `17.1.3` to `17.0.2` + +## 2.27.0 - 2023-11-26 + +### Changed + +- Extended the chart in the account detail dialog by historical cash balances +- Improved the error log for a timeout in the data source request +- Improved the language localization for German (`de`) +- Upgraded `angular` from version `16.2.12` to `17.0.4` +- Upgraded `Nx` from version `17.0.2` to `17.1.3` + +## 2.26.0 - 2023-11-24 + ### Changed - Upgraded `prisma` from version `5.5.2` to `5.6.0` diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5618bb274..10323e640 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -32,7 +32,7 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template 1. Run `yarn nx migrate latest` 1. Make sure `package.json` changes make sense and then run `yarn install` -1. Run `yarn nx migrate --run-migrations` +1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338) ### Prisma diff --git a/README.md b/README.md index 01f8547b4..0a2053e43 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. -Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you. +Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you. If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio). diff --git a/apps/api/project.json b/apps/api/project.json index 80f924672..24054da44 100644 --- a/apps/api/project.json +++ b/apps/api/project.json @@ -47,8 +47,7 @@ "test": { "executor": "@nx/jest:jest", "options": { - "jestConfig": "apps/api/jest.config.ts", - "passWithNoTests": true + "jestConfig": "apps/api/jest.config.ts" }, "outputs": ["{workspaceRoot}/coverage/apps/api"] } diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index e141dc11f..3eeb7117c 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -128,8 +128,8 @@ export class AccountController { @Param('id') id: string ): Promise { return this.accountBalanceService.getAccountBalances({ - accountId: id, - userId: this.request.user.id + filters: [{ id, type: 'ACCOUNT' }], + user: this.request.user }); } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 65838eb98..d5da0a333 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -381,16 +381,34 @@ export class PortfolioController { this.userService.isRestrictedView(this.request.user) ) { performanceInformation.chart = performanceInformation.chart.map( - ({ date, netPerformanceInPercentage, totalInvestment, value }) => { + ({ + date, + netPerformanceInPercentage, + netWorth, + totalInvestment, + value + }) => { return { date, netPerformanceInPercentage, - totalInvestment: new Big(totalInvestment) - .div(performanceInformation.performance.totalInvestment) - .toNumber(), - valueInPercentage: new Big(value) - .div(performanceInformation.performance.currentValue) - .toNumber() + netWorthInPercentage: + performanceInformation.performance.currentNetWorth === 0 + ? 0 + : new Big(netWorth) + .div(performanceInformation.performance.currentNetWorth) + .toNumber(), + totalInvestment: + performanceInformation.performance.totalInvestment === 0 + ? 0 + : new Big(totalInvestment) + .div(performanceInformation.performance.totalInvestment) + .toNumber(), + valueInPercentage: + performanceInformation.performance.currentValue === 0 + ? 0 + : new Big(value) + .div(performanceInformation.performance.currentValue) + .toNumber() }; } ); @@ -400,6 +418,7 @@ export class PortfolioController { [ 'currentGrossPerformance', 'currentNetPerformance', + 'currentNetWorth', 'currentValue', 'totalInvestment' ] diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index bd4505e53..1f701c040 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -12,6 +12,7 @@ import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/ap import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; +import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; @@ -67,7 +68,9 @@ import { isBefore, isSameMonth, isSameYear, + isValid, max, + min, parseISO, set, setDayOfYear, @@ -75,7 +78,7 @@ import { subMonths, subYears } from 'date-fns'; -import { isEmpty, sortBy, uniq, uniqBy } from 'lodash'; +import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash'; import { HistoricalDataContainer, @@ -92,6 +95,7 @@ const europeMarkets = require('../../assets/countries/europe-markets.json'); @Injectable() export class PortfolioService { public constructor( + private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, private readonly currentRateService: CurrentRateService, private readonly dataProviderService: DataProviderService, @@ -115,8 +119,12 @@ export class PortfolioService { }): Promise { const where: Prisma.AccountWhereInput = { userId: userId }; - if (filters?.[0].id && filters?.[0].type === 'ACCOUNT') { - where.id = filters[0].id; + const accountFilter = filters?.find(({ type }) => { + return type === 'ACCOUNT'; + }); + + if (accountFilter) { + where.id = accountFilter.id; } const [accounts, details] = await Promise.all([ @@ -268,6 +276,13 @@ export class PortfolioService { includeDrafts: true }); + if (transactionPoints.length === 0) { + return { + investments: [], + streaks: { currentStreak: 0, longestStreak: 0 } + }; + } + const portfolioCalculator = new PortfolioCalculator({ currency: this.request.user.Settings.settings.baseCurrency, currentRateService: this.currentRateService, @@ -275,12 +290,6 @@ export class PortfolioService { }); portfolioCalculator.setTransactionPoints(transactionPoints); - if (transactionPoints.length === 0) { - return { - investments: [], - streaks: { currentStreak: 0, longestStreak: 0 } - }; - } let investments: InvestmentItem[]; @@ -368,67 +377,6 @@ export class PortfolioService { }; } - public async getChart({ - dateRange = 'max', - filters, - impersonationId, - userCurrency, - userId, - withExcludedAccounts = false - }: { - dateRange?: DateRange; - filters?: Filter[]; - impersonationId: string; - userCurrency: string; - userId: string; - withExcludedAccounts?: boolean; - }): Promise { - userId = await this.getUserId(impersonationId, userId); - - const { portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - filters, - userId, - withExcludedAccounts - }); - - const portfolioCalculator = new PortfolioCalculator({ - currency: userCurrency, - currentRateService: this.currentRateService, - orders: portfolioOrders - }); - - portfolioCalculator.setTransactionPoints(transactionPoints); - if (transactionPoints.length === 0) { - return { - isAllTimeHigh: false, - isAllTimeLow: false, - items: [] - }; - } - const endDate = new Date(); - - const portfolioStart = parseDate(transactionPoints[0].date); - const startDate = this.getStartDate(dateRange, portfolioStart); - - const daysInMarket = differenceInDays(new Date(), startDate); - const step = Math.round( - daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS) - ); - - const items = await portfolioCalculator.getChartData( - startDate, - endDate, - step - ); - - return { - items, - isAllTimeHigh: false, - isAllTimeLow: false - }; - } - public async getDetails({ dateRange = 'max', filters, @@ -1118,12 +1066,6 @@ export class PortfolioService { userId }); - const portfolioCalculator = new PortfolioCalculator({ - currency: this.request.user.Settings.settings.baseCurrency, - currentRateService: this.currentRateService, - orders: portfolioOrders - }); - if (transactionPoints?.length <= 0) { return { hasErrors: false, @@ -1131,6 +1073,12 @@ export class PortfolioService { }; } + const portfolioCalculator = new PortfolioCalculator({ + currency: this.request.user.Settings.settings.baseCurrency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + portfolioCalculator.setTransactionPoints(transactionPoints); const portfolioStart = parseDate(transactionPoints[0].date); @@ -1217,6 +1165,31 @@ export class PortfolioService { const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); + const accountBalances = await this.accountBalanceService.getAccountBalances( + { filters, user, withExcludedAccounts } + ); + + let accountBalanceItems: HistoricalDataItem[] = Object.values( + // Reduce the array to a map with unique dates as keys + accountBalances.balances.reduce( + ( + map: { [date: string]: HistoricalDataItem }, + { date, valueInBaseCurrency } + ) => { + const formattedDate = format(date, DATE_FORMAT); + + // Store the item in the map, overwriting if the date already exists + map[formattedDate] = { + date: formattedDate, + value: valueInBaseCurrency + }; + + return map; + }, + {} + ) + ); + const { portfolioOrders, transactionPoints } = await this.getTransactionPoints({ filters, @@ -1230,7 +1203,7 @@ export class PortfolioService { orders: portfolioOrders }); - if (transactionPoints?.length <= 0) { + if (accountBalanceItems?.length <= 0 && transactionPoints?.length <= 0) { return { chart: [], firstOrderDate: undefined, @@ -1240,6 +1213,7 @@ export class PortfolioService { currentGrossPerformancePercent: 0, currentNetPerformance: 0, currentNetPerformancePercent: 0, + currentNetWorth: 0, currentValue: 0, totalInvestment: 0 } @@ -1248,7 +1222,15 @@ export class PortfolioService { portfolioCalculator.setTransactionPoints(transactionPoints); - const portfolioStart = parseDate(transactionPoints[0].date); + const portfolioStart = min( + [ + parseDate(accountBalanceItems[0]?.date), + parseDate(transactionPoints[0]?.date) + ].filter((date) => { + return isValid(date); + }) + ); + const startDate = this.getStartDate(dateRange, portfolioStart); const { currentValue, @@ -1266,17 +1248,17 @@ export class PortfolioService { let currentNetPerformance = netPerformance; let currentNetPerformancePercent = netPerformancePercentage; - const historicalDataContainer = await this.getChart({ + const { items } = await this.getChart({ dateRange, - filters, impersonationId, + portfolioOrders, + transactionPoints, userCurrency, - userId, - withExcludedAccounts + userId }); - const itemOfToday = historicalDataContainer.items.find((item) => { - return item.date === format(new Date(), DATE_FORMAT); + const itemOfToday = items.find(({ date }) => { + return date === format(new Date(), DATE_FORMAT); }); if (itemOfToday) { @@ -1286,34 +1268,42 @@ export class PortfolioService { ).div(100); } + accountBalanceItems = accountBalanceItems.filter(({ date }) => { + return !isBefore(parseDate(date), startDate); + }); + + const accountBalanceItemOfToday = accountBalanceItems.find(({ date }) => { + return date === format(new Date(), DATE_FORMAT); + }); + + if (!accountBalanceItemOfToday) { + accountBalanceItems.push({ + date: format(new Date(), DATE_FORMAT), + value: last(accountBalanceItems)?.value ?? 0 + }); + } + + const mergedHistoricalDataItems = this.mergeHistoricalDataItems( + accountBalanceItems, + items + ); + + const currentHistoricalDataItem = last(mergedHistoricalDataItems); + const currentNetWorth = currentHistoricalDataItem?.netWorth ?? 0; + return { errors, hasErrors, - chart: historicalDataContainer.items.map( - ({ - date, - netPerformance: netPerformanceOfItem, - netPerformanceInPercentage, - totalInvestment: totalInvestmentOfItem, - value - }) => { - return { - date, - netPerformanceInPercentage, - value, - netPerformance: netPerformanceOfItem, - totalInvestment: totalInvestmentOfItem - }; - } - ), - firstOrderDate: parseDate(historicalDataContainer.items[0]?.date), + chart: mergedHistoricalDataItems, + firstOrderDate: parseDate(items[0]?.date), performance: { - currentValue: currentValue.toNumber(), + currentNetWorth, currentGrossPerformance: currentGrossPerformance.toNumber(), currentGrossPerformancePercent: currentGrossPerformancePercent.toNumber(), currentNetPerformance: currentNetPerformance.toNumber(), currentNetPerformancePercent: currentNetPerformancePercent.toNumber(), + currentValue: currentValue.toNumber(), totalInvestment: totalInvestment.toNumber() } }; @@ -1467,6 +1457,62 @@ export class PortfolioService { return cashPositions; } + private async getChart({ + dateRange = 'max', + impersonationId, + portfolioOrders, + transactionPoints, + userCurrency, + userId + }: { + dateRange?: DateRange; + impersonationId: string; + portfolioOrders: PortfolioOrder[]; + transactionPoints: TransactionPoint[]; + userCurrency: string; + userId: string; + }): Promise { + if (transactionPoints.length === 0) { + return { + isAllTimeHigh: false, + isAllTimeLow: false, + items: [] + }; + } + + userId = await this.getUserId(impersonationId, userId); + + const portfolioCalculator = new PortfolioCalculator({ + currency: userCurrency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + + portfolioCalculator.setTransactionPoints(transactionPoints); + + const endDate = new Date(); + + const portfolioStart = parseDate(transactionPoints[0].date); + const startDate = this.getStartDate(dateRange, portfolioStart); + + const daysInMarket = differenceInDays(new Date(), startDate); + const step = Math.round( + daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS) + ); + + const items = await portfolioCalculator.getChartData( + startDate, + endDate, + step + ); + + return { + items, + isAllTimeHigh: false, + isAllTimeLow: false + }; + } + private getDividendsByGroup({ dividends, groupBy @@ -2120,4 +2166,44 @@ export class PortfolioService { return { accounts, platforms }; } + + private mergeHistoricalDataItems( + accountBalanceItems: HistoricalDataItem[], + performanceChartItems: HistoricalDataItem[] + ): HistoricalDataItem[] { + const historicalDataItemsMap: { [date: string]: HistoricalDataItem } = {}; + let latestAccountBalance = 0; + + for (const item of accountBalanceItems.concat(performanceChartItems)) { + const isAccountBalanceItem = accountBalanceItems.includes(item); + + const totalAccountBalance = isAccountBalanceItem + ? item.value + : latestAccountBalance; + + if (isAccountBalanceItem && performanceChartItems.length > 0) { + latestAccountBalance = item.value; + } else { + historicalDataItemsMap[item.date] = { + ...item, + totalAccountBalance, + netWorth: + (isAccountBalanceItem ? 0 : item.value) + totalAccountBalance + }; + } + } + + // Convert to an array and sort by date in ascending order + const historicalDataItems = Object.keys(historicalDataItemsMap).map( + (date) => { + return historicalDataItemsMap[date]; + } + ); + + historicalDataItems.sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() + ); + + return historicalDataItems; + } } diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index d94dd68ad..2e7c16a39 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -111,14 +111,14 @@ export class SubscriptionService { aSubscriptions: Subscription[] ): UserWithSettings['subscription'] { if (aSubscriptions.length > 0) { - const latestSubscription = aSubscriptions.reduce((a, b) => { + const { expiresAt, price } = aSubscriptions.reduce((a, b) => { return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b; }); return { - expiresAt: latestSubscription.expiresAt, - offer: latestSubscription.price === 0 ? 'default' : 'renewal', - type: isBefore(new Date(), latestSubscription.expiresAt) + expiresAt, + offer: price ? 'renewal' : 'default', + type: isBefore(new Date(), expiresAt) ? SubscriptionType.Premium : SubscriptionType.Basic }; diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index b72da8af1..fe6625439 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -60,7 +60,7 @@ export class UserService { PROPERTY_SYSTEM_MESSAGE )) as SystemMessage; - if (systemMessageProperty?.targetGroups?.includes(subscription.type)) { + if (systemMessageProperty?.targetGroups?.includes(subscription?.type)) { systemMessage = systemMessageProperty; } diff --git a/apps/api/src/decorators/has-permission.decorator.ts b/apps/api/src/decorators/has-permission.decorator.ts new file mode 100644 index 000000000..dc65cf82e --- /dev/null +++ b/apps/api/src/decorators/has-permission.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +export const HAS_PERMISSION_KEY = 'has_permission'; + +export function HasPermission(permission: string) { + return SetMetadata(HAS_PERMISSION_KEY, permission); +} diff --git a/apps/api/src/guards/has-permission.guard.spec.ts b/apps/api/src/guards/has-permission.guard.spec.ts new file mode 100644 index 000000000..7f5f90de9 --- /dev/null +++ b/apps/api/src/guards/has-permission.guard.spec.ts @@ -0,0 +1,55 @@ +import { HttpException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { HasPermissionGuard } from './has-permission.guard'; + +describe('HasPermissionGuard', () => { + let guard: HasPermissionGuard; + let reflector: Reflector; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [HasPermissionGuard, Reflector] + }).compile(); + + guard = module.get(HasPermissionGuard); + reflector = module.get(Reflector); + }); + + function setupReflectorSpy(returnValue: string) { + jest.spyOn(reflector, 'get').mockReturnValue(returnValue); + } + + function createMockExecutionContext(permissions: string[]) { + return new ExecutionContextHost([ + { + user: { + permissions // Set user permissions based on the argument + } + } + ]); + } + + it('should deny access if the user does not have any permission', () => { + setupReflectorSpy('required-permission'); + const noPermissions = createMockExecutionContext([]); + + expect(() => guard.canActivate(noPermissions)).toThrow(HttpException); + }); + + it('should deny access if the user has the wrong permission', () => { + setupReflectorSpy('required-permission'); + const wrongPermission = createMockExecutionContext(['wrong-permission']); + + expect(() => guard.canActivate(wrongPermission)).toThrow(HttpException); + }); + + it('should allow access if the user has the required permission', () => { + setupReflectorSpy('required-permission'); + const rightPermission = createMockExecutionContext(['required-permission']); + + expect(guard.canActivate(rightPermission)).toBe(true); + }); +}); diff --git a/apps/api/src/guards/has-permission.guard.ts b/apps/api/src/guards/has-permission.guard.ts new file mode 100644 index 000000000..298992d06 --- /dev/null +++ b/apps/api/src/guards/has-permission.guard.ts @@ -0,0 +1,37 @@ +import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { hasPermission } from '@ghostfolio/common/permissions'; +import { + CanActivate, + ExecutionContext, + HttpException, + Injectable +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@Injectable() +export class HasPermissionGuard implements CanActivate { + public constructor(private reflector: Reflector) {} + + public canActivate(context: ExecutionContext): boolean { + const requiredPermission = this.reflector.get( + HAS_PERMISSION_KEY, + context.getHandler() + ); + + if (!requiredPermission) { + return true; // No specific permissions required + } + + const { user } = context.switchToHttp().getRequest(); + + if (!user || !hasPermission(user.permissions, requiredPermission)) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return true; + } +} diff --git a/apps/api/src/services/account-balance/account-balance.module.ts b/apps/api/src/services/account-balance/account-balance.module.ts index 53c695b5f..c85727f8c 100644 --- a/apps/api/src/services/account-balance/account-balance.module.ts +++ b/apps/api/src/services/account-balance/account-balance.module.ts @@ -1,10 +1,11 @@ import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; @Module({ exports: [AccountBalanceService], - imports: [PrismaModule], + imports: [ExchangeRateDataModule, PrismaModule], providers: [AccountBalanceService] }) export class AccountBalanceModule {} diff --git a/apps/api/src/services/account-balance/account-balance.service.ts b/apps/api/src/services/account-balance/account-balance.service.ts index 9995bbc3e..e1d002428 100644 --- a/apps/api/src/services/account-balance/account-balance.service.ts +++ b/apps/api/src/services/account-balance/account-balance.service.ts @@ -1,11 +1,16 @@ +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; -import { AccountBalancesResponse } from '@ghostfolio/common/interfaces'; +import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces'; +import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { AccountBalance, Prisma } from '@prisma/client'; @Injectable() export class AccountBalanceService { - public constructor(private readonly prismaService: PrismaService) {} + public constructor( + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly prismaService: PrismaService + ) {} public async createAccountBalance( data: Prisma.AccountBalanceCreateInput @@ -16,27 +21,52 @@ export class AccountBalanceService { } public async getAccountBalances({ - accountId, - userId + filters, + user, + withExcludedAccounts }: { - accountId: string; - userId: string; + filters?: Filter[]; + user: UserWithSettings; + withExcludedAccounts?: boolean; }): Promise { + const where: Prisma.AccountBalanceWhereInput = { userId: user.id }; + + const accountFilter = filters?.find(({ type }) => { + return type === 'ACCOUNT'; + }); + + if (accountFilter) { + where.accountId = accountFilter.id; + } + + if (withExcludedAccounts === false) { + where.Account = { isExcluded: false }; + } + const balances = await this.prismaService.accountBalance.findMany({ + where, orderBy: { date: 'asc' }, select: { + Account: true, date: true, id: true, value: true - }, - where: { - accountId, - userId } }); - return { balances }; + return { + balances: balances.map((balance) => { + return { + ...balance, + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + balance.value, + balance.Account.currency, + user.Settings.settings.baseCurrency + ) + }; + }) + }; } } diff --git a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts index 6ed67bf8b..a9eb44405 100644 --- a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts +++ b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts @@ -56,7 +56,13 @@ export class CoinGeckoService implements DataProviderInterface { response.name = name; } catch (error) { - Logger.error(error, 'CoinGeckoService'); + let message = error; + + if (error?.code === 'ABORT_ERR') { + message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; + } + + Logger.error(message, 'CoinGeckoService'); } return response; @@ -174,7 +180,13 @@ export class CoinGeckoService implements DataProviderInterface { }; } } catch (error) { - Logger.error(error, 'CoinGeckoService'); + let message = error; + + if (error?.code === 'ABORT_ERR') { + message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; + } + + Logger.error(message, 'CoinGeckoService'); } return response; @@ -216,7 +228,13 @@ export class CoinGeckoService implements DataProviderInterface { }; }); } catch (error) { - Logger.error(error, 'CoinGeckoService'); + let message = error; + + if (error?.code === 'ABORT_ERR') { + message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; + } + + Logger.error(message, 'CoinGeckoService'); } return { items }; diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index ef5143475..cd5874ca7 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -346,7 +346,7 @@ export class DataProviderService { ); try { - this.marketDataService.updateMany({ + await this.marketDataService.updateMany({ data: Object.keys(response) .filter((symbol) => { return ( diff --git a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts index a582a953d..448d54b25 100644 --- a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts +++ b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts @@ -229,7 +229,13 @@ export class EodHistoricalDataService implements DataProviderInterface { return response; } catch (error) { - Logger.error(error, 'EodHistoricalDataService'); + let message = error; + + if (error?.code === 'ABORT_ERR') { + message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; + } + + Logger.error(message, 'EodHistoricalDataService'); } return {}; @@ -382,7 +388,13 @@ export class EodHistoricalDataService implements DataProviderInterface { } ); } catch (error) { - Logger.error(error, 'EodHistoricalDataService'); + let message = error; + + if (error?.code === 'ABORT_ERR') { + message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; + } + + Logger.error(message, 'EodHistoricalDataService'); } return searchResult; diff --git a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts index 8c27f01a6..1c3db1520 100644 --- a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts +++ b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts @@ -151,7 +151,13 @@ export class FinancialModelingPrepService implements DataProviderInterface { }; } } catch (error) { - Logger.error(error, 'FinancialModelingPrepService'); + let message = error; + + if (error?.code === 'ABORT_ERR') { + message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; + } + + Logger.error(message, 'FinancialModelingPrepService'); } return response; @@ -196,7 +202,13 @@ export class FinancialModelingPrepService implements DataProviderInterface { }; }); } catch (error) { - Logger.error(error, 'FinancialModelingPrepService'); + let message = error; + + if (error?.code === 'ABORT_ERR') { + message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; + } + + Logger.error(message, 'FinancialModelingPrepService'); } return { items }; diff --git a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts index 5e833cbf1..5c2df222c 100644 --- a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts +++ b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts @@ -163,7 +163,13 @@ export class RapidApiService implements DataProviderInterface { return fgi; } catch (error) { - Logger.error(error, 'RapidApiService'); + let message = error; + + if (error?.code === 'ABORT_ERR') { + message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; + } + + Logger.error(message, 'RapidApiService'); return undefined; } diff --git a/apps/client/project.json b/apps/client/project.json index 0ccce26d8..867ef252a 100644 --- a/apps/client/project.json +++ b/apps/client/project.json @@ -152,8 +152,8 @@ "serve": { "executor": "@nx/angular:webpack-dev-server", "options": { - "browserTarget": "client:build", - "proxyConfig": "apps/client/proxy.conf.json" + "proxyConfig": "apps/client/proxy.conf.json", + "browserTarget": "client:build" }, "configurations": { "development-de": { @@ -215,8 +215,7 @@ "test": { "executor": "@nx/jest:jest", "options": { - "jestConfig": "apps/client/jest.config.ts", - "passWithNoTests": true + "jestConfig": "apps/client/jest.config.ts" }, "outputs": ["{workspaceRoot}/coverage/apps/client"] } diff --git a/apps/client/src/app/adapter/custom-date-adapter.ts b/apps/client/src/app/adapter/custom-date-adapter.ts index 663c91b72..53809dc5f 100644 --- a/apps/client/src/app/adapter/custom-date-adapter.ts +++ b/apps/client/src/app/adapter/custom-date-adapter.ts @@ -1,4 +1,3 @@ -import { Platform } from '@angular/cdk/platform'; import { Inject, forwardRef } from '@angular/core'; import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core'; import { getDateFormatString } from '@ghostfolio/common/helper'; @@ -7,10 +6,9 @@ import { addYears, format, getYear, parse } from 'date-fns'; export class CustomDateAdapter extends NativeDateAdapter { public constructor( @Inject(MAT_DATE_LOCALE) public locale: string, - @Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string, - platform: Platform + @Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string ) { - super(matDateLocale, platform); + super(matDateLocale); } /** diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index d232cb3df..b3a916da9 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -29,14 +29,15 @@ import { AccountDetailDialogParams } from './interfaces/interfaces'; styleUrls: ['./account-detail-dialog.component.scss'] }) export class AccountDetailDialog implements OnDestroy, OnInit { + public activities: OrderWithAccount[]; public balance: number; public currency: string; public equity: number; public hasImpersonationId: boolean; public historicalDataItems: HistoricalDataItem[]; + public isLoadingActivities: boolean; public isLoadingChart: boolean; public name: string; - public orders: OrderWithAccount[]; public platformName: string; public transactionCount: number; public user: User; @@ -64,6 +65,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { } public ngOnInit() { + this.isLoadingActivities = true; this.isLoadingChart = true; this.dataService @@ -103,7 +105,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit { }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ activities }) => { - this.orders = activities; + this.activities = activities; + + this.isLoadingActivities = false; this.changeDetectorRef.markForCheck(); }); @@ -122,13 +126,13 @@ export class AccountDetailDialog implements OnDestroy, OnInit { .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ chart }) => { this.historicalDataItems = chart.map( - ({ date, value, valueInPercentage }) => { + ({ date, netWorth, netWorthInPercentage }) => { return { date, value: this.hasImpersonationId || this.user.settings.isRestrictedView - ? valueInPercentage - : value + ? netWorthInPercentage + : netWorth }; } ); @@ -153,8 +157,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit { public onExport() { this.dataService .fetchExport( - this.orders.map((order) => { - return order.id; + this.activities.map(({ id }) => { + return id; }) ) .pipe(takeUntil(this.unsubscribeSubject)) diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index 02d1c917e..7e92eca85 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -31,7 +31,7 @@ > -
+
-
-
-
Activities
+ + + Activities -
-
+ + + Cash Balances + + +
diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts index c3d45b6ce..83ac5b6ea 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts @@ -2,9 +2,11 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatDialogModule } from '@angular/material/dialog'; +import { MatTabsModule } from '@angular/material/tabs'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module'; +import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module'; import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module'; import { GfValueModule } from '@ghostfolio/ui/value'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @@ -15,6 +17,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component'; declarations: [AccountDetailDialog], imports: [ CommonModule, + GfAccountBalancesModule, GfActivitiesTableModule, GfDialogFooterModule, GfDialogHeaderModule, @@ -22,6 +25,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component'; GfValueModule, MatButtonModule, MatDialogModule, + MatTabsModule, NgxSkeletonLoaderModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA] diff --git a/apps/client/src/app/pages/about/overview/about-overview-page.html b/apps/client/src/app/pages/about/overview/about-overview-page.html index a7053d0bc..fac002a3f 100644 --- a/apps/client/src/app/pages/about/overview/about-overview-page.html +++ b/apps/client/src/app/pages/about/overview/about-overview-page.html @@ -56,11 +56,11 @@ @ghostfolio_@ghostfolio_, send an e-mail to hi@ghostfol.iohi@ghostfol.io or start a discussion at diff --git a/apps/client/src/app/pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.html b/apps/client/src/app/pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.html index b71d65154..d700b9c56 100644 --- a/apps/client/src/app/pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.html +++ b/apps/client/src/app/pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.html @@ -131,8 +131,9 @@

Du erreichst mich per E-Mail unter - hi@ghostfol.io oder auf Twitter - @ghostfolio_. + hi@ghostfol.io oder auf + Twitter + @ghostfolio_.

Ich freue mich, von dir zu hören.
diff --git a/apps/client/src/app/pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.html b/apps/client/src/app/pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.html index 88cbd4a78..6670e0333 100644 --- a/apps/client/src/app/pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.html +++ b/apps/client/src/app/pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.html @@ -126,8 +126,8 @@

You can reach me by e-mail at - hi@ghostfol.io or on Twitter - @ghostfolio_. + hi@ghostfol.io or on Twitter + @ghostfolio_.

I look forward to hearing from you.
diff --git a/apps/client/src/app/pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.html b/apps/client/src/app/pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.html index b0cdc9ae8..d5009a2d1 100644 --- a/apps/client/src/app/pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.html +++ b/apps/client/src/app/pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.html @@ -100,9 +100,9 @@ of users. In the future, I would like to involve more contributors to further extend the functionality of Ghostfolio (e.g. with new reports). Get in touch with me by e-mail at - hi@ghostfol.io or on Twitter - @ghostfolio_ if you - are interested, I’m happy to discuss ideas. + hi@ghostfol.io or on Twitter + @ghostfolio_ if + you are interested, I’m happy to discuss ideas.

I would like to say thank you for all your feedback and support diff --git a/apps/client/src/app/pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.html b/apps/client/src/app/pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.html index b78010dd4..0366dd9af 100644 --- a/apps/client/src/app/pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.html +++ b/apps/client/src/app/pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.html @@ -90,8 +90,8 @@

If you would like to provide feedback or get involved in further development of Ghostfolio, please get in touch by e-mail via - hi@ghostfol.io or on Twitter - @ghostfolio_. + hi@ghostfol.io or on Twitter + @ghostfolio_.

I look forward to hearing from you.
diff --git a/apps/client/src/app/pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.html b/apps/client/src/app/pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.html index 5b485e921..9dea1eb2a 100644 --- a/apps/client/src/app/pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.html +++ b/apps/client/src/app/pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.html @@ -91,9 +91,9 @@ engineering to realize the full potential of open source software. If you are a web developer and interested in personal finance, please get in touch by e-mail via - hi@ghostfol.io or on Twitter - @ghostfolio_. We are - happy to discuss ideas. + hi@ghostfol.io or on Twitter + @ghostfolio_. We + are happy to discuss ideas.

We would like to say thank you for all your feedback and support diff --git a/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.html b/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.html index a6a4abd40..71725e3f6 100644 --- a/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.html +++ b/apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.html @@ -85,8 +85,8 @@ >Slack community or get in touch on Twitter - @ghostfolio_ or by - e-mail via hi@ghostfol.io. + @ghostfolio_ or by + e-mail via hi@ghostfol.io.

We look forward to hearing from you.
diff --git a/apps/client/src/app/pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.html b/apps/client/src/app/pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.html index 97668ebb8..1f4da92b0 100644 --- a/apps/client/src/app/pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.html +++ b/apps/client/src/app/pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.html @@ -92,7 +92,7 @@ > community or via Twitter @ghostfolio_@ghostfolio_. We look forward to hearing from you!

diff --git a/apps/client/src/app/pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.html b/apps/client/src/app/pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.html index f475f00da..11b05c84c 100644 --- a/apps/client/src/app/pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.html +++ b/apps/client/src/app/pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.html @@ -122,7 +122,7 @@ >Slack community or connect with - @ghostfolio_ on + @ghostfolio_ on Twitter. We are happy to discuss ideas and get you involved.

Thank you for all your feedback and support.

diff --git a/apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html b/apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html index 9f062f6a0..08aed42db 100644 --- a/apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html +++ b/apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html @@ -89,7 +89,7 @@ >Slack community or get in touch on X - @ghostfolio_. + @ghostfolio_.

We look forward to hearing from you.
diff --git a/apps/client/src/app/pages/faq/faq-page.html b/apps/client/src/app/pages/faq/faq-page.html index 3aafcbcca..93af2c75e 100644 --- a/apps/client/src/app/pages/faq/faq-page.html +++ b/apps/client/src/app/pages/faq/faq-page.html @@ -203,8 +203,8 @@ Please send an e-mail with the web address of your broker to - hi@ghostfol.io and we are happy to - add it. + hi@ghostfol.io and we are + happy to add it. @@ -234,11 +234,11 @@ @ghostfolio_@ghostfolio_, hi@ghostfol.iohi@ghostfol.io or @@ -263,11 +263,11 @@ @ghostfolio_@ghostfolio_, send an e-mail to hi@ghostfol.iohi@ghostfol.io or start a discussion at diff --git a/apps/client/src/app/pages/i18n/i18n-page.html b/apps/client/src/app/pages/i18n/i18n-page.html index b091ed4ba..4922b01cd 100644 --- a/apps/client/src/app/pages/i18n/i18n-page.html +++ b/apps/client/src/app/pages/i18n/i18n-page.html @@ -2,8 +2,9 @@

  • - Ghostfolio is a personal finance dashboard to keep track of your assets - like stocks, ETFs or cryptocurrencies across multiple platforms. + Ghostfolio is a personal finance dashboard to keep track of your net + worth including cash, stocks, ETFs and cryptocurrencies across multiple + platforms.
  • app, asset, cryptocurrency, dashboard, etf, finance, management, diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.html b/apps/client/src/app/pages/portfolio/activities/activities-page.html index a5c9201a0..8c2cf9bd5 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.html +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.html @@ -1,5 +1,5 @@
    -
    +

    Activities

    -
    +