diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e925a67..2ffe631c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,169 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 3.53.0 - 2026-08-16 + +### Changed + +- Upgraded `angular` from version `21.2.7` to `21.2.19` +- Upgraded `Nx` from version `23.0.2` to `23.1.1` +- Upgraded `storybook` from version `10.1.10` to `10.5.7` + +### Fixed + +- Fixed the internal server error on a failed social login by redirecting to the login page + +## 3.52.0 - 2026-08-15 + +### Added + +- Added the business logic and tests for stock splits of an asset profile (experimental) +- Added the scopes to the access +- Added an index for `symbolProfileId` to the order database table + +### Changed + +- Restricted the creation of tags to unique names in the tags selector component +- Changed the redaction of the monetary values in impersonation mode to be based on the scopes of the access +- Deprecated the `permissions` attribute of the access in favor of the scopes +- Extended the `GET api/v1/access` endpoint by the scopes +- Extended the `GET api/v1/user` endpoint by the scopes +- Improved the performance of deleting activities by loading only the required data + +### Fixed + +- Fixed the missing currency conversion of the dividends on the analysis page +- Fixed the missing error state in the watchlist +- Fixed the missing loading indicator in the benchmarks of the markets overview +- Fixed the incorrect error log output when deleting activities + +## 3.51.0 - 2026-08-14 + +### Changed + +- Simplified the error log output of the data provider and queue services by omitting the stack trace +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed the cash balance update related to activities in a custom currency +- Fixed the empty historical market data of the asset profile dialog of the admin control panel for asset profiles without activities +- Fixed the missing mapping for Czech Republic in the country weightings of the _Financial Modeling Prep_ service +- Fixed the missing mapping for Macau in the data enhancer for asset profile data via _Yahoo Finance_ +- Fixed the outdated exchange rates of currency pairs which are calculated indirectly via the base currency + +## 3.50.0 - 2026-08-13 + +### Changed + +- Refreshed the cryptocurrencies list +- Upgraded `countries-list` from version `3.4.0` to `3.4.1` + +### Fixed + +- Fixed the performance of closed positions in the portfolio calculator caused by a rounding remainder in the investment +- Fixed the missing mapping for Turkey in the country weightings of the _Financial Modeling Prep_ service +- Fixed the missing mapping for Czech Republic and Turkey in the data enhancer for asset profile data via _Yahoo Finance_ +- Resolved an error when fetching dividends from _Yahoo Finance_ for date ranges without events + +## 3.49.0 - 2026-08-12 + +### Changed + +- Improved the wording of the _X-ray_ page to use a neutral form +- Improved the style of the disabled base currency in the user account settings +- Moved the support to clone an activity in the account detail dialog from experimental to general availability +- Moved the support to clone an activity in the holding detail dialog from experimental to general availability +- Moved the support to edit an activity in the account detail dialog from experimental to general availability +- Moved the support to edit an activity in the holding detail dialog from experimental to general availability +- Removed the deprecated `isDraft` attribute of the activity in favor of the _Draft_ tag +- Improved the language localization for German (`de`) + +## 3.48.1 - 2026-08-11 + +### Added + +- Added the quantity to the accounts tab of the holding detail dialog (experimental) + +### Changed + +- Improved the performance of the _X-ray_ page by resolving the user only once per request +- Refactored the impersonation mode to resolve the impersonated user once per request in a guard instead of in every endpoint +- Restricted the modification of data in impersonation mode to the data of the authenticated user +- Restricted the update of the user settings in impersonation mode to the settings of the authenticated user + +### Fixed + +- Fixed the allocation in the accounts tab of the holding detail dialog caused by floating-point rounding +- Fixed the allocation in the accounts tab of the holding detail dialog by excluding the cash balance of the account +- Fixed the account aggregations in impersonation mode to be based on the impersonated user +- Fixed the base currency of the activities in impersonation mode to be based on the impersonated user +- Fixed the base currency of the dividends in impersonation mode to be based on the impersonated user +- Fixed the base currency of the user account settings in impersonation mode to be disabled +- Fixed the benchmark of the performance chart in impersonation mode to be based on the authenticated user +- Fixed the emergency fund of the _X-ray_ page in impersonation mode to be based on the impersonated user +- Fixed the redaction of the emergency fund, projected total amount and savings rate in a restricted view +- Fixed the rules of the _X-ray_ page to be withheld in a restricted view +- Fixed the savings rate of the _FIRE_ calculator in impersonation mode to be presented +- Fixed the user settings in impersonation mode to be based on the impersonated user +- Fixed the validation of the impersonation identifier of an unknown user + +## 3.47.0 - 2026-08-10 + +### Changed + +- Extended the toggle component to support a disabled state +- Extended the toggle component to support icons +- Reused the toggle component on the portfolio holdings page +- Reused the currency selector component in the user account settings + +### Fixed + +- Fixed the handling of the disabled state in the currency selector and symbol autocomplete components +- Fixed the restoration of the current selection in the currency selector component when leaving the field without picking an option + +## 3.46.0 - 2026-08-09 + +### Added + +- Added the _Draft_ tag, assigned automatically to activities dated in the future + +### Changed + +- Deprecated the `isDraft` attribute of the activity in favor of the _Draft_ tag +- Changed the activities count of an account to include draft activities +- Extended the _Draft_ tag to activities with a custom asset profile of type `BUY` +- Improved the language localization for German (`de`) +- Upgraded `bull-board` from version `8.1.2` to `8.6.0` + +### Fixed + +- Fixed the dividend and interest of an account by excluding draft activities +- Resolved an issue with unknown country names in the country weightings of the _Financial Modeling Prep_ service +- Resolved an issue with unknown country names in the data enhancer for asset profile data via _Trackinsight_ + +## 3.45.0 - 2026-08-08 + +### Added + +- Added a _Coverage_ rule to the _Emergency Fund_ section on the _X-ray_ page + +### Changed + +- Aligned the x-axis of the dividend and investment timeline charts on the analysis page +- Improved the check for duplicates in the preview step of the activities import (regardless of the account) +- Improved the check for duplicates in the preview step of the import dividends dialog (regardless of the account) +- Extended the activities import to reuse an existing account of the user by name and currency +- Extended the activities import to resolve an ISIN to the symbol of the data provider +- Improved the style of the placeholder in the entity logo component +- Migrated the create, detail and edit account dialogs to dedicated routes +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed the check for duplicates in the preview step of the activities import for activities without a comment +- Fixed the holdings mock data in the _Storybook_ story of the portfolio filter form component + ## 3.44.0 - 2026-08-07 ### Added diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index 3bad0e171..8bd22fc25 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -5,6 +6,7 @@ import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { SubscriptionType } from '@ghostfolio/common/enums'; import { Access, AccessSettings } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; +import { getScopesOfAccess } from '@ghostfolio/common/scopes'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { @@ -26,6 +28,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AccessService } from './access.service'; +@AllowDuringImpersonation() @Controller('access') export class AccessController { public constructor( @@ -45,29 +48,32 @@ export class AccessController { where: { userId: this.request.user.id } }); - return accessesWithGranteeUser.map( - ({ alias, granteeUser, id, permissions, settings }) => { - if (granteeUser) { - return { - alias, - id, - permissions, - grantee: granteeUser?.id, - settings: settings as AccessSettings, - type: 'PRIVATE' - }; - } + return accessesWithGranteeUser.map((accessItem) => { + const { alias, granteeUser, id, permissions, settings } = accessItem; + const scopes = getScopesOfAccess(accessItem); + if (granteeUser) { return { alias, id, permissions, - grantee: 'Public', + scopes, + grantee: granteeUser?.id, settings: settings as AccessSettings, - type: 'PUBLIC' + type: 'PRIVATE' }; } - ); + + return { + alias, + id, + permissions, + scopes, + grantee: 'Public', + settings: settings as AccessSettings, + type: 'PUBLIC' + }; + }); } @HasPermission(permissions.createAccess) @@ -93,6 +99,10 @@ export class AccessController { ? { connect: { id: data.granteeUserId } } : undefined, permissions: data.permissions, + scopes: getScopesOfAccess({ + granteeUserId: data.granteeUserId, + permissions: data.permissions + }), settings: this.accessService.buildSettings(data.filters), user: { connect: { id: this.request.user.id } } }); @@ -162,6 +172,10 @@ export class AccessController { ? { connect: { id: data.granteeUserId } } : { disconnect: true }, permissions: data.permissions, + scopes: getScopesOfAccess({ + granteeUserId: data.granteeUserId, + permissions: data.permissions ?? originalAccess.permissions + }), settings: this.accessService.buildSettings(data.filters) }, where: { id } diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index f43aeedd5..8b6692503 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -1,13 +1,12 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; -import { UserService } from '@ghostfolio/api/app/user/user.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; -import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { CreateAccountDto, TransferBalanceDto, @@ -19,14 +18,17 @@ import { AccountsResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import { scopes } from '@ghostfolio/common/scopes'; +import type { + ImpersonationContext, + RequestWithUser +} from '@ghostfolio/common/types'; import { Body, Controller, Delete, Get, - Headers, HttpException, Inject, Param, @@ -49,10 +51,8 @@ export class AccountController { private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, private readonly apiService: ApiService, - private readonly impersonationService: ImpersonationService, private readonly portfolioService: PortfolioService, - @Inject(REQUEST) private readonly request: RequestWithUser, - private readonly userService: UserService + @Inject(REQUEST) private readonly request: RequestWithUser ) {} @Delete(':id') @@ -85,18 +85,15 @@ export class AccountController { } @Get() - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.accountRead) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getAllAccounts( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId }: ImpersonationContext, @Query('dataSource') filterByDataSource?: string, @Query('query') filterBySearchQuery?: string, @Query('symbol') filterBySymbol?: string ): Promise { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - const filters = this.apiService.buildFiltersFromQueryParams({ filterByDataSource, filterBySearchQuery, @@ -105,25 +102,22 @@ export class AccountController { return this.portfolioService.getAccountsWithAggregations({ filters, - userId: impersonationUserId || this.request.user.id, + userId, withExcludedAccounts: true }); } @Get(':id') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.accountRead) @UseInterceptors(RedactValuesInResponseInterceptor) public async getAccountById( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId }: ImpersonationContext, @Param('id') id: string ): Promise { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - const accountsWithAggregations = await this.portfolioService.getAccountsWithAggregations({ + userId, filters: [{ id, type: 'ACCOUNT' }], - userId: impersonationUserId || this.request.user.id, withExcludedAccounts: true }); @@ -131,22 +125,16 @@ export class AccountController { } @Get(':id/balances') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.accountRead) @UseInterceptors(RedactValuesInResponseInterceptor) public async getAccountBalancesById( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId, userSettings }: ImpersonationContext, @Param('id') id: string ): Promise { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - const userId = impersonationUserId || this.request.user.id; - - const { settings } = await this.userService.user({ id: userId }); - return this.accountBalanceService.getAccountBalances({ userId, filters: [{ id, type: 'ACCOUNT' }], - userCurrency: settings.settings.baseCurrency + userCurrency: userSettings.baseCurrency }); } diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index 3d0bb91bd..ccd7d24bf 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -89,7 +89,10 @@ export class AccountService { orderBy?: Prisma.AccountOrderByWithRelationInput; }): Promise< (AccountWithBalance & { - activities?: (Order & { SymbolProfile?: SymbolProfile })[]; + activities?: (Order & { + SymbolProfile?: SymbolProfile; + tags?: Pick[]; + })[]; balances?: AccountBalance[]; platform?: Platform; tags?: Tag[]; @@ -172,7 +175,7 @@ export class AccountService { tagIds?: string[]; userId: string; }): Promise { - await this.tagService.validateTagIds({ tagIds, userId }); + await this.tagService.validateTagIdsWithoutDraftTag({ tagIds, userId }); const account = await this.prismaService.account.create({ data: { @@ -237,15 +240,10 @@ export class AccountService { }); return accounts.map((account) => { - let activitiesCount = 0; - - for (const { isDraft } of account.activities) { - if (!isDraft) { - activitiesCount += 1; - } - } - - const result = { ...account, activitiesCount }; + const result = { + ...account, + activitiesCount: account.activities.length + }; delete result.activities; @@ -317,7 +315,7 @@ export class AccountService { userId: string; where: Prisma.AccountWhereUniqueInput; }): Promise { - await this.tagService.validateTagIds({ tagIds, userId }); + await this.tagService.validateTagIdsWithoutDraftTag({ tagIds, userId }); const account = await this.prismaService.account.update({ data: { diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index a1b559c84..21adf7c19 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -1,31 +1,33 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { isActivityInFuture } from '@ghostfolio/api/helper/activity.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; -import { - DATA_GATHERING_QUEUE_PRIORITY_HIGH, - HEADER_KEY_IMPERSONATION -} from '@ghostfolio/common/config'; +import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { ActivitiesResponse, ActivityResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import { scopes } from '@ghostfolio/common/scopes'; +import type { + ImpersonationContext, + RequestWithUser +} from '@ghostfolio/common/types'; import { Body, Controller, Delete, Get, - Headers, HttpException, Inject, Param, @@ -52,7 +54,6 @@ export class ActivitiesController { private readonly apiService: ApiService, private readonly dataProviderService: DataProviderService, private readonly dataGatheringService: DataGatheringService, - private readonly impersonationService: ImpersonationService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @@ -61,7 +62,6 @@ export class ActivitiesController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async deleteActivities( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query() { accounts, @@ -73,13 +73,6 @@ export class ActivitiesController { tags }: ActivitiesFilterDto ): Promise { - if (impersonationId) { - throw new HttpException( - getReasonPhrase(StatusCodes.FORBIDDEN), - StatusCodes.FORBIDDEN - ); - } - let endDate: Date; let startDate: Date; @@ -128,12 +121,12 @@ export class ActivitiesController { } @Get() - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.activityRead) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getAllActivities( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId, userSettings }: ImpersonationContext, @Query() { accounts, @@ -166,11 +159,6 @@ export class ActivitiesController { filterByTags: tags }); - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - - const userCurrency = this.request.user.settings.settings.baseCurrency; - const { activities, count } = await this.activitiesService.getActivities({ endDate, filters, @@ -179,10 +167,10 @@ export class ActivitiesController { sortDirection, startDate, take, - userCurrency, + userId, includeDrafts: true, types: activityTypes, - userId: impersonationUserId || this.request.user.id, + userCurrency: userSettings.baseCurrency, withExcludedAccountsAndActivities: true }); @@ -190,21 +178,17 @@ export class ActivitiesController { } @Get(':id') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.activityRead) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getActivityById( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId, userSettings }: ImpersonationContext, @Param('id') id: string ): Promise { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - const userCurrency = this.request.user.settings.settings.baseCurrency; - const { activities } = await this.activitiesService.getActivities({ - userCurrency, + userId, includeDrafts: true, - userId: impersonationUserId || this.request.user.id, + userCurrency: userSettings.baseCurrency, withExcludedAccountsAndActivities: true }); @@ -287,9 +271,9 @@ export class ActivitiesController { userId: this.request.user.id }); - if (dataSource && !activity.isDraft) { + if (dataSource && !isActivityInFuture({ date: activity.date })) { // Gather symbol data in the background, if data source is set - // (not MANUAL) and not draft + // (not MANUAL) and the date is not in the future this.dataGatheringService.gatherSymbols({ dataGatheringItems: [ { @@ -369,6 +353,7 @@ export class ActivitiesController { }), user: { connect: { id: this.request.user.id } } }, + originalDate: originalActivity.date, userId: this.request.user.id, where: { id diff --git a/apps/api/src/app/activities/activities.module.ts b/apps/api/src/app/activities/activities.module.ts index 34091ba5e..1508917f0 100644 --- a/apps/api/src/app/activities/activities.module.ts +++ b/apps/api/src/app/activities/activities.module.ts @@ -6,6 +6,7 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; @@ -26,6 +27,7 @@ import { ActivitiesService } from './activities.service'; exports: [ActivitiesService], imports: [ ApiModule, + AssetProfileSplitModule, BenchmarkModule, CacheModule, DataGatheringQueueModule, diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts new file mode 100644 index 000000000..4e935efb3 --- /dev/null +++ b/apps/api/src/app/activities/activities.service.spec.ts @@ -0,0 +1,337 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { + activityDummyData, + assetProfileDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; +import { + INVESTMENT_ACTIVITY_TYPES, + NON_INVESTMENT_ACTIVITY_TYPES +} from '@ghostfolio/common/config'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity, Filter } from '@ghostfolio/common/interfaces'; + +import { AssetProfileSplit, DataSource } from '@prisma/client'; +import { Big } from 'big.js'; + +import { ActivitiesService } from './activities.service'; + +describe('ActivitiesService', () => { + let activitiesService: ActivitiesService; + let getSplitsByUserId: jest.Mock; + let accountService: { getCashDetails: jest.Mock }; + + beforeEach(() => { + getSplitsByUserId = jest.fn().mockResolvedValue([]); + accountService = { getCashDetails: jest.fn() }; + + activitiesService = new ActivitiesService( + null, + accountService as unknown as AccountService, + { getSplitsByUserId } as unknown as AssetProfileSplitService, + null, + null, + null, + null, + null, + null, + null, + null, + null + ); + }); + + describe('getActivitiesForPortfolioCalculator', () => { + it('leaves an activity unchanged when no splits exist', async () => { + const activity = createActivity({ symbol: 'AAPL' }); + + const result = await getAdjustedActivity(activity, []); + + expect(result).toEqual(activity); + expect(result).toBe(activity); + }); + + it.each([ + { denominator: 1, expectedPrice: 50, expectedQuantity: 20, numerator: 2 }, + { + denominator: 10, + expectedPrice: 1000, + expectedQuantity: 1, + numerator: 1 + } + ])( + 'applies a $numerator:$denominator split to quantity and price', + async ({ denominator, expectedPrice, expectedQuantity, numerator }) => { + const result = await getAdjustedActivity( + createActivity({ symbol: 'AAPL' }), + [createSplit('2021-01-01', numerator, denominator)] + ); + + expect(result.quantity).toBe(expectedQuantity); + expect(result.unitPrice).toBe(expectedPrice); + expect(result.unitPriceInAssetProfileCurrency).toBe(expectedPrice); + } + ); + + it('adjusts only activities before the split calendar date', async () => { + const split = createSplit('2021-01-01', 2, 1); + const activityBeforeSplit = createActivity({ + date: '2020-12-31T23:00:00.000Z', + symbol: 'AAPL' + }); + const activityOnSplitDate = createActivity({ + date: '2021-01-01T23:00:00.000Z', + symbol: 'AAPL' + }); + const activityAfterSplit = createActivity({ + date: '2021-01-02T00:00:00.000Z', + symbol: 'AAPL' + }); + + expect( + (await getAdjustedActivity(activityBeforeSplit, [split])).quantity + ).toBe(20); + expect( + (await getAdjustedActivity(activityOnSplitDate, [split])).quantity + ).toBe(10); + expect( + (await getAdjustedActivity(activityAfterSplit, [split])).quantity + ).toBe(10); + }); + + it('applies multiple splits cumulatively with exact ratio arithmetic', async () => { + const result = await getAdjustedActivity( + createActivity({ symbol: 'AAPL' }), + [createSplit('2021-01-01', 2, 1), createSplit('2022-01-01', 1, 3)] + ); + + expect(new Big(result.quantity).toFixed(15)).toBe( + new Big(20).div(3).toFixed(15) + ); + expect(result.unitPrice).toBe(150); + }); + + it('preserves fees and total activity value', async () => { + const activity = createActivity({ symbol: 'AAPL' }); + activity.feeInAssetProfileCurrency = 12; + activity.feeInBaseCurrency = 15; + + const result = await getAdjustedActivity(activity, [ + createSplit('2021-01-01', 2, 1) + ]); + + expect(result).toMatchObject({ + feeInAssetProfileCurrency: 12, + feeInBaseCurrency: 15, + value: 1000, + valueInBaseCurrency: 1000 + }); + expect(result.quantity * result.unitPrice).toBe(1000); + }); + + it('does not apply splits from another symbol or data source', async () => { + const activity = createActivity({ symbol: 'AAPL' }); + const split = createSplit('2021-01-01', 2, 1); + + jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ + activities: [activity], + count: 1 + }); + getSplitsByUserId.mockResolvedValue([ + { ...split, symbolProfileId: 'YAHOO-MSFT-profile' }, + { ...split, symbolProfileId: 'MANUAL-AAPL-profile' } + ]); + + const result = + await activitiesService.getActivitiesForPortfolioCalculator({ + userCurrency: 'USD', + userId: 'user-id' + }); + + expect(result.activities[0].quantity).toBe(10); + expect(result.activities[0].unitPrice).toBe(100); + }); + + it.each(INVESTMENT_ACTIVITY_TYPES)( + 'adjusts %s activities', + async (type) => { + const activity = createActivity({ symbol: 'AAPL' }); + activity.type = type as Activity['type']; + + const result = await getAdjustedActivity(activity, [ + createSplit('2021-01-01', 2, 1) + ]); + + expect(result.quantity).toBe(20); + expect(result.unitPrice).toBe(50); + expect(result.quantity * result.unitPrice).toBe(1000); + } + ); + + it.each(NON_INVESTMENT_ACTIVITY_TYPES)( + 'leaves %s activities unchanged', + async (type) => { + const activity = createActivity({ symbol: 'AAPL' }); + activity.type = type as Activity['type']; + + const result = await getAdjustedActivity(activity, [ + createSplit('2021-01-01', 2, 1) + ]); + + expect(result).toBe(activity); + expect(result.quantity).toBe(10); + expect(result.unitPrice).toBe(100); + } + ); + + it('loads and applies splits to standard activities while preserving filters', async () => { + const activity = createActivity({ symbol: 'AAPL' }); + const filters = [{ id: 'AAPL', type: 'SYMBOL' }] as Filter[]; + const split = createSplit(); + + jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ + activities: [activity], + count: 1 + }); + getSplitsByUserId.mockResolvedValue([split]); + + const result = + await activitiesService.getActivitiesForPortfolioCalculator({ + filters, + userCurrency: 'USD', + userId: 'user-id' + }); + + expect(activitiesService.getActivities).toHaveBeenCalledWith({ + filters, + userCurrency: 'USD', + userId: 'user-id', + withExcludedAccountsAndActivities: false + }); + expect(getSplitsByUserId).toHaveBeenCalledWith({ userId: 'user-id' }); + expect(result.activities[0]).toMatchObject({ + quantity: 20, + unitPrice: 50, + unitPriceInAssetProfileCurrency: 50 + }); + }); + + it('does not adjust synthetic cash activities', async () => { + const activity = createActivity({ symbol: 'AAPL' }); + const cashActivity = createActivity({ + assetSubClass: 'CASH', + currency: 'USD', + dataSource: DataSource.YAHOO, + quantity: 100, + symbol: 'USD', + unitPrice: 1 + }); + const split = createSplit(); + + jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ + activities: [activity], + count: 1 + }); + jest.spyOn(activitiesService, 'getCashActivities').mockResolvedValue({ + activities: [cashActivity], + count: 1 + }); + accountService.getCashDetails.mockResolvedValue({ accounts: [] }); + getSplitsByUserId.mockResolvedValue([split]); + + const result = + await activitiesService.getActivitiesForPortfolioCalculator({ + userCurrency: 'USD', + userId: 'user-id', + withCash: true + }); + + expect(getSplitsByUserId).toHaveBeenCalledWith({ userId: 'user-id' }); + expect(result.activities).toEqual([ + expect.objectContaining({ + assetProfile: expect.objectContaining({ symbol: 'AAPL' }), + quantity: 20 + }), + cashActivity + ]); + }); + + async function getAdjustedActivity( + activity: Activity, + splits: AssetProfileSplit[] + ) { + jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ + activities: [activity], + count: 1 + }); + getSplitsByUserId.mockResolvedValue( + splits.map((split) => { + return { ...split, symbolProfileId: activity.assetProfile.id }; + }) + ); + + const result = + await activitiesService.getActivitiesForPortfolioCalculator({ + userCurrency: 'USD', + userId: 'user-id' + }); + + return result.activities[0]; + } + }); +}); + +function createActivity({ + assetSubClass, + currency, + dataSource = DataSource.YAHOO, + date = '2020-01-01', + quantity = 10, + symbol, + unitPrice = 100 +}: { + assetSubClass?: string; + currency?: string; + dataSource?: DataSource; + date?: string; + quantity?: number; + symbol: string; + unitPrice?: number; +}): Activity { + return { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + assetSubClass, + currency, + dataSource, + id: `${dataSource}-${symbol}-profile`, + symbol + }, + date: parseDate(date), + quantity, + type: 'BUY', + unitPrice, + unitPriceInAssetProfileCurrency: unitPrice, + value: quantity * unitPrice, + valueInBaseCurrency: quantity * unitPrice + } as Activity; +} + +function createSplit( + dateString = '2021-01-01', + numerator = 2, + denominator = 1 +): AssetProfileSplit { + const date = parseDate(dateString); + + return { + createdAt: date, + date, + denominator, + id: `${dateString}-${numerator}-${denominator}`, + numerator, + symbolProfileId: 'YAHOO-AAPL-profile', + updatedAt: date + }; +} diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 140726aeb..3627de912 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -7,7 +7,15 @@ import { isAccountBalanceInFuture, WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; +import { + getTagsWithDraftTag, + isActivityInFuture, + isDraftTagToBeAssigned, + WHERE_ACTIVITY_NOT_DRAFT +} from '@ghostfolio/api/helper/activity.helper'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +import { adjustActivityBySplits } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.helper'; +import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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'; @@ -21,11 +29,13 @@ import { GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, NON_INVESTMENT_ACTIVITY_TYPES, + TAG_ID_DRAFT, TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { canDeleteAssetProfile, getAssetProfileIdentifier, + isDraftActivity, isValidCustomAssetProfileSymbol } from '@ghostfolio/common/helper'; import { @@ -49,7 +59,7 @@ import { Type as ActivityType } from '@prisma/client'; import { Big } from 'big.js'; -import { endOfToday, isAfter } from 'date-fns'; +import { endOfToday } from 'date-fns'; import { groupBy, uniqBy } from 'lodash'; import { randomUUID } from 'node:crypto'; @@ -58,6 +68,7 @@ export class ActivitiesService { public constructor( private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, + private readonly assetProfileSplitService: AssetProfileSplitService, private readonly benchmarkService: BenchmarkService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, @@ -112,7 +123,7 @@ export class ActivitiesService { tags, userId }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { - await this.tagService.validateTagIds({ + await this.tagService.validateTagIdsWithoutDraftTag({ userId, tagIds: tags.map(({ id }) => { return id; @@ -120,6 +131,7 @@ export class ActivitiesService { }); const activities = await this.prismaService.order.findMany({ + include: { tags: { select: { id: true } } }, where: { userId, SymbolProfile: { @@ -129,20 +141,27 @@ export class ActivitiesService { } }); + const tagsToAssign = tags.map(({ id }) => { + return { id }; + }); + await Promise.all( - activities.map(({ id }) => - this.prismaService.order.update({ + activities.map((activity) => { + // The set operation replaces all existing connections with the provided + // ones, hence the "Draft" tag of an individual activity is carried over + const tagsToSet = isDraftActivity(activity) + ? [...tagsToAssign, { id: TAG_ID_DRAFT }] + : tagsToAssign; + + return this.prismaService.order.update({ data: { tags: { - // The set operation replaces all existing connections with the provided ones - set: tags.map((tag) => { - return { id: tag.id }; - }) + set: tagsToSet } }, - where: { id } - }) - ) + where: { id: activity.id } + }); + }) ); this.eventEmitter.emit( @@ -261,17 +280,19 @@ export class ActivitiesService { const orderData: Prisma.OrderCreateInput = data; - const isDraft = NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) - ? false - : isAfter(data.date as Date, endOfToday()); + const tagsToConnect = getTagsWithDraftTag({ + tags, + date: data.date as Date, + draftTag: { id: TAG_ID_DRAFT }, + type: data.type + }); const activity = await this.prismaService.order.create({ data: { ...orderData, account, - isDraft, tags: { - connect: tags + connect: tagsToConnect } }, include: { SymbolProfile: true } @@ -290,7 +311,7 @@ export class ActivitiesService { accountId, userId, amount: amount.toNumber(), - currency: data.SymbolProfile.connectOrCreate.create.currency, + currency: activity.currency ?? activity.SymbolProfile.currency, date: data.date as Date }); } @@ -372,36 +393,32 @@ export class ActivitiesService { types?: ActivityType[]; userId: string; }): Promise { - const { activities } = await this.getActivities({ + const where = this.getWhereClause({ endDate, filters, startDate, types, userId, includeDrafts: true, - userCurrency: undefined, withExcludedAccountsAndActivities: true }); - const { count } = await this.prismaService.order.deleteMany({ - where: { - id: { - in: activities.map(({ id }) => { - return id; - }) - } - } + const activities = await this.prismaService.order.findMany({ + where, + distinct: ['symbolProfileId'], + select: { symbolProfileId: true } }); - const symbolProfiles = - await this.symbolProfileService.getSymbolProfilesByIds( + const { count } = await this.prismaService.order.deleteMany({ where }); + + const [benchmarkAssetProfiles, symbolProfiles] = await Promise.all([ + this.benchmarkService.getBenchmarkAssetProfiles(), + this.symbolProfileService.getSymbolProfilesByIds( activities.map(({ symbolProfileId }) => { return symbolProfileId; }) - ); - - const benchmarkAssetProfiles = - await this.benchmarkService.getBenchmarkAssetProfiles(); + ) + ]); for (const { activitiesCount, @@ -518,7 +535,6 @@ export class ActivitiesService { feeInAssetProfileCurrency: 0, feeInBaseCurrency: 0, id: balanceItem.id, - isDraft: false, quantity: 1, symbolProfileId: account.currency, type: ActivityType.BUY, @@ -607,160 +623,19 @@ export class ActivitiesService { { date: 'asc' } ]; - const andConditions: Prisma.OrderWhereInput[] = []; - const where: Prisma.OrderWhereInput = { userId, AND: andConditions }; - - if (endDate) { - andConditions.push({ date: { lte: endDate } }); - } - - if (startDate) { - andConditions.push({ date: { gt: startDate } }); - } - - const { - ACCOUNT: filtersByAccount = [], - ASSET_CLASS: filtersByAssetClass = [], - DATA_SOURCE: [filterByDataSource] = [], - SEARCH_QUERY: [filterBySearchQuery] = [], - SYMBOL: [filterBySymbol] = [], - TAG: filtersByTag = [] - } = groupBy(filters, ({ type }) => { - return type; - }); - - if (filtersByAccount.length > 0) { - where.accountId = { - in: filtersByAccount.map(({ id }) => { - return id; - }) - }; - } - - if (includeDrafts === false) { - where.isDraft = false; - } - - if (filtersByAssetClass.length > 0) { - where.SymbolProfile = { - OR: [ - { - AND: [ - { - OR: filtersByAssetClass.map(({ id }) => { - return { assetClass: AssetClass[id] }; - }) - }, - { - OR: [ - { assetProfileOverrides: { is: null } }, - { assetProfileOverrides: { assetClass: null } } - ] - } - ] - }, - { - assetProfileOverrides: { - OR: filtersByAssetClass.map(({ id }) => { - return { assetClass: AssetClass[id] }; - }) - } - } - ] - }; - } - - if (filterByDataSource && filterBySymbol) { - if (where.SymbolProfile) { - where.SymbolProfile = { - AND: [ - where.SymbolProfile, - { - AND: [ - { dataSource: filterByDataSource.id as DataSource }, - { symbol: filterBySymbol.id } - ] - } - ] - }; - } else { - where.SymbolProfile = { - AND: [ - { dataSource: filterByDataSource.id as DataSource }, - { symbol: filterBySymbol.id } - ] - }; - } - } - - if (filterBySearchQuery) { - const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [ - { id: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, - { isin: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, - { name: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, - { symbol: { mode: 'insensitive', startsWith: filterBySearchQuery.id } } - ]; - - if (where.SymbolProfile) { - where.SymbolProfile = { - AND: [ - where.SymbolProfile, - { - OR: searchQueryWhereInput - } - ] - }; - } else { - where.SymbolProfile = { - OR: searchQueryWhereInput - }; - } - } - - if (filtersByTag.length > 0) { - andConditions.push({ - OR: [ - { - tags: { - some: { - OR: filtersByTag.map(({ id }) => { - return { id }; - }) - } - } - }, - { - account: { - tags: { - some: { - OR: filtersByTag.map(({ id }) => { - return { tagId: id }; - }) - } - } - } - } - ] - }); - } - if (sortColumn) { orderBy = [{ [sortColumn]: sortDirection }]; } - if (types?.length > 0) { - where.type = { in: types }; - } - - if (withExcludedAccountsAndActivities === false) { - where.OR = [{ account: null }, { account: WHERE_ACCOUNT_NOT_EXCLUDED }]; - - where.tags = { - none: { - id: TAG_ID_EXCLUDE_FROM_ANALYSIS - } - }; - } + const where = this.getWhereClause({ + endDate, + filters, + includeDrafts, + startDate, + types, + userId, + withExcludedAccountsAndActivities + }); const [orders, count] = await Promise.all([ this.orders({ @@ -894,12 +769,26 @@ export class ActivitiesService { /** Whether to include cash activities in the result. */ withCash?: boolean; }) { - const activities = await this.getActivities({ - filters, - userCurrency, - userId, - withExcludedAccountsAndActivities: false // TODO - }); + const [activities, splits] = await Promise.all([ + this.getActivities({ + filters, + userCurrency, + userId, + withExcludedAccountsAndActivities: false // TODO + }), + this.assetProfileSplitService.getSplitsByUserId({ userId }) + ]); + + if (splits.length > 0) { + const splitsBySymbolProfileId = groupBy(splits, 'symbolProfileId'); + + activities.activities = activities.activities.map((activity) => { + return adjustActivityBySplits( + activity, + splitsBySymbolProfileId[activity.assetProfile.id] ?? [] + ); + }); + } if (withCash && !this.areCashActivitiesExcludedByFilters(filters)) { const cashDetails = await this.accountService.getCashDetails({ @@ -942,6 +831,25 @@ export class ActivitiesService { }; } + /** + * Returns the id of every user who has an activity for the given asset + * profile, including draft activities and activities of excluded accounts + */ + public async getUserIdsBySymbolProfileId( + symbolProfileId: string + ): Promise { + const activitiesByUser = await this.prismaService.order.groupBy({ + by: ['userId'], + where: { + symbolProfileId + } + }); + + return activitiesByUser.map(({ userId }) => { + return userId; + }); + } + public async order( orderWhereUniqueInput: Prisma.OrderWhereUniqueInput ): Promise { @@ -952,6 +860,7 @@ export class ActivitiesService { public async updateActivity({ data, + originalDate, userId, where }: { @@ -963,9 +872,11 @@ export class ActivitiesService { tags?: { id: string }[]; type?: ActivityType; }; + originalDate: Date; userId: string; where: Prisma.OrderWhereUniqueInput; }): Promise { + const areTagsProvided = data.tags !== undefined; const tags = data.tags ?? []; await this.tagService.validateTagIds({ @@ -979,8 +890,6 @@ export class ActivitiesService { data.comment = null; } - let isDraft = false; - if ( NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) || (data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' && @@ -991,10 +900,9 @@ export class ActivitiesService { } else { delete data.SymbolProfile.update; - isDraft = isAfter(data.date as Date, endOfToday()); - - if (!isDraft) { - // Gather symbol data of order in the background, if not draft + if (!isActivityInFuture({ date: data.date as Date })) { + // Gather symbol data of order in the background, if the date is not in + // the future this.dataGatheringService.gatherSymbols({ dataGatheringItems: [ { @@ -1014,14 +922,35 @@ export class ActivitiesService { delete data.symbol; delete data.tags; + // Leave the tags untouched if the request does not provide them, so that a + // partial update cannot drop the "Draft" tag + let tagsToUpdate: Prisma.OrderUpdateInput['tags']; + + if (areTagsProvided) { + tagsToUpdate = { + set: getTagsWithDraftTag({ + originalDate, + tags, + date: data.date as Date, + draftTag: { id: TAG_ID_DRAFT }, + type: data.type + }) + }; + } else if ( + isDraftTagToBeAssigned({ + originalDate, + date: data.date as Date, + type: data.type + }) + ) { + tagsToUpdate = { connect: { id: TAG_ID_DRAFT } }; + } + const activity = await this.prismaService.order.update({ where, data: { ...data, - isDraft, - tags: { - set: tags - } + tags: tagsToUpdate } }); @@ -1035,6 +964,181 @@ export class ActivitiesService { return activity; } + private getWhereClause({ + endDate, + filters, + includeDrafts, + startDate, + types, + userId, + withExcludedAccountsAndActivities + }: { + endDate?: Date; + filters?: Filter[]; + includeDrafts: boolean; + startDate?: Date; + types?: ActivityType[]; + userId: string; + withExcludedAccountsAndActivities: boolean; + }): Prisma.OrderWhereInput { + const andConditions: Prisma.OrderWhereInput[] = []; + const where: Prisma.OrderWhereInput = { userId, AND: andConditions }; + + if (endDate) { + andConditions.push({ date: { lte: endDate } }); + } + + if (startDate) { + andConditions.push({ date: { gt: startDate } }); + } + + const { + ACCOUNT: filtersByAccount = [], + ASSET_CLASS: filtersByAssetClass = [], + DATA_SOURCE: [filterByDataSource] = [], + SEARCH_QUERY: [filterBySearchQuery] = [], + SYMBOL: [filterBySymbol] = [], + TAG: filtersByTag = [] + } = groupBy(filters, ({ type }) => { + return type; + }); + + if (filtersByAccount.length > 0) { + where.accountId = { + in: filtersByAccount.map(({ id }) => { + return id; + }) + }; + } + + const isFilteredByDraftTag = filtersByTag.some(({ id }) => { + return id === TAG_ID_DRAFT; + }); + + if (includeDrafts === false && !isFilteredByDraftTag) { + andConditions.push(WHERE_ACTIVITY_NOT_DRAFT); + } + + if (filtersByAssetClass.length > 0) { + where.SymbolProfile = { + OR: [ + { + AND: [ + { + OR: filtersByAssetClass.map(({ id }) => { + return { assetClass: AssetClass[id] }; + }) + }, + { + OR: [ + { assetProfileOverrides: { is: null } }, + { assetProfileOverrides: { assetClass: null } } + ] + } + ] + }, + { + assetProfileOverrides: { + OR: filtersByAssetClass.map(({ id }) => { + return { assetClass: AssetClass[id] }; + }) + } + } + ] + }; + } + + if (filterByDataSource && filterBySymbol) { + if (where.SymbolProfile) { + where.SymbolProfile = { + AND: [ + where.SymbolProfile, + { + AND: [ + { dataSource: filterByDataSource.id as DataSource }, + { symbol: filterBySymbol.id } + ] + } + ] + }; + } else { + where.SymbolProfile = { + AND: [ + { dataSource: filterByDataSource.id as DataSource }, + { symbol: filterBySymbol.id } + ] + }; + } + } + + if (filterBySearchQuery) { + const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [ + { id: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, + { isin: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, + { name: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, + { symbol: { mode: 'insensitive', startsWith: filterBySearchQuery.id } } + ]; + + if (where.SymbolProfile) { + where.SymbolProfile = { + AND: [ + where.SymbolProfile, + { + OR: searchQueryWhereInput + } + ] + }; + } else { + where.SymbolProfile = { + OR: searchQueryWhereInput + }; + } + } + + if (filtersByTag.length > 0) { + andConditions.push({ + OR: [ + { + tags: { + some: { + OR: filtersByTag.map(({ id }) => { + return { id }; + }) + } + } + }, + { + account: { + tags: { + some: { + OR: filtersByTag.map(({ id }) => { + return { tagId: id }; + }) + } + } + } + } + ] + }); + } + + if (types?.length > 0) { + where.type = { in: types }; + } + + if (withExcludedAccountsAndActivities === false) { + where.OR = [{ account: null }, { account: WHERE_ACCOUNT_NOT_EXCLUDED }]; + + where.tags = { + none: { + id: TAG_ID_EXCLUDE_FROM_ANALYSIS + } + }; + } + + return where; + } + private async orders(params: { include?: Prisma.OrderInclude; skip?: number; diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 9009ded54..8653191b9 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; @@ -61,6 +62,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AdminService } from './admin.service'; import { PropertyKeyPipe } from './pipes/property-key.pipe'; +@AllowDuringImpersonation() @Controller('admin') export class AdminController { private readonly logger = new Logger(AdminController.name); diff --git a/apps/api/src/app/admin/queue/queue.controller.ts b/apps/api/src/app/admin/queue/queue.controller.ts index 060abd247..71eedd16e 100644 --- a/apps/api/src/app/admin/queue/queue.controller.ts +++ b/apps/api/src/app/admin/queue/queue.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { AdminJobs } from '@ghostfolio/common/interfaces'; @@ -16,6 +17,7 @@ import { JobStatus } from 'bull'; import { QueueService } from './queue.service'; +@AllowDuringImpersonation() @Controller('admin/queue') export class QueueController { public constructor(private readonly queueService: QueueService) {} diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index ddda044a7..e79abca50 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,5 +1,6 @@ import { EventsModule } from '@ghostfolio/api/events/events.module'; import { PortfolioSnapshotComputationExceptionFilter } from '@ghostfolio/api/filters/portfolio-snapshot-computation-exception.filter'; +import { ImpersonationWriteGuard } from '@ghostfolio/api/guards/impersonation-write.guard'; import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; @@ -25,7 +26,7 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis' import { BullModule } from '@nestjs/bull'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { APP_FILTER } from '@nestjs/core'; +import { APP_FILTER, APP_GUARD } from '@nestjs/core'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; @@ -191,6 +192,10 @@ import { UserModule } from './user/user.module'; { provide: APP_FILTER, useClass: PortfolioSnapshotComputationExceptionFilter + }, + { + provide: APP_GUARD, + useClass: ImpersonationWriteGuard } ] }) diff --git a/apps/api/src/app/auth-device/auth-device.controller.ts b/apps/api/src/app/auth-device/auth-device.controller.ts index c46589d74..45f7925ad 100644 --- a/apps/api/src/app/auth-device/auth-device.controller.ts +++ b/apps/api/src/app/auth-device/auth-device.controller.ts @@ -1,4 +1,5 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { permissions } from '@ghostfolio/common/permissions'; @@ -16,6 +17,7 @@ import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +@AllowDuringImpersonation() @Controller('auth-device') export class AuthDeviceController { public constructor( diff --git a/apps/api/src/app/auth/api-key.strategy.ts b/apps/api/src/app/auth/api-key.strategy.ts index 232a272bc..af83f4c60 100644 --- a/apps/api/src/app/auth/api-key.strategy.ts +++ b/apps/api/src/app/auth/api-key.strategy.ts @@ -56,22 +56,20 @@ export class ApiKeyStrategy extends PassportStrategy( } private async validateApiKey(apiKey: string) { - if (!apiKey) { - throw new HttpException( - getReasonPhrase(StatusCodes.UNAUTHORIZED), - StatusCodes.UNAUTHORIZED - ); - } - - try { - const { id } = await this.apiKeyService.getUserByApiKey(apiKey); + if (apiKey) { + try { + const { id } = await this.apiKeyService.getUserByApiKey(apiKey); + const user = await this.userService.user({ id }); - return this.userService.user({ id }); - } catch { - throw new HttpException( - getReasonPhrase(StatusCodes.UNAUTHORIZED), - StatusCodes.UNAUTHORIZED - ); + if (user) { + return user; + } + } catch {} } + + throw new HttpException( + getReasonPhrase(StatusCodes.UNAUTHORIZED), + StatusCodes.UNAUTHORIZED + ); } } diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index e3886e39c..da45f0071 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -1,6 +1,8 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { OAuthCallbackGuard } from '@ghostfolio/api/guards/oauth-callback.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { @@ -27,6 +29,7 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { AuthService } from './auth.service'; +@AllowDuringImpersonation() @Controller('auth') export class AuthController { public constructor( @@ -60,13 +63,13 @@ export class AuthController { } @Get('google/callback') - @UseGuards(AuthGuard('google')) + @UseGuards(OAuthCallbackGuard('google')) @Version(VERSION_NEUTRAL) public googleLoginCallback( @Req() request: Request, @Res() response: Response ) { - const jwt: string = (request.user as any).jwt; + const jwt: string = (request.user as any)?.jwt; if (jwt) { response.redirect( @@ -96,10 +99,10 @@ export class AuthController { } @Get('oidc/callback') - @UseGuards(AuthGuard('oidc')) + @UseGuards(OAuthCallbackGuard('oidc')) @Version(VERSION_NEUTRAL) public oidcLoginCallback(@Req() request: Request, @Res() response: Response) { - const jwt: string = (request.user as any).jwt; + const jwt: string = (request.user as any)?.jwt; if (jwt) { response.redirect( diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index ddc41abad..e2a71ca84 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -69,7 +69,7 @@ import { OidcStrategy } from './oidc.strategy'; const issuer = configurationService.get('OIDC_ISSUER'); const scope = configurationService.get('OIDC_SCOPE'); - const callbackUrl = + const callbackURL = configurationService.get('OIDC_CALLBACK_URL') || `${configurationService.get('ROOT_URL')}/api/auth/oidc/callback`; @@ -114,15 +114,26 @@ import { OidcStrategy } from './oidc.strategy'; } } + const clientID = configurationService.get('OIDC_CLIENT_ID'); + const clientSecret = configurationService.get('OIDC_CLIENT_SECRET'); + + if (!clientID || !clientSecret || !issuer) { + logger.error( + 'OIDC configuration incomplete: issuer, clientID, or clientSecret missing' + ); + + throw new Error('OIDC configuration incomplete'); + } + const options: StrategyOptions = { authorizationURL, + callbackURL, + clientID, + clientSecret, issuer, scope, tokenURL, - userInfoURL, - callbackURL: callbackUrl, - clientID: configurationService.get('OIDC_CLIENT_ID'), - clientSecret: configurationService.get('OIDC_CLIENT_SECRET') + userInfoURL }; return new OidcStrategy(authService, options); diff --git a/apps/api/src/app/auth/oidc-state.store.ts b/apps/api/src/app/auth/oidc-state.store.ts index 653451166..aebd99892 100644 --- a/apps/api/src/app/auth/oidc-state.store.ts +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -1,17 +1,24 @@ +import type { Request } from 'express'; import ms from 'ms'; +import type { + SessionStore, + SessionStoreCallback, + SessionStoreContext, + SessionVerifyCallback +} from 'passport-openidconnect'; /** * Custom state store for OIDC authentication that doesn't rely on express-session. * This store manages OAuth2 state parameters in memory with automatic cleanup. */ -export class OidcStateStore { +export class OidcStateStore implements SessionStore { private readonly STATE_EXPIRY_MS = ms('10 minutes'); private stateMap = new Map< string, { appState?: unknown; - ctx: { issued?: Date; maxAge?: number; nonce?: string }; + ctx: SessionStoreContext; meta?: unknown; timestamp: number; } @@ -19,14 +26,13 @@ export class OidcStateStore { /** * Store request state. - * Signature matches passport-openidconnect SessionStore */ public store( - _req: unknown, - _meta: unknown, + _req: Request, + ctx: SessionStoreContext, appState: unknown, - ctx: { maxAge?: number; nonce?: string; issued?: Date }, - callback: (err: Error | null, handle?: string) => void + meta: unknown, + callback: SessionStoreCallback ) { try { // Generate a unique handle for this state @@ -35,7 +41,7 @@ export class OidcStateStore { this.stateMap.set(handle, { appState, ctx, - meta: _meta, + meta, timestamp: Date.now() }); @@ -50,16 +56,11 @@ export class OidcStateStore { /** * Verify request state. - * Signature matches passport-openidconnect SessionStore */ public verify( - _req: unknown, + _req: Request, handle: string, - callback: ( - err: Error | null, - appState?: unknown, - ctx?: { maxAge?: number; nonce?: string; issued?: Date } - ) => void + callback: SessionVerifyCallback ) { try { const data = this.stateMap.get(handle); diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts index 661f2a821..30c7673c7 100644 --- a/apps/api/src/app/auth/oidc.strategy.ts +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -15,10 +15,10 @@ import { OidcStateStore } from './oidc-state.store'; @Injectable() export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { - private readonly logger = new Logger(OidcStrategy.name); - private static readonly stateStore = new OidcStateStore(); + private readonly logger = new Logger(OidcStrategy.name); + public constructor( private readonly authService: AuthService, options: StrategyOptions @@ -48,11 +48,6 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { params?.sub ?? context?.claims?.sub; - const jwt = await this.authService.validateOAuthLogin({ - thirdPartyId, - provider: Provider.OIDC - }); - if (!thirdPartyId) { this.logger.error( `Missing subject identifier in OIDC response from ${issuer}` @@ -61,6 +56,11 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { throw new Error('Missing subject identifier in OIDC response'); } + const jwt = await this.authService.validateOAuthLogin({ + thirdPartyId, + provider: Provider.OIDC + }); + return { jwt }; } catch (error) { this.logger.error(error); diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts index cb9dd8cb7..568822fb8 100644 --- a/apps/api/src/app/auth/web-auth.service.ts +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -99,6 +99,11 @@ export class WebAuthService { ): Promise { const user = this.request.user; const expectedChallenge = user.authChallenge; + + if (!expectedChallenge) { + throw new Error('Missing authentication challenge'); + } + let verification: VerifiedRegistrationResponse; try { @@ -213,7 +218,7 @@ export class WebAuthService { id: isoBase64URL.fromBuffer(device.credentialId), publicKey: device.credentialPublicKey }, - expectedChallenge: `${user.authChallenge}`, + expectedChallenge: `${user?.authChallenge}`, expectedOrigin: this.expectedOrigin, expectedRPID: this.rpID, requireUserVerification: false, @@ -243,7 +248,7 @@ export class WebAuthService { }); return this.jwtService.sign({ - id: user.id + id: user?.id }); } diff --git a/apps/api/src/app/cache/cache.controller.ts b/apps/api/src/app/cache/cache.controller.ts index 4d34a2eff..680e7e682 100644 --- a/apps/api/src/app/cache/cache.controller.ts +++ b/apps/api/src/app/cache/cache.controller.ts @@ -1,4 +1,5 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { permissions } from '@ghostfolio/common/permissions'; @@ -6,6 +7,7 @@ import { permissions } from '@ghostfolio/common/permissions'; import { Controller, Post, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +@AllowDuringImpersonation() @Controller('cache') export class CacheController { public constructor(private readonly redisCacheService: RedisCacheService) {} diff --git a/apps/api/src/app/endpoints/ai/ai.controller.ts b/apps/api/src/app/endpoints/ai/ai.controller.ts index 6c8102db1..1a3fd3ba2 100644 --- a/apps/api/src/app/endpoints/ai/ai.controller.ts +++ b/apps/api/src/app/endpoints/ai/ai.controller.ts @@ -46,7 +46,6 @@ export class AiController { const prompt = await this.aiService.getPrompt({ filters, mode, - impersonationId: undefined, languageCode: this.request.user.settings.settings.language, userCurrency: this.request.user.settings.settings.baseCurrency, userId: this.request.user.id diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index d0ef17844..70741374d 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -70,14 +70,12 @@ export class AiService { public async getPrompt({ filters, - impersonationId, languageCode, mode, userCurrency, userId }: { filters?: Filter[]; - impersonationId: string; languageCode: string; mode: AiPromptMode; userCurrency: string; @@ -85,7 +83,6 @@ export class AiService { }) { const { holdings } = await this.portfolioService.getDetails({ filters, - impersonationId, userId }); diff --git a/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts b/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts index cbc68df93..cde2ade8c 100644 --- a/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts +++ b/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; @@ -9,6 +10,7 @@ import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +@AllowDuringImpersonation() @Controller('api-keys') export class ApiKeysController { public constructor( diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts index 5ffb756a0..e1452c548 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; @@ -41,6 +42,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AssetProfilesService } from './asset-profiles.service'; +@AllowDuringImpersonation() @Controller('asset-profiles') export class AssetProfilesController { public constructor( @@ -167,7 +169,12 @@ export class AssetProfilesController { permissions.deleteAssetProfileSplitOfOwnAssetProfile }); - return this.assetProfilesService.deleteSplit({ id, symbolProfileId }); + return this.assetProfilesService.deleteSplit({ + dataSource, + id, + symbol, + symbolProfileId + }); } @HasPermission(permissions.accessAdminControl) diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts new file mode 100644 index 000000000..909c96e8b --- /dev/null +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts @@ -0,0 +1,179 @@ +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; +import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; + +import { NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { AssetProfileSplit, DataSource } from '@prisma/client'; + +import { AssetProfilesService } from './asset-profiles.service'; + +describe('AssetProfilesService', () => { + let assetProfilesService: AssetProfilesService; + let deleteById: jest.Mock; + let emit: jest.Mock; + let finished: jest.Mock; + let gatherSymbol: jest.Mock; + let getUserIdsBySymbolProfileId: jest.Mock; + let upsert: jest.Mock; + + beforeEach(() => { + deleteById = jest.fn(); + emit = jest.fn(); + finished = jest.fn().mockResolvedValue(undefined); + gatherSymbol = jest.fn().mockResolvedValue([{ finished }]); + getUserIdsBySymbolProfileId = jest.fn().mockResolvedValue([]); + upsert = jest.fn(); + + assetProfilesService = new AssetProfilesService( + { getUserIdsBySymbolProfileId } as unknown as ActivitiesService, + { + deleteById, + upsert + } as unknown as AssetProfileSplitService, + null, + { gatherSymbol } as unknown as DataGatheringService, + null, + { emit } as unknown as EventEmitter2, + null, + null, + null, + null + ); + }); + + describe('createSplit', () => { + it('upserts the split and refreshes the asset profile data', async () => { + const split = {} as AssetProfileSplit; + const data = { + dataSource: DataSource.YAHOO, + date: new Date('2024-06-15T18:30:00.000Z'), + denominator: 1, + numerator: 2, + symbol: 'AAPL', + symbolProfileId: 'profile-id' + }; + upsert.mockResolvedValue(split); + + const result = await assetProfilesService.createSplit(data); + + expect(upsert).toHaveBeenCalledWith({ + date: data.date, + denominator: data.denominator, + numerator: data.numerator, + symbolProfileId: data.symbolProfileId + }); + expect(gatherSymbol).toHaveBeenCalledWith({ + dataSource: data.dataSource, + symbol: data.symbol + }); + expect(result).toBe(split); + }); + + it('invalidates portfolio snapshots for users holding the asset', async () => { + upsert.mockResolvedValue({} as AssetProfileSplit); + getUserIdsBySymbolProfileId.mockResolvedValue(['user-1', 'user-2']); + + await assetProfilesService.createSplit({ + dataSource: DataSource.YAHOO, + date: new Date('2024-06-15T18:30:00.000Z'), + denominator: 1, + numerator: 2, + symbol: 'AAPL', + symbolProfileId: 'profile-id' + }); + await flushPendingPromises(); + + expect(getUserIdsBySymbolProfileId).toHaveBeenCalledWith('profile-id'); + expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([ + 'user-1', + 'user-2' + ]); + expect(emit.mock.calls[0][0]).toBe(PortfolioChangedEvent.getName()); + }); + + it('emits the events only once the market data has been gathered', async () => { + let completeJob: () => void; + + finished.mockReturnValue( + new Promise((resolve) => { + completeJob = resolve; + }) + ); + upsert.mockResolvedValue({} as AssetProfileSplit); + getUserIdsBySymbolProfileId.mockResolvedValue(['user-1']); + + await assetProfilesService.createSplit({ + dataSource: DataSource.YAHOO, + date: new Date('2024-06-15T18:30:00.000Z'), + denominator: 1, + numerator: 2, + symbol: 'AAPL', + symbolProfileId: 'profile-id' + }); + await flushPendingPromises(); + + expect(emit).not.toHaveBeenCalled(); + + completeJob(); + await flushPendingPromises(); + + expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([ + 'user-1' + ]); + }); + }); + + describe('deleteSplit', () => { + it('throws NotFoundException when the scoped split does not exist', async () => { + deleteById.mockResolvedValue(false); + + await expect( + assetProfilesService.deleteSplit({ + dataSource: DataSource.YAHOO, + id: 'split-id', + symbol: 'AAPL', + symbolProfileId: 'profile-id' + }) + ).rejects.toBeInstanceOf(NotFoundException); + await flushPendingPromises(); + + expect(gatherSymbol).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }); + + it('deletes an existing split using its profile scope', async () => { + deleteById.mockResolvedValue(true); + getUserIdsBySymbolProfileId.mockResolvedValue(['user-1']); + + await expect( + assetProfilesService.deleteSplit({ + dataSource: DataSource.YAHOO, + id: 'split-id', + symbol: 'AAPL', + symbolProfileId: 'profile-id' + }) + ).resolves.toBeUndefined(); + await flushPendingPromises(); + + expect(deleteById).toHaveBeenCalledWith({ + id: 'split-id', + symbolProfileId: 'profile-id' + }); + expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([ + 'user-1' + ]); + expect(gatherSymbol).toHaveBeenCalledWith({ + dataSource: DataSource.YAHOO, + symbol: 'AAPL' + }); + }); + }); +}); + +function flushPendingPromises() { + return new Promise((resolve) => { + setImmediate(resolve); + }); +} diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts index 0d50e2223..e34feb649 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts @@ -1,4 +1,5 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; @@ -25,6 +26,7 @@ import { import { MarketDataPreset } from '@ghostfolio/common/types'; import { Injectable, NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; import { groupBy } from 'lodash'; @@ -36,6 +38,7 @@ export class AssetProfilesService { private readonly benchmarkService: BenchmarkService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, + private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, @@ -62,18 +65,24 @@ export class AssetProfilesService { symbolProfileId }); - await this.dataGatheringService.gatherSymbol({ dataSource, symbol }); + await this.gatherSymbolAndEmitPortfolioChangedEvents({ + dataSource, + symbol, + symbolProfileId + }); return assetProfileSplit; } public async deleteSplit({ + dataSource, id, + symbol, symbolProfileId }: { id: string; symbolProfileId: string; - }) { + } & AssetProfileIdentifier) { const isDeleted = await this.assetProfileSplitService.deleteById({ id, symbolProfileId @@ -82,6 +91,12 @@ export class AssetProfilesService { if (!isDeleted) { throw new NotFoundException(); } + + await this.gatherSymbolAndEmitPortfolioChangedEvents({ + dataSource, + symbol, + symbolProfileId + }); } public async getAssetProfile({ @@ -425,6 +440,43 @@ export class AssetProfilesService { return assetProfile; } + private async emitPortfolioChangedEvents(symbolProfileId: string) { + const userIds = + await this.activitiesService.getUserIdsBySymbolProfileId(symbolProfileId); + + for (const userId of userIds) { + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ userId }) + ); + } + } + + /** + * Gathers the market data of the given asset profile and invalidates the + * portfolio snapshots of the affected users as soon as it is available. + * Emitting the events earlier would recompute the snapshots from + * split-adjusted quantities and not yet split-adjusted market prices. + */ + private async gatherSymbolAndEmitPortfolioChangedEvents({ + dataSource, + symbol, + symbolProfileId + }: { symbolProfileId: string } & AssetProfileIdentifier) { + const jobs = await this.dataGatheringService.gatherSymbol({ + dataSource, + symbol + }); + + void Promise.allSettled( + jobs.map((job) => { + return job.finished(); + }) + ).then(() => { + return this.emitPortfolioChangedEvents(symbolProfileId); + }); + } + private getAssetProfileDataUpdate({ countries, holdings, diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts index 53df9bd92..0930eb5b1 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts @@ -1,34 +1,34 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; -import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import type { AssetProfileIdentifier, BenchmarkMarketDataDetailsResponse, BenchmarkResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import { scopes } from '@ghostfolio/common/scopes'; +import type { ImpersonationContext } from '@ghostfolio/common/types'; import { Body, Controller, Delete, Get, - Headers, HttpException, - Inject, Param, Post, Query, UseGuards, UseInterceptors } from '@nestjs/common'; -import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { DataSource } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -36,13 +36,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { BenchmarksService } from './benchmarks.service'; import { GetBenchmarkMarketDataDto } from './get-benchmark-market-data.dto'; +@AllowDuringImpersonation() @Controller('benchmarks') export class BenchmarksController { public constructor( private readonly apiService: ApiService, private readonly benchmarkService: BenchmarkService, - private readonly benchmarksService: BenchmarksService, - @Inject(REQUEST) private readonly request: RequestWithUser + private readonly benchmarksService: BenchmarksService ) {} @HasPermission(permissions.accessAdminControl) @@ -112,10 +112,10 @@ export class BenchmarksController { } @Get(':dataSource/:symbol/:startDateString') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getBenchmarkMarketDataForUser( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId, userSettings }: ImpersonationContext, @Param('dataSource') dataSource: DataSource, @Param('startDateString') startDateString: string, @Param('symbol') symbol: string, @@ -147,12 +147,12 @@ export class BenchmarksController { dataSource, endDate, filters, - impersonationId, startDate, symbol, + userId, + userSettings, withExcludedAccounts, - dateRange: range, - user: this.request.user + dateRange: range }); } } diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts index 1fe42ab0d..4b6a3f1c9 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts @@ -7,9 +7,10 @@ import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, BenchmarkMarketDataDetailsResponse, - Filter + Filter, + UserSettings } from '@ghostfolio/common/interfaces'; -import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; +import { DateRange } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { format, isSameDay } from 'date-fns'; @@ -32,28 +33,26 @@ export class BenchmarksService { dateRange, endDate = new Date(), filters, - impersonationId, startDate, symbol, - user, + userId, + userSettings, withExcludedAccounts }: { dateRange: DateRange; endDate?: Date; filters?: Filter[]; - impersonationId: string; startDate: Date; - user: UserWithSettings; + userId: string; + userSettings: UserSettings; withExcludedAccounts?: boolean; } & AssetProfileIdentifier): Promise { const marketData: { date: string; value: number }[] = []; - const userCurrency = user.settings.settings.baseCurrency; - const userId = user.id; + const userCurrency = userSettings.baseCurrency; const { chart } = await this.portfolioService.getPerformance({ dateRange, filters, - impersonationId, userId, withExcludedAccounts }); diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts index b858688c2..addc5d879 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts @@ -115,7 +115,7 @@ export class GhostfolioService { return result; } catch (error) { - this.logger.error(error); + this.logger.error(error.message); throw error; } @@ -157,7 +157,7 @@ export class GhostfolioService { return result; } catch (error) { - this.logger.error(error); + this.logger.error(error.message); throw error; } @@ -199,7 +199,7 @@ export class GhostfolioService { return result; } catch (error) { - this.logger.error(error); + this.logger.error(error.message); throw error; } @@ -226,7 +226,7 @@ export class GhostfolioService { return marketDataOfMarkets; } catch (error) { - this.logger.error(error); + this.logger.error(error.message); throw error; } @@ -314,7 +314,7 @@ export class GhostfolioService { return results; } catch (error) { - this.logger.error(error); + this.logger.error(error.message); throw error; } @@ -393,7 +393,7 @@ export class GhostfolioService { return results; } catch (error) { - this.logger.error(error); + this.logger.error(error.message); throw error; } diff --git a/apps/api/src/app/endpoints/market-data/market-data.controller.ts b/apps/api/src/app/endpoints/market-data/market-data.controller.ts index 03d50c284..6f7583acf 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.controller.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.controller.ts @@ -1,4 +1,5 @@ import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; @@ -27,6 +28,7 @@ import { DataSource, Prisma } from '@prisma/client'; import { parseISO } from 'date-fns'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +@AllowDuringImpersonation() @Controller('market-data') export class MarketDataController { public constructor( diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index 67bed71ef..6093e5f87 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -78,7 +78,6 @@ export class PublicController { ] = await Promise.all([ this.portfolioService.getDetails({ filters, - impersonationId: access.userId, userId: user.id, withMarkets: true }), @@ -86,7 +85,6 @@ export class PublicController { return this.portfolioService.getPerformance({ dateRange, filters, - impersonationId: undefined, userId: user.id }); }) diff --git a/apps/api/src/app/endpoints/tags/tags.controller.ts b/apps/api/src/app/endpoints/tags/tags.controller.ts index cd043b593..a61e1188c 100644 --- a/apps/api/src/app/endpoints/tags/tags.controller.ts +++ b/apps/api/src/app/endpoints/tags/tags.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; @@ -23,6 +24,7 @@ import { AuthGuard } from '@nestjs/passport'; import { Tag } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +@AllowDuringImpersonation() @Controller('tags') export class TagsController { public constructor( diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts index 78693239a..11f3ff84e 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts @@ -1,20 +1,23 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; -import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { CreateWatchlistItemDto } from '@ghostfolio/common/dtos'; import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import { RequestWithUser } from '@ghostfolio/common/types'; +import { scopes } from '@ghostfolio/common/scopes'; +import { + ImpersonationContext, + RequestWithUser +} from '@ghostfolio/common/types'; import { Body, Controller, Delete, Get, - Headers, HttpException, Inject, Param, @@ -32,7 +35,6 @@ import { WatchlistService } from './watchlist.service'; @Controller('watchlist') export class WatchlistController { public constructor( - private readonly impersonationService: ImpersonationService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly watchlistService: WatchlistService ) {} @@ -81,17 +83,12 @@ export class WatchlistController { @Get() @HasPermission(permissions.readWatchlist) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.watchlistRead) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getWatchlistItems( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string + @Impersonation() { userId }: ImpersonationContext ): Promise { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - - const watchlist = await this.watchlistService.getWatchlistItems( - impersonationUserId || this.request.user.id - ); + const watchlist = await this.watchlistService.getWatchlistItems(userId); return { watchlist diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 9d1e899c3..7040ca104 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { getTagsWithDraftTag } from '@ghostfolio/api/helper/activity.helper'; import { ApiService } from '@ghostfolio/api/services/api/api.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'; @@ -13,9 +14,14 @@ import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, ghostfolioPrefix, NON_INVESTMENT_ACTIVITY_TYPES, + TAG_ID_DRAFT, TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; -import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos'; +import { + CreateAccountWithBalancesDto, + CreateAssetProfileDto, + CreateOrderDto +} from '@ghostfolio/common/dtos'; import { getAssetProfileIdentifier, isValidCustomAssetProfileSymbol, @@ -34,9 +40,10 @@ import { } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; -import { DataSource, Prisma } from '@prisma/client'; +import { Account, DataSource, Prisma } from '@prisma/client'; import { Big } from 'big.js'; -import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns'; +import { isISIN } from 'class-validator'; +import { isSameSecond, parseISO } from 'date-fns'; import { omit, uniqBy } from 'lodash'; import { randomUUID } from 'node:crypto'; @@ -96,7 +103,9 @@ export class ImportService { filters, userCurrency, userId, - startDate: parseDate(dateOfFirstActivity) + includeDrafts: true, + startDate: parseDate(dateOfFirstActivity), + withExcludedAccountsAndActivities: true }), this.symbolProfileService.getSymbolProfiles([ { @@ -117,6 +126,8 @@ export class ImportService { return await Promise.all( Object.entries(dividends).map(([dateString, { marketPrice }]) => { + const date = parseDate(dateString); + const quantity = historicalData.find((historicalDataItem) => { return historicalDataItem.date === dateString; @@ -124,10 +135,8 @@ export class ImportService { const value = new Big(quantity).mul(marketPrice).toNumber(); - const date = parseDate(dateString); const isDuplicate = activities.some((activity) => { return ( - activity.accountId === account?.id && activity.assetProfile.currency === assetProfile.currency && activity.assetProfile.dataSource === assetProfile.dataSource && isSameSecond(activity.date, date) && @@ -158,7 +167,6 @@ export class ImportService { feeInAssetProfileCurrency: 0, feeInBaseCurrency: 0, id: assetProfile.id, - isDraft: false, symbolProfileId: assetProfile.id, type: 'DIVIDEND', unitPrice: marketPrice, @@ -234,6 +242,59 @@ export class ImportService { } } + // Resolve the symbols with an ISIN to the symbol of the data provider + // before the asset profiles are created and the duplicates are detected + const assetProfileIdentifiersWithIsin: AssetProfileIdentifier[] = uniqBy( + [...(assetProfilesWithMarketDataDto ?? []), ...activitiesDto] + .filter(({ dataSource, symbol }) => { + return dataSource !== DataSource.MANUAL && isISIN(symbol); + }) + .map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }), + getAssetProfileIdentifier + ); + + const resolvedAssetProfileIdentifiers = await Promise.all( + assetProfileIdentifiersWithIsin.map(async ({ dataSource, symbol }) => { + try { + const assetProfile = await this.dataProviderService + .getDataProvider(dataSource) + .getAssetProfile({ symbol }); + + return { dataSource, symbol, resolvedSymbol: assetProfile?.symbol }; + } catch { + return { dataSource, symbol, resolvedSymbol: undefined }; + } + }) + ); + + for (const { + dataSource, + resolvedSymbol, + symbol + } of resolvedAssetProfileIdentifiers) { + if (!resolvedSymbol || resolvedSymbol === symbol) { + continue; + } + + for (const activity of activitiesDto) { + if (activity.dataSource === dataSource && activity.symbol === symbol) { + activity.symbol = resolvedSymbol; + } + } + + for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto ?? + []) { + if ( + assetProfileWithMarketData.dataSource === dataSource && + assetProfileWithMarketData.symbol === symbol + ) { + assetProfileWithMarketData.symbol = resolvedSymbol; + } + } + } + if (platformsDto?.length) { const canCreatePlatform = hasPermission( user.permissions, @@ -326,17 +387,29 @@ export class ImportService { } } - if (!isDryRun && accountsWithBalancesDto?.length) { - const [existingAccounts, existingPlatforms] = await Promise.all([ + if (accountsWithBalancesDto?.length) { + const [ + existingAccountsOfOtherUsers, + existingAccountsOfUser, + existingPlatforms + ] = await Promise.all([ this.accountService.accounts({ where: { id: { - in: accountsWithBalancesDto.map(({ id }) => { - return id; - }) - } + in: accountsWithBalancesDto + .filter(({ id }) => { + return Boolean(id); + }) + .map(({ id }) => { + return id; + }) + }, + userId: { not: user.id } } }), + this.accountService.accounts({ + where: { userId: user.id } + }), this.platformService.getPlatforms() ]); @@ -347,79 +420,116 @@ export class ImportService { ); for (const accountWithBalances of accountsWithBalancesDto) { - // Check if there is any existing account with the same ID - const accountWithSameId = existingAccounts.find((existingAccount) => { - return existingAccount.id === accountWithBalances.id; + // Skip the account if it already belongs to the user + if ( + existingAccountsOfUser.some(({ id }) => { + return id === accountWithBalances.id; + }) + ) { + continue; + } + + // If there is no account or if the account belongs to a different + // user, then reuse an existing account of the user with the same name + // and currency or create a new account + const accountToReuse = this.getAccountToReuse({ + accountWithBalances, + accountsWithBalancesDto, + existingAccountsOfUser }); - // If there is no account or if the account belongs to a different user then create a new account - if (!accountWithSameId || accountWithSameId.userId !== user.id) { - const account = omit(accountWithBalances, [ - 'balance', - 'balances', - 'isExcluded', - 'tags' - ]); + if (accountToReuse) { + // Reuse the account of the user instead of creating a duplicate. The + // balances, the platform and the tags of the import are deliberately + // not applied to leave the existing account of the user untouched. + if ( + accountWithBalances.id && + accountWithBalances.id !== accountToReuse.id + ) { + // Store the new to old account ID mappings for updating activities + accountIdMapping[accountWithBalances.id] = accountToReuse.id; + } - let oldAccountId: string; - const platformId = - platformIdMapping[account.platformId] ?? account.platformId; + continue; + } - delete account.platformId; + if (isDryRun) { + continue; + } - if (accountWithSameId) { - oldAccountId = account.id; - delete account.id; + // Check if there is any existing account of a different user with the + // same ID, since the ID cannot be reused in this case + const accountWithSameIdOfOtherUser = existingAccountsOfOtherUsers.find( + ({ id }) => { + return id === accountWithBalances.id; } + ); - const tagIds = (accountWithBalances.tags ?? []) - .map((tagId) => { - return tagIdMapping[tagId] ?? tagId; - }) - .filter((tagId) => { - return existingTagIds.has(tagId); - }); + const account = omit(accountWithBalances, [ + 'balance', + 'balances', + 'isExcluded', + 'tags' + ]); - // Map the legacy isExcluded attribute of old export files to - // the "Exclude from Analysis" tag - if ( - accountWithBalances.isExcluded && - existingTagIds.has(TAG_ID_EXCLUDE_FROM_ANALYSIS) && - !tagIds.includes(TAG_ID_EXCLUDE_FROM_ANALYSIS) - ) { - tagIds.push(TAG_ID_EXCLUDE_FROM_ANALYSIS); - } + let oldAccountId: string; + const platformId = + platformIdMapping[account.platformId] ?? account.platformId; - let accountObject: Prisma.AccountCreateInput = { - ...account, - balances: { - create: accountWithBalances.balances ?? [] - }, - user: { connect: { id: user.id } } - }; + delete account.platformId; - if ( - existingPlatforms.some(({ id }) => { - return id === platformId; - }) - ) { - accountObject = { - ...accountObject, - platform: { connect: { id: platformId } } - }; - } + if (accountWithSameIdOfOtherUser) { + oldAccountId = account.id; + delete account.id; + } - const newAccount = await this.accountService.createAccount({ - tagIds, - balance: accountWithBalances.balance, - data: accountObject, - userId: user.id + const tagIds = (accountWithBalances.tags ?? []) + .map((tagId) => { + return tagIdMapping[tagId] ?? tagId; + }) + .filter((tagId) => { + return existingTagIds.has(tagId); }); - // Store the new to old account ID mappings for updating activities - if (accountWithSameId && oldAccountId) { - accountIdMapping[oldAccountId] = newAccount.id; - } + // Map the legacy isExcluded attribute of old export files to + // the "Exclude from Analysis" tag + if ( + accountWithBalances.isExcluded && + existingTagIds.has(TAG_ID_EXCLUDE_FROM_ANALYSIS) && + !tagIds.includes(TAG_ID_EXCLUDE_FROM_ANALYSIS) + ) { + tagIds.push(TAG_ID_EXCLUDE_FROM_ANALYSIS); + } + + let accountObject: Prisma.AccountCreateInput = { + ...account, + balances: { + create: accountWithBalances.balances ?? [] + }, + user: { connect: { id: user.id } } + }; + + if ( + existingPlatforms.some(({ id }) => { + return id === platformId; + }) + ) { + accountObject = { + ...accountObject, + platform: { connect: { id: platformId } } + }; + } + + const newAccount = await this.accountService.createAccount({ + tagIds, + balance: accountWithBalances.balance, + data: accountObject, + userId: user.id + }); + + // Store the new to old account ID mappings for updating activities + if (accountWithSameIdOfOtherUser && oldAccountId) { + accountIdMapping[oldAccountId] = newAccount.id; } } } @@ -537,12 +647,12 @@ export class ImportService { activity.symbol = assetProfileSymbolMapping[activity.symbol]; } - if (!isDryRun) { - // If a new account is created, then update the accountId in all activities - if (accountIdMapping[activity.accountId]) { - activity.accountId = accountIdMapping[activity.accountId]; - } + // If an account is created or reused, then update the accountId in all activities + if (accountIdMapping[activity.accountId]) { + activity.accountId = accountIdMapping[activity.accountId]; + } + if (!isDryRun) { // If a new tag is created, then update the tag ID in all activities activity.tags = (activity.tags ?? []).map((tagId) => { return tagIdMapping[tagId] ?? tagId; @@ -570,9 +680,20 @@ export class ImportService { ); if (isDryRun) { - accountsWithBalancesDto.forEach(({ id, name }) => { - accounts.push({ id, name }); - }); + accountsWithBalancesDto + .filter(({ id }) => { + // Skip the accounts which are reused or which already belong to the + // user, since they are part of the accounts of the user above + return ( + !accountIdMapping[id] && + !accounts.some(({ id: accountId }) => { + return accountId === id; + }) + ); + }) + .forEach(({ id, name }) => { + accounts.push({ id, name }); + }); } const tags = (await this.tagService.getTagsForUser(user.id)).map( @@ -593,6 +714,11 @@ export class ImportService { }); } + // Preview the "Draft" tag which createActivity() assigns in a real run + const draftTag = tags.find(({ id }) => { + return id === TAG_ID_DRAFT; + }) ?? { id: TAG_ID_DRAFT, name: 'DRAFT' }; + const activities: Activity[] = []; for (const activity of activitiesExtendedWithErrors) { @@ -655,6 +781,13 @@ export class ImportService { }); if (isDryRun) { + const previewTags = getTagsWithDraftTag({ + date, + draftTag, + type, + tags: validatedTags + }); + order = { comment, currency, @@ -668,7 +801,6 @@ export class ImportService { accountUserId: undefined, createdAt: new Date(), id: randomUUID(), - isDraft: isAfter(date, endOfToday()), SymbolProfile: { assetClass, assetSubClass, @@ -697,7 +829,7 @@ export class ImportService { userId: dataSource === 'MANUAL' ? user.id : undefined }, symbolProfileId: undefined, - tags: validatedTags, + tags: previewTags, updatedAt: new Date(), userId: user.id }; @@ -826,10 +958,10 @@ export class ImportService { unitPrice }) => { const date = parseISO(dateString); + const isDuplicate = existingActivities.some((activity) => { return ( - activity.accountId === accountId && - activity.comment === comment && + (activity.comment || null) === (comment || null) && (activity.currency === currency || activity.assetProfile.currency === currency) && activity.assetProfile.dataSource === dataSource && @@ -877,6 +1009,52 @@ export class ImportService { ); } + /** + * Returns the account of the user to reuse for the given account of the + * import, based on the name and the currency. The currency is considered + * because the activities of the import would otherwise end up in an account + * of a different currency. The name is only considered if it is unambiguous, + * both in the accounts of the user and in the accounts of the import, since + * it is not unique. Otherwise, distinct accounts would be merged into a + * single one. + */ + private getAccountToReuse({ + accountWithBalances, + accountsWithBalancesDto, + existingAccountsOfUser + }: { + accountWithBalances: CreateAccountWithBalancesDto; + accountsWithBalancesDto: ImportDataDto['accounts']; + existingAccountsOfUser: Account[]; + }): Account { + const matchingAccountsOfUser = existingAccountsOfUser.filter( + ({ currency, name }) => { + return ( + currency === accountWithBalances.currency && + name === accountWithBalances.name + ); + } + ); + + const matchingAccountsToImport = accountsWithBalancesDto.filter( + ({ currency, name }) => { + return ( + currency === accountWithBalances.currency && + name === accountWithBalances.name + ); + } + ); + + if ( + matchingAccountsOfUser.length !== 1 || + matchingAccountsToImport.length !== 1 + ) { + return undefined; + } + + return matchingAccountsOfUser[0]; + } + private isUniqueAccount(accounts: AccountWithValue[]) { const uniqueAccountIds = new Set(); diff --git a/apps/api/src/app/platform/platform.controller.ts b/apps/api/src/app/platform/platform.controller.ts index ebf03e3a9..6c0af4515 100644 --- a/apps/api/src/app/platform/platform.controller.ts +++ b/apps/api/src/app/platform/platform.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; @@ -20,6 +21,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { PlatformService } from './platform.service'; +@AllowDuringImpersonation() @Controller('platform') export class PlatformController { public constructor(private readonly platformService: PlatformService) {} diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts index 5e6bfba99..2dee4cbc8 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts @@ -12,7 +12,6 @@ export const activityDummyData = { feeInAssetProfileCurrency: undefined, feeInBaseCurrency: undefined, id: undefined, - isDraft: false, symbolProfileId: undefined, unitPrice: undefined, unitPriceInAssetProfileCurrency: undefined, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index 3b09bfd26..e50b132d2 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -11,6 +11,7 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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'; @@ -124,6 +125,9 @@ describe('PortfolioCalculator', () => { activitiesService = new ActivitiesService( accountBalanceService, accountService, + { + getSplitsByUserId: jest.fn().mockResolvedValue([]) + } as unknown as AssetProfileSplitService, null, null, dataProviderService, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts new file mode 100644 index 000000000..c71a3bda1 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts @@ -0,0 +1,276 @@ +import { + activityDummyData, + assetProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { adjustActivityBySplits } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.helper'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { AssetProfileSplit, DataSource } from '@prisma/client'; +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('RoaiPortfolioCalculator stock splits', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + currentRateService = new CurrentRateService(null, null, null, null); + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + redisCacheService = new RedisCacheService(null, null); + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + it('doubles the position and halves the average price for a 2:1 split', () => { + const activity = adjustActivityBySplits( + createActivity({ unitPrice: 100 }), + [createSplit({ denominator: 1, numerator: 2 })] + ); + + const position = getLastPosition(portfolioCalculatorFactory, [activity]); + + expect(position).toMatchObject({ + averagePrice: new Big(50), + investment: new Big(1000), + quantity: new Big(20) + }); + }); + + it('applies the inverse quantity and price changes for a reverse split', () => { + const activity = adjustActivityBySplits( + createActivity({ unitPrice: 100 }), + [createSplit({ denominator: 10, numerator: 1 })] + ); + + const position = getLastPosition(portfolioCalculatorFactory, [activity]); + + expect(position).toMatchObject({ + averagePrice: new Big(1000), + investment: new Big(1000), + quantity: new Big(1) + }); + }); + + it('uses adjusted quantities when selling after a split', () => { + const buy = adjustActivityBySplits( + createActivity({ date: '2020-01-01', unitPrice: 100 }), + [createSplit({ denominator: 1, numerator: 2 })] + ); + const sell = createActivity({ + date: '2021-01-01', + quantity: 5, + type: 'SELL', + unitPrice: 60 + }); + + const position = getLastPosition(portfolioCalculatorFactory, [buy, sell]); + + expect(position).toMatchObject({ + averagePrice: new Big(50), + investment: new Big(750), + quantity: new Big(15) + }); + }); + + it('applies multiple splits while preserving fractional precision', () => { + const activity = adjustActivityBySplits( + createActivity({ unitPrice: 100 }), + [ + createSplit({ denominator: 1, numerator: 2 }), + createSplit({ denominator: 3, numerator: 1, date: '2022-01-01' }) + ] + ); + + const position = getLastPosition(portfolioCalculatorFactory, [activity]); + + expect(position.averagePrice).toEqual(new Big(150)); + expect(position.quantity.toFixed(15)).toBe(new Big(20).div(3).toFixed(15)); + expect(position.investment.toNumber()).toBeCloseTo(1000, 12); + }); + + it('resets quantity and investment when the adjusted position is closed', () => { + const buy = adjustActivityBySplits( + createActivity({ date: '2020-01-01', unitPrice: 100 }), + [createSplit({ denominator: 1, numerator: 2 })] + ); + const sell = createActivity({ + date: '2021-01-01', + quantity: 20, + type: 'SELL', + unitPrice: 60 + }); + + const position = getLastPosition(portfolioCalculatorFactory, [buy, sell]); + + expect(position.quantity).toEqual(new Big(0)); + expect(position.investment).toEqual(new Big(0)); + }); + + it('preserves existing behavior when no splits exist', () => { + const position = getLastPosition(portfolioCalculatorFactory, [ + createActivity({ unitPrice: 100 }) + ]); + + expect(position).toMatchObject({ + averagePrice: new Big(100), + investment: new Big(1000), + quantity: new Big(10) + }); + }); + + it('uses provider market data without adjusting it a second time', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activity = adjustActivityBySplits( + createActivity({ date: '2023-07-09', unitPrice: 674.44 }), + [ + createSplit({ + date: '2023-07-10', + denominator: 1, + numerator: 2 + }) + ] + ); + const calculator = portfolioCalculatorFactory.createCalculator({ + activities: [activity], + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const snapshot = await calculator.computeSnapshot(); + const [position] = snapshot.positions; + + expect(position).toMatchObject({ + investment: new Big(6744.4), + marketPrice: 331.83, + quantity: new Big(20), + valueInBaseCurrency: new Big(6636.6) + }); + }); +}); + +function getLastPosition( + portfolioCalculatorFactory: PortfolioCalculatorFactory, + activities: Activity[] +) { + const calculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + return calculator.getTransactionPoints().at(-1).items[0]; +} + +function createActivity({ + date = '2020-01-01', + quantity = 10, + type = 'BUY', + unitPrice = 100 +}: { + date?: string; + quantity?: number; + type?: Activity['type']; + unitPrice?: number; +}): Activity { + return { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: DataSource.YAHOO, + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + date: parseDate(date), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity, + type, + unitPrice, + unitPriceInAssetProfileCurrency: unitPrice, + value: quantity * unitPrice, + valueInBaseCurrency: quantity * unitPrice + } as Activity; +} + +function createSplit({ + date = '2021-01-01', + denominator, + numerator +}: { + date?: string; + denominator: number; + numerator: number; +}): AssetProfileSplit { + const splitDate = parseDate(date); + + return { + denominator, + numerator, + createdAt: splitDate, + date: splitDate, + id: `${date}-${numerator}-${denominator}`, + symbolProfileId: 'msft-profile', + updatedAt: splitDate + }; +} diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index 9a87af153..69ab70aa8 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -187,15 +187,15 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { let totalInvestmentWithCurrencyEffect = new Big(0); let totalLiabilities = new Big(0); let totalLiabilitiesInBaseCurrency = new Big(0); + let totalQuantity = new Big(0); let totalQuantityFromBuyTransactions = new Big(0); - let totalUnits = new Big(0); let valueAtStartDate: Big; let valueAtStartDateWithCurrencyEffect: Big; - // Clone orders to keep the original values in this.orders + // Deep clone as the items are enriched below and the originals are shared let orders: PortfolioOrderItem[] = cloneDeep( - this.activities.filter(({ assetProfile }) => { - return assetProfile.symbol === symbol; + this.activities.filter((activities) => { + return activities.assetProfile.symbol === symbol; }) ); @@ -295,10 +295,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { !unitPriceAtEndDate || (!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) ) { - // A missing market price can only affect the units which are held. The - // dividends, the interest and the liabilities do not hold any units and - // are therefore not in error. - const hasActivitiesWithUnits = orders.some(({ type }) => { + // A missing market price can only affect the quantity which is held. The + // dividends, the interest and the liabilities do not hold any quantity + // and are therefore not in error. + const hasActivitiesWithQuantity = orders.some(({ type }) => { return ['BUY', 'SELL'].includes(type); }); @@ -316,7 +316,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0), grossPerformanceWithCurrencyEffect: new Big(0), - hasErrors: hasActivitiesWithUnits, + hasErrors: hasActivitiesWithQuantity, initialValue: new Big(0), initialValueWithCurrencyEffect: new Big(0), investmentValuesAccumulated: {}, @@ -489,12 +489,12 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { order.unitPriceFromMarketData?.mul(exchangeRateAtOrderDate ?? 1) ?? new Big(0); - const valueOfInvestmentBeforeTransaction = totalUnits.mul( + const valueOfInvestmentBeforeTransaction = totalQuantity.mul( marketPriceInBaseCurrency ); const valueOfInvestmentBeforeTransactionWithCurrencyEffect = - totalUnits.mul(marketPriceInBaseCurrencyWithCurrencyEffect); + totalQuantity.mul(marketPriceInBaseCurrencyWithCurrencyEffect); if (!investmentAtStartDate && i >= indexOfStartOrder) { investmentAtStartDate = totalInvestment ?? new Big(0); @@ -531,16 +531,19 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { transactionInvestmentWithCurrencyEffect ); } else if (order.type === 'SELL') { - if (totalUnits.gt(0)) { + if (totalQuantity.gt(0)) { + const remainingQuantity = totalQuantity.minus(order.quantity); + transactionInvestment = totalInvestment - .div(totalUnits) - .mul(order.quantity) - .mul(getFactor(order.type)); + .mul(remainingQuantity) + .div(totalQuantity) + .minus(totalInvestment); + transactionInvestmentWithCurrencyEffect = totalInvestmentWithCurrencyEffect - .div(totalUnits) - .mul(order.quantity) - .mul(getFactor(order.type)); + .mul(remainingQuantity) + .div(totalQuantity) + .minus(totalInvestmentWithCurrencyEffect); } } @@ -589,11 +592,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { order.feeInBaseCurrencyWithCurrencyEffect ?? 0 ); - totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type))); + totalQuantity = totalQuantity.plus( + order.quantity.mul(getFactor(order.type)) + ); - const valueOfInvestment = totalUnits.mul(marketPriceInBaseCurrency); + const valueOfInvestment = totalQuantity.mul(marketPriceInBaseCurrency); - const valueOfInvestmentWithCurrencyEffect = totalUnits.mul( + const valueOfInvestmentWithCurrencyEffect = totalQuantity.mul( marketPriceInBaseCurrencyWithCurrencyEffect ); @@ -634,7 +639,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { totalQuantityFromBuyTransactions ); - if (totalUnits.eq(0)) { + if (totalQuantity.eq(0)) { // Reset tracking variables when position is fully closed totalInvestmentFromBuyTransactions = new Big(0); totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); @@ -827,14 +832,14 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { ) : new Big(0); - const feesPerUnit = totalUnits.gt(0) - ? fees.minus(feesAtStartDate).div(totalUnits) + const feesPerUnit = totalQuantity.gt(0) + ? fees.minus(feesAtStartDate).div(totalQuantity) : new Big(0); - const feesPerUnitWithCurrencyEffect = totalUnits.gt(0) + const feesPerUnitWithCurrencyEffect = totalQuantity.gt(0) ? feesWithCurrencyEffect .minus(feesAtStartDateWithCurrencyEffect) - .div(totalUnits) + .div(totalQuantity) : new Big(0); const netPerformancePercentage = @@ -1006,7 +1011,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { grossPerformance: totalGrossPerformance, grossPerformanceWithCurrencyEffect: totalGrossPerformanceWithCurrencyEffect, - hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), + hasErrors: totalQuantity.gt(0) && (!initialValue || !unitPriceAtEndDate), netPerformance: totalNetPerformance, timeWeightedInvestment: timeWeightedAverageInvestmentBetweenStartAndEndDate, diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 953976a4a..b2dd8b69a 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,6 +1,7 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; -import { UserService } from '@ghostfolio/api/app/user/user.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { hasNotDefinedValuesInObject, @@ -12,12 +13,8 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; -import { - HEADER_KEY_IMPERSONATION, - UNKNOWN_KEY -} from '@ghostfolio/common/config'; +import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { SubscriptionType } from '@ghostfolio/common/enums'; import { PortfolioDetails, @@ -28,18 +25,17 @@ import { PortfolioPerformanceResponse, PortfolioReportResponse } from '@ghostfolio/common/interfaces'; -import { - hasReadRestrictedAccessPermission, - isRestrictedView, - permissions -} from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import { isRestrictedView, permissions } from '@ghostfolio/common/permissions'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; +import type { + ImpersonationContext, + RequestWithUser +} from '@ghostfolio/common/types'; import { Body, Controller, Get, - Headers, HttpException, Inject, Param, @@ -69,19 +65,18 @@ export class PortfolioController { private readonly activitiesService: ActivitiesService, private readonly apiService: ApiService, private readonly configurationService: ConfigurationService, - private readonly impersonationService: ImpersonationService, private readonly portfolioService: PortfolioService, - @Inject(REQUEST) private readonly request: RequestWithUser, - private readonly userService: UserService + @Inject(REQUEST) private readonly request: RequestWithUser ) {} @Get('details') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getDetails( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext, @Query() { accounts: filterByAccounts, @@ -120,10 +115,9 @@ export class PortfolioController { summary } = await this.portfolioService.getDetails({ filters, - impersonationId, + userId, withMarkets, dateRange: range, - userId: this.request.user.id, withSummary: true }); @@ -134,10 +128,7 @@ export class PortfolioController { let portfolioSummary = summary; if ( - hasReadRestrictedAccessPermission({ - impersonationId, - accesses: this.request.user?.accessesGet - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) ) { const totalInvestment = Object.values(holdings) @@ -178,10 +169,7 @@ export class PortfolioController { if ( hasDetails === false || - hasReadRestrictedAccessPermission({ - impersonationId, - accesses: this.request.user?.accessesGet - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) ) { Object.values(markets ?? {}).forEach((market) => { @@ -323,10 +311,11 @@ export class PortfolioController { } @Get('dividends') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getDividends( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() + { scopes: impersonationScopes, userId, userSettings }: ImpersonationContext, @Query() { accounts, @@ -346,12 +335,7 @@ export class PortfolioController { filterByTags: tags }); - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - const userId = impersonationUserId || this.request.user.id; - - const { settings } = await this.userService.user({ id: userId }); - const userCurrency = settings.settings.baseCurrency; + const userCurrency = userSettings.baseCurrency; const { endDate, startDate } = getIntervalFromDateRange({ dateRange: range @@ -372,10 +356,7 @@ export class PortfolioController { }); if ( - hasReadRestrictedAccessPermission({ - impersonationId, - accesses: this.request.user?.accessesGet - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) ) { const maxDividend = dividends.reduce( @@ -405,17 +386,16 @@ export class PortfolioController { @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.portfolioRead) public async getHolding( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId }: ImpersonationContext, @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string ): Promise { const holding = await this.portfolioService.getHolding({ dataSource, - impersonationId, symbol, - userId: this.request.user.id + userId }); if (!holding) { @@ -429,12 +409,12 @@ export class PortfolioController { } @Get('holdings') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getHoldings( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId }: ImpersonationContext, @Query() { accounts, @@ -459,19 +439,19 @@ export class PortfolioController { const holdings = await this.portfolioService.getHoldings({ filters, - impersonationId, - dateRange: range, - userId: this.request.user.id + userId, + dateRange: range }); return { holdings }; } @Get('investments') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getInvestments( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext, @Query() { accounts, @@ -495,16 +475,12 @@ export class PortfolioController { await this.portfolioService.getInvestments({ filters, groupBy, - impersonationId, - dateRange: range, - userId: this.request.user.id + userId, + dateRange: range }); if ( - hasReadRestrictedAccessPermission({ - impersonationId, - accesses: this.request.user?.accessesGet - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) ) { const maxInvestment = investments.reduce( @@ -543,13 +519,14 @@ export class PortfolioController { } @Get('performance') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(PerformanceLoggingInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @Version('2') public async getPerformanceV2( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext, @Query() { accounts, @@ -571,17 +548,13 @@ export class PortfolioController { const performanceInformation = await this.portfolioService.getPerformance({ filters, - impersonationId, + userId, withExcludedAccounts, - dateRange: range, - userId: this.request.user.id + dateRange: range }); if ( - hasReadRestrictedAccessPermission({ - impersonationId, - accesses: this.request.user?.accessesGet - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) || this.request.user.settings.settings.viewMode === 'ZEN' ) { @@ -657,18 +630,18 @@ export class PortfolioController { } @Get('report') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @RequiresScope(scopes.portfolioRead) public async getReport( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext ): Promise { - const report = await this.portfolioService.getReport({ - impersonationId, - userId: this.request.user.id - }); + const report = await this.portfolioService.getReport({ userId }); if ( - this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription?.type === SubscriptionType.Basic + !hasScope(impersonationScopes, scopes.portfolioReadValues) || + isRestrictedView(this.request.user) || + (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription?.type === SubscriptionType.Basic) ) { for (const category of report.xRay.categories) { category.rules = null; diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index eed3a27cb..970b13734 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -8,7 +8,6 @@ import { UserService } from '@ghostfolio/api/app/user/user.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { parseDate } from '@ghostfolio/common/helper'; @@ -30,7 +29,6 @@ describe('PortfolioService', () => { let configurationService: ConfigurationService; let dataProviderService: DataProviderService; let exchangeRateDataService: ExchangeRateDataService; - let impersonationService: ImpersonationService; let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioService: PortfolioService; let symbolProfileService: SymbolProfileService; @@ -68,6 +66,7 @@ describe('PortfolioService', () => { accountService, null, null, + null, dataProviderService, null, exchangeRateDataService, @@ -77,8 +76,6 @@ describe('PortfolioService', () => { null ); - impersonationService = new ImpersonationService(null, null); - portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, null, @@ -110,7 +107,6 @@ describe('PortfolioService', () => { dataProviderService, exchangeRateDataService, null, - impersonationService, null, null, symbolProfileService, @@ -245,16 +241,11 @@ describe('PortfolioService', () => { .spyOn(dataProviderService, 'getDataSourceForExchangeRates') .mockReturnValue(DataSource.YAHOO); - jest - .spyOn(impersonationService, 'validateImpersonationId') - .mockResolvedValue(null); - jest .spyOn(symbolProfileService, 'getSymbolProfiles') .mockResolvedValue([]); jest.spyOn(userService, 'user').mockResolvedValue({ - accessesGet: [], accounts: [], activityCount: 0, dataProviderGhostfolioDailyRequests: 0, @@ -331,7 +322,6 @@ describe('PortfolioService', () => { const { holdings } = await portfolioService.getDetails({ filters: [], - impersonationId: userDummyData.id, userId: userDummyData.id }); @@ -371,10 +361,6 @@ describe('PortfolioService', () => { .spyOn(activitiesService, 'getActivities') .mockResolvedValue({ activities: [], count: 0 }); - jest - .spyOn(impersonationService, 'validateImpersonationId') - .mockResolvedValue(null); - jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({ performance: { currentValueInBaseCurrency: 3000, @@ -408,7 +394,6 @@ describe('PortfolioService', () => { balanceInBaseCurrency: 1000, emergencyFundHoldingsValueInBaseCurrency: 0, filteredValueInBaseCurrency: new Big(3000), - impersonationId: undefined, userCurrency: 'CHF', userId: userDummyData.id }); @@ -426,7 +411,10 @@ describe('PortfolioService', () => { return ( portfolioService as unknown as { getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{ - accounts: Record; + accounts: Record< + string, + { quantity?: number; valueInBaseCurrency: number } + >; platforms: Record; }>; } @@ -443,6 +431,10 @@ describe('PortfolioService', () => { }; beforeEach(() => { + jest + .spyOn(accountService, 'accounts') + .mockResolvedValue([account] as unknown as AccountWithBalance[]); + jest .spyOn(accountService, 'getAccounts') .mockResolvedValue([account] as unknown as AccountWithBalance[]); @@ -510,5 +502,156 @@ describe('PortfolioService', () => { expect(accounts[UNKNOWN_KEY]).toBeUndefined(); expect(platforms[UNKNOWN_KEY]).toBeUndefined(); }); + + it('should not accumulate rounding errors of activities cancelling each other out', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.1, + type: 'BUY' + }, + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.2, + type: 'BUY' + }, + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.3, + type: 'SELL' + } + ], + filters: [], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 1234.5678 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 100 (balance) + 0 (activities) + expect(accounts[account.id].valueInBaseCurrency).toBe(100); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(100); + }); + + it('should aggregate the quantity per account if the activities are filtered by a single holding', async () => { + const { accounts } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.1, + type: 'BUY' + }, + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.2, + type: 'BUY' + } + ], + filters: [{ id: 'AAPL', type: 'SYMBOL' }], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + expect(accounts[account.id].quantity).toBe(0.3); + }); + + it('should not expose a quantity if the activities are not filtered by a single holding', async () => { + const { accounts } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 1, + type: 'BUY' + } + ], + filters: [], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + expect(accounts[account.id].quantity).toBeUndefined(); + }); + + it('should only consider accounts of the current user if the activities are filtered by a single account', async () => { + const accountsSpy = jest.spyOn(accountService, 'accounts'); + + await getValueOfAccountsAndPlatforms({ + activities: [], + filters: [{ id: account.id, type: 'ACCOUNT' }], + portfolioItemsNow: {}, + userCurrency: 'USD', + userId: userDummyData.id + }); + + expect(accountsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: userDummyData.id, id: account.id } + }) + ); + }); + + it('should exclude the cash balance if the activities are filtered by a single holding', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 1, + type: 'BUY' + } + ], + filters: [{ id: 'AAPL', type: 'SYMBOL' }], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 1 * 10 (activity), without the balance of 100 + expect(accounts[account.id].valueInBaseCurrency).toBe(10); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(10); + }); + + it('should not accumulate rounding errors of the balances of accounts sharing a platform', async () => { + const platformId = randomUUID(); + + jest.spyOn(accountService, 'getAccounts').mockResolvedValue([ + { ...account, platformId, balance: 0.1, id: randomUUID() }, + { ...account, platformId, balance: 0.2, id: randomUUID() } + ] as unknown as AccountWithBalance[]); + + const { platforms } = await getValueOfAccountsAndPlatforms({ + activities: [], + filters: [], + portfolioItemsNow: {}, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 0.1 (balance) + 0.2 (balance) + expect(platforms[platformId].valueInBaseCurrency).toBe(0.3); + }); }); }); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 48ea66dac..30eb53c59 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 { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; +import { EmergencyFundCoverage } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-coverage'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume'; import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power'; @@ -24,7 +25,6 @@ import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.s 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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { getAnnualizedPerformancePercent, @@ -33,6 +33,7 @@ import { import { DEFAULT_CURRENCY, DEFAULT_DATE_RANGE, + TAG_ID_DRAFT, TAG_ID_EMERGENCY_FUND, TAG_ID_EXCLUDE_FROM_ANALYSIS, UNKNOWN_KEY @@ -42,7 +43,9 @@ import { getAssetProfileIdentifier, getSum, isAccountExcluded, - parseDate + isDraftActivity, + parseDate, + resolveUserSettings } from '@ghostfolio/common/helper'; import { AccountsResponse, @@ -122,7 +125,6 @@ export class PortfolioService { private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly i18nService: I18nService, - private readonly impersonationService: ImpersonationService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly rulesService: RulesService, private readonly symbolProfileService: SymbolProfileService, @@ -173,17 +175,28 @@ export class PortfolioService { this.accountService.accounts({ where, include: { - activities: { include: { SymbolProfile: true } }, + activities: { + include: { + SymbolProfile: true, + tags: { + select: { + id: true + }, + where: { + id: TAG_ID_DRAFT + } + } + } + }, platform: true, tags: true }, orderBy: { name: 'asc' } }), this.getDetails({ + userId, withExcludedAccounts, - filters: filtersWithoutSearchQueryFilter, - impersonationId: userId, - userId: this.request.user.id + filters: filtersWithoutSearchQueryFilter }), this.userService.user({ id: userId }) ]); @@ -199,12 +212,18 @@ export class PortfolioService { for (const { currency, date, - isDraft, quantity, SymbolProfile, + tags, type, unitPrice } of account.activities) { + activitiesCount += 1; + + if (isDraftActivity({ tags })) { + continue; + } + switch (type) { case ActivityType.DIVIDEND: dividendInBaseCurrency += @@ -225,12 +244,12 @@ export class PortfolioService { )) ?? 0; break; } - - if (!isDraft) { - activitiesCount += 1; - } } + const quantityOfHolding = filterBySymbol + ? (details.accounts[account.id]?.quantity ?? 0) + : undefined; + const valueInBaseCurrency = details.accounts[account.id]?.valueInBaseCurrency ?? 0; @@ -246,6 +265,7 @@ export class PortfolioService { account.currency, userCurrency ), + quantity: quantityOfHolding, value: this.exchangeRateDataService.toCurrency( valueInBaseCurrency, userCurrency, @@ -343,14 +363,10 @@ export class PortfolioService { activities: Activity[]; groupBy?: GroupBy; }): InvestmentItem[] { - let dividends = activities.map(({ currency, date, value }) => { + let dividends = activities.map(({ date, valueInBaseCurrency }) => { return { date: format(date, DATE_FORMAT), - investment: this.exchangeRateDataService.toCurrency( - value, - currency, - this.getUserCurrency() - ) + investment: valueInBaseCurrency }; }); @@ -364,16 +380,12 @@ export class PortfolioService { public async getHoldings({ dateRange, filters, - impersonationId, userId }: { dateRange: DateRange; filters?: Filter[]; - impersonationId: string; userId: string; }) { - userId = await this.getUserId(impersonationId, userId); - const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy( filters, ({ type }) => { @@ -387,7 +399,6 @@ export class PortfolioService { const { holdings: holdingsMap } = await this.getDetails({ dateRange, - impersonationId, userId, filters: filtersWithoutSearchQueryFilter }); @@ -412,16 +423,13 @@ export class PortfolioService { dateRange, filters, groupBy, - impersonationId, userId }: { dateRange: DateRange; filters?: Filter[]; groupBy?: GroupBy; - impersonationId: string; userId: string; }): Promise { - userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate; @@ -492,7 +500,7 @@ export class PortfolioService { public async getDetails({ dateRange = DEFAULT_DATE_RANGE, filters, - impersonationId, + user: userFromCaller, userId, withExcludedAccounts = false, withMarkets = false, @@ -500,14 +508,14 @@ export class PortfolioService { }: { dateRange?: DateRange; filters?: Filter[]; - impersonationId: string; + user?: UserWithSettings; userId: string; withExcludedAccounts?: boolean; withMarkets?: boolean; withSummary?: boolean; }): Promise { - userId = await this.getUserId(impersonationId, userId); - const user = await this.userService.user({ id: userId }); + const user = + userFromCaller ?? (await this.userService.user({ id: userId })); const userCurrency = this.getUserCurrency(user); const emergencyFund = new Big( @@ -741,7 +749,6 @@ export class PortfolioService { if (withSummary) { summary = await this.getSummary({ filteredValueInBaseCurrency, - impersonationId, portfolioCalculator, userCurrency, userId, @@ -767,14 +774,11 @@ export class PortfolioService { public async getHolding({ dataSource, - impersonationId, symbol, userId }: { - impersonationId?: string; userId: string; } & AssetProfileIdentifier): Promise { - userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); @@ -1016,16 +1020,13 @@ export class PortfolioService { public async getPerformance({ dateRange = DEFAULT_DATE_RANGE, filters, - impersonationId, userId }: { dateRange?: DateRange; filters?: Filter[]; - impersonationId: string; userId: string; withExcludedAccounts?: boolean; }): Promise { - userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); @@ -1118,23 +1119,44 @@ export class PortfolioService { } public async getReport({ - impersonationId, userId }: { - impersonationId: string; userId: string; }): Promise { - userId = await this.getUserId(impersonationId, userId); - const userSettings = this.request.user.settings.settings as UserSettings; + const user = await this.userService.user({ id: userId }); + + // The rules are evaluated against the portfolio of the (potentially + // impersonated) user, while the translations follow the language of the + // authenticated user + const userSettings = resolveUserSettings({ + impersonationUserSettings: user?.settings?.settings as UserSettings, + userSettings: this.request.user.settings.settings as UserSettings + }); const { accounts, holdings, markets, marketsAdvanced, summary } = await this.getDetails({ - impersonationId, + user, userId, withMarkets: true, withSummary: true }); + // The cash balance of the summary is split into the emergency fund and + // the remainder, both denominated in the base currency of the user + const cashBalanceInBaseCurrency = new Big(summary.cash) + .plus(summary.emergencyFund.cash) + .toNumber(); + + const emergencyFundInBaseCurrency = userSettings.emergencyFund ?? 0; + + const emergencyFundHoldingsValueInBaseCurrency = + this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings }); + + const totalEmergencyFundInBaseCurrency = this.getTotalEmergencyFund({ + emergencyFundHoldingsValueInBaseCurrency, + userSettings + }).toNumber(); + const hasOpenHoldings = Object.keys(holdings).length > 0; const marketsAdvancedTotalInBaseCurrency = getSum( @@ -1180,12 +1202,22 @@ export class PortfolioService { this.exchangeRateDataService, this.i18nService, userSettings.language, - this.getTotalEmergencyFund({ - userSettings, - emergencyFundHoldingsValueInBaseCurrency: - this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings }) - }).toNumber() - ) + totalEmergencyFundInBaseCurrency + ), + // The coverage is only meaningful once an emergency fund has been + // set up, either by an amount or by the tagged holdings + ...(totalEmergencyFundInBaseCurrency > 0 + ? [ + new EmergencyFundCoverage( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + emergencyFundInBaseCurrency, + emergencyFundHoldingsValueInBaseCurrency, + cashBalanceInBaseCurrency + ) + ] + : []) ], userSettings ) @@ -1865,7 +1897,6 @@ export class PortfolioService { balanceInBaseCurrency, emergencyFundHoldingsValueInBaseCurrency, filteredValueInBaseCurrency, - impersonationId, portfolioCalculator, userCurrency, userId @@ -1873,12 +1904,10 @@ export class PortfolioService { balanceInBaseCurrency: number; emergencyFundHoldingsValueInBaseCurrency: number; filteredValueInBaseCurrency: Big; - impersonationId: string; portfolioCalculator: PortfolioCalculator; userCurrency: string; userId: string; }): Promise { - userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); const { activities } = await this.activitiesService.getActivities({ @@ -1911,7 +1940,6 @@ export class PortfolioService { } = await portfolioCalculator.getSnapshot(); const { performance } = await this.getPerformance({ - impersonationId, userId }); @@ -2074,8 +2102,8 @@ export class PortfolioService { }) { return getSum( activities - .filter(({ isDraft, type }) => { - return isDraft === false && type === activityType; + .filter((activity) => { + return !isDraftActivity(activity) && activity.type === activityType; }) .map(({ assetProfile, currency, quantity, unitPrice }) => { return new Big( @@ -2105,18 +2133,7 @@ export class PortfolioService { } private getUserCurrency(aUser?: UserWithSettings) { - return ( - aUser?.settings?.settings.baseCurrency ?? - this.request.user?.settings?.settings.baseCurrency ?? - DEFAULT_CURRENCY - ); - } - - private async getUserId(aImpersonationId: string, aUserId: string) { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(aImpersonationId); - - return impersonationUserId || aUserId; + return aUser?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; } private getUserPerformanceCalculationType( @@ -2143,6 +2160,10 @@ export class PortfolioService { const accounts: PortfolioDetails['accounts'] = {}; const platforms: PortfolioDetails['platforms'] = {}; + const { SYMBOL: [filterBySymbol] = [] } = groupBy(filters, ({ type }) => { + return type; + }); + let currentAccounts: (AccountWithBalance & { Order?: Order[]; platform?: Platform; @@ -2154,7 +2175,7 @@ export class PortfolioService { } else if (filters.length === 1 && filters[0].type === 'ACCOUNT') { currentAccounts = await this.accountService.accounts({ include: { platform: true, tags: true }, - where: { id: filters[0].id } + where: { userId, id: filters[0].id } }); } else { const accountIds = Array.from( @@ -2182,75 +2203,103 @@ export class PortfolioService { // Iterate over the accounts plus a null entry to group activities without // an account into the unknown bucket for (const account of [...currentAccounts, null]) { + const currentAccountId = account?.id || UNKNOWN_KEY; + const currentPlatformId = account?.platformId || UNKNOWN_KEY; + const ordersByAccount = activities.filter(({ accountId }) => { return account ? accountId === account.id : !accountId; }); if (account) { - accounts[account.id] = { + // The cash balance is not part of a holding and would distort the value + // and thus the allocation per account and platform + const balanceInBaseCurrency = filterBySymbol + ? 0 + : this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ); + + accounts[currentAccountId] = { balance: account.balance, currency: account.currency, name: account.name, - valueInBaseCurrency: this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ) + valueInBaseCurrency: balanceInBaseCurrency }; - if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) { - platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency += - this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ); + if (platforms[currentPlatformId]) { + platforms[currentPlatformId].valueInBaseCurrency = new Big( + platforms[currentPlatformId].valueInBaseCurrency + ) + .plus(balanceInBaseCurrency) + .toNumber(); } else { - platforms[account.platformId || UNKNOWN_KEY] = { + platforms[currentPlatformId] = { balance: account.balance, currency: account.currency, name: account.platform?.name, - valueInBaseCurrency: this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ) + valueInBaseCurrency: balanceInBaseCurrency }; } } - for (const { account, assetProfile, quantity, type } of ordersByAccount) { - const currentValueOfSymbolInBaseCurrency = - getFactor(type) * - quantity * - (portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ?? - 0); + if (ordersByAccount.length === 0) { + continue; + } - if (accounts[account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { - accounts[account?.id || UNKNOWN_KEY].valueInBaseCurrency += - currentValueOfSymbolInBaseCurrency; - } else { - accounts[account?.id || UNKNOWN_KEY] = { - balance: 0, - currency: account?.currency, - name: account?.name, - valueInBaseCurrency: currentValueOfSymbolInBaseCurrency - }; - } + let quantityOfAccount = new Big(0); + let valueOfAccountInBaseCurrency = new Big(0); - if ( - platforms[account?.platformId || UNKNOWN_KEY]?.valueInBaseCurrency - ) { - platforms[account?.platformId || UNKNOWN_KEY].valueInBaseCurrency += - currentValueOfSymbolInBaseCurrency; - } else { - platforms[account?.platformId || UNKNOWN_KEY] = { - balance: 0, - currency: account?.currency, - name: account?.platform?.name, - valueInBaseCurrency: currentValueOfSymbolInBaseCurrency - }; - } + for (const { assetProfile, quantity, type } of ordersByAccount) { + const currentQuantityOfSymbol = new Big(quantity).mul(getFactor(type)); + + quantityOfAccount = quantityOfAccount.plus(currentQuantityOfSymbol); + + valueOfAccountInBaseCurrency = valueOfAccountInBaseCurrency.plus( + currentQuantityOfSymbol.mul( + portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ?? + 0 + ) + ); + } + + // The quantity is only meaningful if the activities are filtered by a + // single holding + const quantityOfHolding = filterBySymbol + ? quantityOfAccount.toNumber() + : undefined; + + if (accounts[currentAccountId]) { + accounts[currentAccountId].quantity = quantityOfHolding; + accounts[currentAccountId].valueInBaseCurrency = new Big( + accounts[currentAccountId].valueInBaseCurrency + ) + .plus(valueOfAccountInBaseCurrency) + .toNumber(); + } else { + accounts[currentAccountId] = { + balance: 0, + currency: account?.currency, + name: account?.name, + quantity: quantityOfHolding, + valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber() + }; + } + + if (platforms[currentPlatformId]) { + platforms[currentPlatformId].valueInBaseCurrency = new Big( + platforms[currentPlatformId].valueInBaseCurrency + ) + .plus(valueOfAccountInBaseCurrency) + .toNumber(); + } else { + platforms[currentPlatformId] = { + balance: 0, + currency: account?.currency, + name: account?.platform?.name, + valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber() + }; } } diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index a70fe8791..0cfc4d90e 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; @@ -31,6 +32,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { SubscriptionService } from './subscription.service'; +@AllowDuringImpersonation() @Controller('subscription') export class SubscriptionController { private readonly logger = new Logger(SubscriptionController.name); diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index 98869797e..9cf940208 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -198,7 +198,7 @@ export class SymbolService { results.items = items; return results; } catch (error) { - this.logger.error(error); + this.logger.error(error.message); throw error; } diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 2b679f34c..cf9043910 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -1,15 +1,16 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; import { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { - HEADER_KEY_IMPERSONATION, THROTTLE_SIGNUP_LIMIT, THROTTLE_SIGNUP_TTL } from '@ghostfolio/common/config'; @@ -18,6 +19,7 @@ import { UpdateOwnAccessTokenDto, UpdateUserSettingDto } from '@ghostfolio/common/dtos'; +import { isUserSettingOfAuthenticatedUser } from '@ghostfolio/common/helper'; import { AccessTokenResponse, User, @@ -25,7 +27,10 @@ import { UserSettings } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { + ImpersonationContext, + RequestWithUser +} from '@ghostfolio/common/types'; import { Body, @@ -51,11 +56,11 @@ import { merge, size } from 'lodash'; import { UserService } from './user.service'; +@AllowDuringImpersonation() @Controller('user') export class UserController { public constructor( private readonly configurationService: ConfigurationService, - private readonly impersonationService: ImpersonationService, private readonly jwtService: JwtService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, @@ -119,18 +124,16 @@ export class UserController { } @Get() - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getUser( @Headers('accept-language') acceptLanguage: string, - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string + @Impersonation() { isActive, scopes, userId }: ImpersonationContext ): Promise { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - return this.userService.getUser({ - impersonationUserId, + scopes, + impersonationUserId: isActive ? userId : undefined, locale: acceptLanguage?.split(',')?.[0], user: this.request.user }); @@ -167,9 +170,27 @@ export class UserController { } @Put('setting') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(TransformDataSourceInResponseInterceptor) - public async updateUserSetting(@Body() data: UpdateUserSettingDto) { + public async updateUserSetting( + @Body() data: UpdateUserSettingDto, + @Impersonation() { isActive }: ImpersonationContext + ) { + if ( + isActive && + Object.keys(data).some((key) => { + return !isUserSettingOfAuthenticatedUser(key); + }) + ) { + // While impersonating, only the settings which stay with the + // authenticated user can be changed, as the update is always written + // back to the authenticated user + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + if ( size(data) === 1 && (data.benchmark || data.dateRange) && diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 8fb11ad34..0a4f21190 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -11,6 +11,7 @@ import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/ap import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; +import { EmergencyFundCoverage } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-coverage'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume'; import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power'; @@ -34,11 +35,13 @@ import { PROPERTY_MAX_DAILY_REQUESTS, PROPERTY_REFERRAL_PARTNERS, PROPERTY_SYSTEM_MESSAGE, + TAG_ID_DRAFT, TAG_ID_EXCLUDE_FROM_ANALYSIS, THROTTLE_DAILY_KEY, THROTTLE_DAILY_TTL } from '@ghostfolio/common/config'; import { SubscriptionType } from '@ghostfolio/common/enums'; +import { resolveUserSettings } from '@ghostfolio/common/helper'; import { User as IUser, ReferralPartner, @@ -50,13 +53,14 @@ import { hasRole, permissions } from '@ghostfolio/common/permissions'; +import { getScopesOfAccess } from '@ghostfolio/common/scopes'; import { UserWithSettings } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Injectable, Logger } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler'; -import { Prisma, Role, Settings, User } from '@prisma/client'; +import { Prisma, Role, User } from '@prisma/client'; import { differenceInDays, subDays } from 'date-fns'; import { isNil, without } from 'lodash'; import { createHmac } from 'node:crypto'; @@ -112,10 +116,12 @@ export class UserService { public async getUser({ impersonationUserId, locale = DEFAULT_LOCALE, + scopes, user }: { impersonationUserId: string; locale?: string; + scopes: string[]; user: UserWithSettings; }): Promise { const { id, permissions, settings, subscription } = user; @@ -125,7 +131,7 @@ export class UserService { accounts, activitiesCount, firstActivity, - impersonationUserSettings, + impersonationUser, tagsForUser ] = await Promise.all([ this.prismaService.access.findMany({ @@ -154,36 +160,39 @@ export class UserService { where: { userId: impersonationUserId || user.id } }), impersonationUserId - ? this.prismaService.settings.findUnique({ - where: { userId: impersonationUserId } - }) - : Promise.resolve(null), + ? this.user({ id: impersonationUserId }) + : Promise.resolve(null), this.tagService.getTagsForUser(impersonationUserId || user.id) ]); - const baseCurrency = - (impersonationUserSettings?.settings as UserSettings)?.baseCurrency ?? - (settings.settings as UserSettings)?.baseCurrency; + const resolvedUserSettings = resolveUserSettings({ + impersonationUserSettings: impersonationUser?.settings + ?.settings as UserSettings, + userSettings: settings.settings as UserSettings + }); - let referralPartners: ReferralPartner[]; + let referralPartners: ReferralPartner[] = []; if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - subscription.type === SubscriptionType.Basic + subscription?.type === SubscriptionType.Basic ) { referralPartners = await this.propertyService.getByKey( PROPERTY_REFERRAL_PARTNERS ); } - let systemMessage: SystemMessage; + let systemMessage: SystemMessage | undefined; const systemMessageProperty = await this.propertyService.getByKey( PROPERTY_SYSTEM_MESSAGE ); - if (systemMessageProperty?.targetGroups?.includes(subscription?.type)) { + if ( + subscription?.type && + systemMessageProperty?.targetGroups?.includes(subscription.type) + ) { systemMessage = systemMessageProperty; } @@ -191,10 +200,10 @@ export class UserService { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - subscription.type === SubscriptionType.Basic + subscription?.type === SubscriptionType.Basic ) { tags = tags.filter(({ id }) => { - return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; + return [TAG_ID_DRAFT, TAG_ID_EXCLUDE_FROM_ANALYSIS].includes(id); }); } @@ -203,6 +212,7 @@ export class UserService { id, permissions, referralPartners, + scopes, subscription, systemMessage, tags, @@ -210,7 +220,8 @@ export class UserService { return { alias: accessItem.alias, id: accessItem.id, - permissions: accessItem.permissions + permissions: accessItem.permissions, + scopes: getScopesOfAccess(accessItem) }; }), accounts: accounts.sort((a, b) => { @@ -218,9 +229,9 @@ export class UserService { }), dateOfFirstActivity: firstActivity?.date ?? new Date(), settings: { - ...(settings.settings as UserSettings), - baseCurrency, - locale: (settings.settings as UserSettings)?.locale ?? locale + ...resolvedUserSettings, + baseCurrency: resolvedUserSettings.baseCurrency ?? DEFAULT_CURRENCY, + locale: resolvedUserSettings.locale ?? locale } }; } @@ -279,7 +290,6 @@ export class UserService { activities: true } }, - accessesGet: true, accounts: { include: { platform: true } }, @@ -296,7 +306,6 @@ export class UserService { const { _count, - accessesGet, accessToken, accounts, analytics, @@ -314,7 +323,6 @@ export class UserService { const activitiesCount = _count?.activities ?? 0; const user: UserWithSettings = { - accessesGet, accessToken, accounts, authChallenge, @@ -448,6 +456,14 @@ export class UserService { undefined, undefined ).getSettings(user.settings.settings), + EmergencyFundCoverage: new EmergencyFundCoverage( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), EmergencyFundSetup: new EmergencyFundSetup( undefined, undefined, diff --git a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json index 9eac78aa4..69d455264 100644 --- a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json +++ b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json @@ -113,7 +113,7 @@ "2OMB": "2omb Finance", "2SHARES": "2SHARE", "2TF": "2TF", - "2Z": "DoubleZero USD Price", + "2Z": "DoubleZero", "300F": "300FIT", "314DAO": "Tonken 314 DAO", "32BIT": "32Bitcoin", @@ -212,7 +212,7 @@ "AAVEGOTCHIFOMO": "Aavegotchi FOMO", "AAX": "Academic Labs", "AAZ": "ATLAZ", - "AB": "Arma The Battle Ground USD Price", + "AB": "Arma The Battle Ground", "AB1INCH": "1inch (Avalanche Bride)", "ABA": "EcoBall", "ABAT": "Aave BAT", @@ -806,7 +806,7 @@ "AMY": "Amygws", "AMZE": "The Amaze World", "AMZNON": "Amazon (Ondo Tokenized)", - "AMZNX": "Amazon tokenized stock (xStock) USD Price", + "AMZNX": "Amazon tokenized stock (xStock)", "ANA": "Nirvana ANA", "ANAL": "AnalCoin", "ANALOS": "analoS", @@ -845,7 +845,7 @@ "ANIM": "Animalia", "ANIMA": "Realm Anima", "ANIME": "Animecoin", - "ANIME35319": "Animecoin USD Price", + "ANIME35319": "Animecoin", "ANIMECOIN": "Animecoin", "ANIMEONBASE": "Anime", "ANIMETOKEN": "Anime Token (animetoken.in)", @@ -1016,7 +1016,7 @@ "ARBUZ": "ARBUZ", "ARBYS": "Arbys Token", "ARC": "AI Rig Complex", - "ARC23486": "Arcadeum USD Price", + "ARC23486": "Arcadeum", "ARCA": "Legend of Arcadia", "ARCAD": "Arcadeum", "ARCADE": "ARCADE", @@ -1367,7 +1367,7 @@ "AVGOX": "Broadcom tokenized stock (xStock)", "AVH": "Animation Vision Cash", "AVI": "Aviator", - "AVICI": "Avici USD Price", + "AVICI": "Avici", "AVINOC": "AVINOC", "AVIVE": "Avive World", "AVL": "Aston Villa Fan Token", @@ -1390,7 +1390,7 @@ "AWAX": "AWAX", "AWBTC": "Aave interest bearing WBTC", "AWC": "Atomic Wallet Coin", - "AWE": "AWE Network USD Price", + "AWE": "AWE Network", "AWK": "Awkward Monkey Base", "AWM": "Another World", "AWNEX": "AWNEX token", @@ -1452,7 +1452,7 @@ "AZUR": "Azuro Protocol", "AZURE": "Azure Wallet", "AZY": "Amazy", - "B": "BUILDon USD Price", + "B": "BUILDon", "B01": "b0rder1ess", "B1P": "B ONE PAYMENT", "B2": "B² Network", @@ -1974,7 +1974,7 @@ "BEPE": "Blast Pepe", "BEPR": "Blockchain Euro Project", "BEPRO": "BEPRO Network", - "BERA": "Berachain USD Price", + "BERA": "Berachain", "BERAETH": "Berachain Staked ETH", "BERASTONE": "StakeStone Berachain Vault Token", "BERC": "Fair BERC20", @@ -2129,6 +2129,7 @@ "BIKE": "White Bike", "BIKI": "BIKI", "BILL": "TillBilly", + "BILL39545": "Billions Network", "BILLI": "Billi", "BILLICAT": "BilliCat", "BILLY": "Billy", @@ -2614,7 +2615,7 @@ "BONDLYV1": "Bondly Finance", "BONDX": "BondX", "BONE": "Shibarium Wrapped BONE", - "BONE11865": "Bone ShibaSwap USD Price", + "BONE11865": "Bone ShibaSwap", "BONEBONE": "Bone", "BONES": "Moonshots Farm", "BONESCOIN": "BonesCoin", @@ -3715,7 +3716,7 @@ "CHILL": "ChillPill", "CHILLAX": "Chillax", "CHILLCAT": "Chillchat", - "CHILLGUY": "Just a chill guy USD Price", + "CHILLGUY": "Just a chill guy", "CHILLHOUSE": "Chill House", "CHIM": "Chimera", "CHINA": "China Coin", @@ -3965,7 +3966,7 @@ "CO2": "CO2 Token", "COA": "Alliance Games", "COAI": "ChainOpera AI", - "COAI38489": "ChainOpera AI USD Price", + "COAI38489": "ChainOpera AI", "COAL": "BitCoal", "COB": "Cobinhood", "COBE": "Castle of Blackwater", @@ -5260,7 +5261,7 @@ "DOFI": "Doge Floki Coin", "DOG": "Dog (Runes)", "DOG11557": "The Doge NFT", - "DOG30933": "Dog (Runes) USD Price", + "DOG30933": "Dog (Runes)", "DOGA": "DOGAMÍ", "DOGACOIN": "DogaCoin", "DOGAI": "Dogai", @@ -5527,7 +5528,7 @@ "DRT": "DomRaider", "DRUGS": "Big Pharmai", "DRV": "Derive", - "DRV35014": "Derive USD Price", + "DRV35014": "Derive", "DRX": "DRX Token", "DRXNE": "Droxne", "DRZ": "Droidz", @@ -6077,7 +6078,7 @@ "ENRON": "Enron", "ENRX": "Enrex", "ENS": "Ethereum Name Service", - "ENSO": "Enso USD Price", + "ENSO": "Enso", "ENT": "Eternity", "ENTC": "ENTERBUTTON", "ENTER": "EnterCoin", @@ -6629,7 +6630,7 @@ "FEY": "Feyorra", "FF": "Forefront", "FF1": "Two Prime FF1 Token", - "FF38482": "Falcon Finance USD Price", + "FF38482": "Falcon Finance", "FFA": "Cryptofifa", "FFC": "FireflyCoin", "FFCT": "FortFC", @@ -6678,7 +6679,7 @@ "FIGHTPEPE": "FIGHT PEPE", "FIGHTRUMP": "FIGHT TRUMP", "FIGMA": "Figments Club", - "FIGRHELOC": "Figure HELOC USD Price", + "FIGRHELOC": "Figure HELOC", "FIH": "Fidelity House", "FIII": "Fiii", "FIL": "Filecoin", @@ -6955,7 +6956,7 @@ "FORGE": "Forge Finance", "FORK": "Gastro Advisor Token", "FORM": "Formation Fi", - "FORM23635": "Four USD Price", + "FORM23635": "Four", "FORMATION": "Formation FI", "FORMNET": "Form", "FORS": "Foresight", @@ -7555,7 +7556,7 @@ "GIFT": "GiftNet", "GIG": "GigaCoin", "GIGA": "Gigachad", - "GIGA30063": "Gigachad USD Price", + "GIGA30063": "Gigachad", "GIGABRAIN": "Gigabrain by virtuals", "GIGACAT": "GIGACAT", "GIGACHAD": "GigaChad", @@ -7911,7 +7912,7 @@ "GRID": "Grid+", "GRIDCOIN": "GridCoin", "GRIDZ": "GridZone.io", - "GRIFFAIN": "GRIFFAIN USD Price", + "GRIFFAIN": "GRIFFAIN", "GRIFT": "ORBIT", "GRIM": "GrimToken", "GRIMACE": "GrimaceCoin", @@ -8108,7 +8109,7 @@ "GZT": "Golden Zen Token", "GZX": "GreenZoneX", "Glo Dollar": "USDGLO", - "H": "Humanity Protocol USD Price", + "H": "Humanity Protocol", "H1": "Haven1", "H1DR4": "H1DR4 by Virtuals", "H2O": "H2O DAO", @@ -8545,7 +8546,7 @@ "HOSTAI": "Host AI", "HOT": "Holo", "HOT1": "Holo", - "HOT2682": "Holo USD Price", + "HOT2682": "Holo", "HOTCROSS": "Hot Cross", "HOTDOGE": "Hot Doge", "HOTKEY": "HotKeySwap", @@ -8952,7 +8953,7 @@ "INFX": "Influxcoin", "ING": "Infinity Games", "INI": "InitVerse", - "INIT": "Initia USD Price", + "INIT": "Initia", "INJ": "Injective", "INK": "Ink", "INN": "Innova", @@ -9293,7 +9294,7 @@ "JINDO": "JINDOGE", "JINDOGE": "Jindoge", "JIO": "JIO Token", - "JITOSOL": "Jito Staked SOL USD Price", + "JITOSOL": "Jito Staked SOL", "JIZZ": "JizzRocket", "JIZZLORD": "JizzLord", "JIZZUS": "JIZZUS CHRIST", @@ -9301,7 +9302,7 @@ "JK": "JK Coin", "JKC": "JunkCoin", "JKL": "Jackal Protocol", - "JLP": "Jupiter Perps LP USD Price", + "JLP": "Jupiter Perps LP", "JLY": "Jellyverse", "JM": "JustMoney", "JMC": "Junson Ming Chan Coin", @@ -9457,7 +9458,7 @@ "KAIM": "Kai Meme", "KAINET": "KAINET", "KAIRO": "Kairo", - "KAITO": "KAITO USD Price", + "KAITO": "KAITO", "KAKA": "KAKA NFT World", "KAKAXA": "KAKAXA", "KAKI": "Doge KaKi", @@ -9694,7 +9695,7 @@ "KISHU": "Kishu Inu", "KIT": "DexKit", "KITA": "KITA INU", - "KITE": "Kite USD Price", + "KITE": "Kite", "KITEAI": "KITEAI", "KITEHAI": "Kite", "KITKAT": "Remember KitKat", @@ -10274,7 +10275,7 @@ "LISUSD": "lisUSD", "LIT": "Lition", "LIT1": "Litentry", - "LIT39125": "Lighter USD Price", + "LIT39125": "Lighter", "LITCOIN": "Litcoin", "LITE": "Lite USD", "LITEBTC": "LiteBitcoin", @@ -10362,7 +10363,7 @@ "LOCG": "LOCGame", "LOCI": "LociCoin", "LOCK": "Meridian Network", - "LOCKIN": "LOCK IN USD Price", + "LOCKIN": "LOCK IN", "LOCO": "Loco", "LOCOM": "Locomotir", "LOCUS": "Locus Chain", @@ -10370,7 +10371,7 @@ "LOE": "Legends of Elysium", "LOF": "Land of Fantasy", "LOFI": "LOFI", - "LOFI34187": "LOFI USD Price", + "LOFI34187": "LOFI", "LOFIBUZZ": "LOFI", "LOG": "Wood Coin", "LOGO": "LOGOS", @@ -10640,7 +10641,7 @@ "M0": "M by M^0", "M1": "SupplyShock", "M2O": "M2O Token", - "M35491": "MemeCore USD Price", + "M35491": "MemeCore", "M3H": "MehVerseCoin", "M3M3": "M3M3", "M87": "MESSIER", @@ -11011,7 +11012,7 @@ "MEFAI": "META FINANCIAL AI", "MEFI": "Meo Finance", "MEGA": "MegaCryptoPolis", - "MEGA38770": "MegaETH USD Price", + "MEGA38770": "MegaETH", "MEGABOT": "Megabot", "MEGAD": "Mega Dice Casino", "MEGAHERO": "MEGAHERO", @@ -11026,7 +11027,7 @@ "MEL": "MELX", "MELAN": "Melania Meme", "MELANIA": "Melania Meme", - "MELANIA35347": "Official Melania Meme USD Price", + "MELANIA35347": "Official Melania Meme", "MELANIATRUMP": "Melania Trump", "MELB": "Minelab", "MELD": "MELD", @@ -11106,7 +11107,7 @@ "MESSI": "MESSI COIN", "MESSU": "Loinel Messu", "MET": "Metronome", - "MET38353": "Meteora USD Price", + "MET38353": "Meteora", "META": "Metadium", "METAA": "META ARENA", "METABOT": "Robot Warriors", @@ -11513,7 +11514,7 @@ "MOBYONBASEV1": "Moby v1", "MOC": "Moss Coin", "MOCA": "Moca Network", - "MOCA31526": "Moca Network USD Price", + "MOCA31526": "Moca Network", "MOCHI": "Mochiswap", "MOCHI14315": "Mochi", "MOCHICAT": "MochiCat", @@ -11576,7 +11577,7 @@ "MOMO2": "MOMO 2.0", "MOMO2025": "momo", "MON": "PocMon", - "MON30495": "Monad USD Price", + "MON30495": "Monad", "MON30950": "MON", "MONA": "MonaCoin", "MONAI": "MONAI", @@ -11631,7 +11632,7 @@ "MOOBIFI": "Staked BIFI", "MOOCAT": "MooCat", "MOODENG": "Moo Deng (moodengsol.com)", - "MOODENG33093": "Moo Deng USD Price", + "MOODENG33093": "Moo Deng", "MOODENGBNB": "MOODENG (moodengbnb.com)", "MOODENGSBS": "Moo Deng (moodeng.sbs)", "MOODENGSPACE": "MOO DENG", @@ -12352,7 +12353,7 @@ "NIK": "NIKPLACE", "NIKO": "NikolAI", "NIL": "Nillion", - "NIL35702": "Nillion USD Price", + "NIL35702": "Nillion", "NILA": "MindWave", "NILE": "Nile", "NIM": "Nimiq", @@ -13672,7 +13673,7 @@ "PIPL": "PiplCoin", "PIPO": "Pipo", "PIPONHL": "PiP", - "PIPPIN": "pippin USD Price", + "PIPPIN": "pippin", "PIPPKIN": "Pippkin The Horse", "PIPT": "Power Index Pool Token", "PIRATE": "PirateCash", @@ -14094,7 +14095,7 @@ "PROLIFIC": "Prolific Game Studio", "PROM": "Prom", "PROME": "Prometheus Trading", - "PROMPT": "Wayfinder USD Price", + "PROMPT": "Wayfinder", "PRON": "Rise Of Nebula", "PROOF": "PROVER", "PROP": "Propeller", @@ -14250,7 +14251,7 @@ "PURPE": "Purple Pepe", "PURPLEBTC": "Purple Bitcoin", "PURR": "Purr", - "PURR34332": "Purr USD Price", + "PURR34332": "Purr", "PURRC": "Purrcoin", "PURSE": "Pundi X PURSE", "PUS": "Pussy Cat", @@ -14357,6 +14358,7 @@ "QGOLD": "Quorium", "QGOV": "Q Protocol", "QI": "QiSwap", + "QI9288": "BENQI", "QIE": "QI Blockchain", "QINGWA": "ShangXin QingWa", "QISWAP": "QiSwap", @@ -14546,7 +14548,7 @@ "RATWIF": "RatWifHat", "RAV": "Ravelin Finance", "RAVE": "Ravendex", - "RAVE38967": "RaveDAO USD Price", + "RAVE38967": "RaveDAO", "RAVELOUS": "Ravelous", "RAVEN": "Raven Protocol", "RAVENCOINC": "Ravencoin Classic", @@ -14620,6 +14622,7 @@ "RDT": "Ridotto", "RDWON": "Redwire (Ondo Tokenized)", "RDX": "Redux Protocol", + "RE": "Re", "REA": "Realisto", "REACH": "/Reach", "REACT": "Reactive Network", @@ -14706,7 +14709,7 @@ "REINDEER": "Reindeer", "REK": "Rekt", "REKT": "Rekt", - "REKT34434": "Rekt USD Price", + "REKT34434": "Rekt", "REKTV1": "REKT", "REKTV2": "REKT 2.0", "REKTV3": "REKT v3 (rekt.game)", @@ -15053,7 +15056,7 @@ "RRT": "Recovery Right Tokens", "RS": "ReadySwap", "RSC": "ResearchCoin", - "RSC27054": "ResearchCoin USD Price", + "RSC27054": "ResearchCoin", "RSETH": "Kelp DAO Restaked ETH", "RSF": "Royal Sting", "RSG": "RSG TOKEN", @@ -15202,7 +15205,7 @@ "SAD": "SadCat", "SAF": "Safcoin", "SAFE": "SafeCoin", - "SAFE21585": "Safe USD Price", + "SAFE21585": "Safe", "SAFEBTC": "SafeBTC", "SAFEBULL": "SafeBull", "SAFECOIN": "SafeCoin", @@ -15289,7 +15292,7 @@ "SAO": "Sator", "SAP": "SwapAll", "SAPE": "SolanaApe", - "SAPIEN": "Sapien USD Price", + "SAPIEN": "Sapien", "SAPP": "Sapphire", "SAPPC": "SappChat", "SAR": "Saren", @@ -15315,7 +15318,7 @@ "SATOX": "Satoxcoin", "SATOZ": "Satozhi", "SATS": "SATS (Ordinals)", - "SATS28194": "SATS (Ordinals) USD Price", + "SATS28194": "SATS (Ordinals)", "SATSALL": "ALL BEST ICO SATOSHI", "SATT": "SaTT", "SATX": "SATX", @@ -15514,7 +15517,7 @@ "SENATE": "SENATE", "SENC": "Sentinel Chain", "SEND": "Social Send", - "SEND34611": "Suilend USD Price", + "SEND34611": "Suilend", "SENDCOIN": "Sendcoin", "SENDOR": "Sendor", "SENK": "Senk", @@ -16125,7 +16128,7 @@ "SNRG": "Synergy", "SNRK": "Snark Launch", "SNS": "Synesis One", - "SNS36468": "Solana Name Service USD Price", + "SNS36468": "Solana Name Service", "SNST": "Smooth Network Solutions Token", "SNSY": "Sensay", "SNT": "Status", @@ -16283,7 +16286,7 @@ "SONOF": "Son of Solana", "SONOR": "SonorusToken", "SOON": "SoonVerse", - "SOON36542": "SOON USD Price", + "SOON36542": "SOON", "SOONAVERSE": "Soonaverse", "SOONCOIN": "SoonCoin", "SOONTOKEN": "SOON", @@ -17340,7 +17343,7 @@ "THOR": "THORSwap", "THOREUM": "Thoreum V3", "THP": "TurboHigh Performance", - "THQ": "Theoriq Token", + "THQ": "Theoriq", "THR": "Thorecoin", "THREE": "Three Protocol Token", "THRT": "ThriveToken", @@ -17751,7 +17754,7 @@ "TRUMP2024": "Donald Trump", "TRUMP27872": "MAGA", "TRUMP3": "Trump MP3", - "TRUMP35336": "OFFICIAL TRUMP USD Price", + "TRUMP35336": "OFFICIAL TRUMP", "TRUMP47": "47th President of the United States", "TRUMPA": "TRUMP AI", "TRUMPAI": "Trump Maga AI", @@ -17890,7 +17893,7 @@ "TURT": "TurtSat", "TURT28826": "TurtSat", "TURTLE": "Turtle", - "TURTLE38671": "Turtle USD Price", + "TURTLE38671": "Turtle", "TUS": "Treasure Under Sea", "TUSD": "TrueUSD", "TUSDV1": "True USD v1", @@ -18065,7 +18068,7 @@ "UMAMI": "Umami Finance", "UMB": "Umbrella Network", "UMBR": "Umbria Network", - "UMBRA": "Umbra USD Price", + "UMBRA": "Umbra", "UMC": "Umbrella Coin", "UMI": "UMI", "UMID": "Umi Digital", @@ -18216,7 +18219,7 @@ "USCR": "United States Crypto Reserve", "USD0": "Usual USD", "USD1": "World Liberty Financial USD", - "USD136148": "World Liberty Financial USD USD Price", + "USD136148": "World Liberty Financial USD", "USD3": "Web 3 Dollar", "USDA": "USDA", "USDACC": "USDA", @@ -18296,7 +18299,7 @@ "USE": "Usechain Token", "USEDCAR": "A Gently Used 2001 Honda", "USELESS": "USELESS COIN", - "USELESS36828": "Useless Coin USD Price", + "USELESS36828": "Useless Coin", "USETH": "USETH", "USF": "Unslashed Finance", "USG": "USGold", @@ -18363,7 +18366,7 @@ "UWULEND": "UwU Lend", "UX": "UX Chain", "UXD": "UXD Stablecoin", - "UXLINK": "UXLINK USD Price", + "UXLINK": "UXLINK", "UXLINKV1": "UXLINK v1", "UXOS": "UXOS", "UXP": "UXD Protocol", @@ -19177,7 +19180,7 @@ "WLSC": "WESTLAND SMART CITY", "WLTH": "Common Wealth", "WLUNA": "Wrapped LUNA Token", - "WLUNC": "Wrapped LUNA Classic USD Price", + "WLUNC": "Wrapped LUNA Classic", "WLXT": "Wallex Token", "WM": "WrappedM by M^0", "WMATIC": "Wrapped Matic", @@ -19509,7 +19512,7 @@ "XCXT": "CoinonatX", "XD": "Data Transaction Token", "XDAG": "XDAG", - "XDAI": "xDAI", + "XDAI": "XDAI", "XDAO": "XDAO", "XDATA": "Streamr XDATA", "XDB": "DigitalBits", @@ -19587,7 +19590,7 @@ "XIDR": "XIDR", "XIL": "Project X", "XIN": "Mixin", - "XIN2349": "Mixin USD Price", + "XIN2349": "Mixin", "XING": "Xing Xing", "XINGXING": "星星", "XINU": "XINU", @@ -19848,7 +19851,7 @@ "YAXIS": "yAxis", "YAY": "YAY Games", "YAYCOIN": "YAYcoin", - "YB": "YieldBasis USD Price", + "YB": "YieldBasis", "YBC": "YbCoin", "YBDBD": "YBDBD", "YBNB": "Yellow BNB 4", @@ -20034,7 +20037,7 @@ "ZBCN": "Zebec Network", "ZBIT": "zbit", "ZBT": "ZB Token", - "ZBT38427": "ZEROBASE USD Price", + "ZBT38427": "ZEROBASE", "ZBU": "Zeebu", "ZBUV1": "ZEEBU v1", "ZCC": "ZCC Coin", diff --git a/apps/api/src/decorators/allow-during-impersonation.decorator.ts b/apps/api/src/decorators/allow-during-impersonation.decorator.ts new file mode 100644 index 000000000..4795bcf90 --- /dev/null +++ b/apps/api/src/decorators/allow-during-impersonation.decorator.ts @@ -0,0 +1,12 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ALLOW_DURING_IMPERSONATION_KEY = 'allow_during_impersonation'; + +/** + * Marks a controller or a route which modifies data of the authenticated user + * instead of data of the impersonated user, hence it stays available while an + * impersonation is active + */ +export function AllowDuringImpersonation() { + return SetMetadata(ALLOW_DURING_IMPERSONATION_KEY, true); +} diff --git a/apps/api/src/decorators/impersonation.decorator.ts b/apps/api/src/decorators/impersonation.decorator.ts new file mode 100644 index 000000000..3964388e5 --- /dev/null +++ b/apps/api/src/decorators/impersonation.decorator.ts @@ -0,0 +1,28 @@ +import { getScopesOfOwnAccess } from '@ghostfolio/common/scopes'; +import type { + ImpersonationContext, + RequestWithUser +} from '@ghostfolio/common/types'; + +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +/** + * Provides the impersonation context of the request, which requires the + * ImpersonationGuard to be applied to the route + */ +export const Impersonation = createParamDecorator( + (_data: unknown, context: ExecutionContext): ImpersonationContext => { + const { impersonation, user } = context + .switchToHttp() + .getRequest(); + + return ( + impersonation ?? { + isActive: false, + scopes: getScopesOfOwnAccess(), + userId: user?.id, + userSettings: user?.settings?.settings ?? {} + } + ); + } +); diff --git a/apps/api/src/decorators/requires-scope.decorator.spec.ts b/apps/api/src/decorators/requires-scope.decorator.spec.ts new file mode 100644 index 000000000..a36ff9201 --- /dev/null +++ b/apps/api/src/decorators/requires-scope.decorator.spec.ts @@ -0,0 +1,41 @@ +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; +import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard'; +import { scopes } from '@ghostfolio/common/scopes'; + +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { AuthGuard } from '@nestjs/passport'; + +import { REQUIRES_SCOPE_KEY, RequiresScope } from './requires-scope.decorator'; + +class TestController { + @RequiresScope(scopes.portfolioRead) + public getPortfolio() { + return null; + } +} + +describe('Requires scope', () => { + it('Sets the required scopes', () => { + expect( + Reflect.getMetadata( + REQUIRES_SCOPE_KEY, + TestController.prototype.getPortfolio + ) + ).toEqual([scopes.portfolioRead]); + }); + + it('Applies the guards in the required order', () => { + expect( + Reflect.getMetadata( + GUARDS_METADATA, + TestController.prototype.getPortfolio + ) + ).toEqual([ + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ]); + }); +}); diff --git a/apps/api/src/decorators/requires-scope.decorator.ts b/apps/api/src/decorators/requires-scope.decorator.ts new file mode 100644 index 000000000..dab837955 --- /dev/null +++ b/apps/api/src/decorators/requires-scope.decorator.ts @@ -0,0 +1,26 @@ +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; +import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard'; +import { Scope } from '@ghostfolio/common/scopes'; + +import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +export const REQUIRES_SCOPE_KEY = 'requires_scope'; + +/** + * Marks a route which requires the given scopes and applies the guards which + * resolve the impersonation context and evaluate it, hence the ScopeGuard + * cannot be applied without the ImpersonationGuard preceding it + */ +export function RequiresScope(...requiredScopes: Scope[]) { + return applyDecorators( + SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes), + UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) + ); +} diff --git a/apps/api/src/guards/impersonation-write.guard.ts b/apps/api/src/guards/impersonation-write.guard.ts new file mode 100644 index 000000000..ec94c8e10 --- /dev/null +++ b/apps/api/src/guards/impersonation-write.guard.ts @@ -0,0 +1,53 @@ +import { ALLOW_DURING_IMPERSONATION_KEY } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; +import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; + +import { + CanActivate, + ExecutionContext, + HttpException, + Injectable +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +/** + * Blocks write requests while an impersonation is active, so that data of the + * authenticated user cannot be changed from a view presenting data of the + * impersonated user. The header is evaluated instead of the resolved context to + * fail closed, also for an identifier which cannot be resolved. + */ +@Injectable() +export class ImpersonationWriteGuard implements CanActivate { + public constructor(private readonly reflector: Reflector) {} + + public canActivate(context: ExecutionContext): boolean { + if (context.getType() !== 'http') { + return true; + } + + const request = context.switchToHttp().getRequest(); + + if (request.method === 'GET') { + return true; + } + + if (!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()]) { + return true; + } + + const isAllowedDuringImpersonation = + this.reflector.getAllAndOverride( + ALLOW_DURING_IMPERSONATION_KEY, + [context.getHandler(), context.getClass()] + ); + + if (isAllowedDuringImpersonation) { + return true; + } + + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } +} diff --git a/apps/api/src/guards/impersonation.guard.ts b/apps/api/src/guards/impersonation.guard.ts new file mode 100644 index 000000000..88500f1b3 --- /dev/null +++ b/apps/api/src/guards/impersonation.guard.ts @@ -0,0 +1,25 @@ +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; + +@Injectable() +export class ImpersonationGuard implements CanActivate { + public constructor( + private readonly impersonationService: ImpersonationService + ) {} + + public async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + + request.impersonation = await this.impersonationService.resolve({ + impersonationId: request.headers?.[ + HEADER_KEY_IMPERSONATION.toLowerCase() + ] as string, + user: request.user + }); + + return true; + } +} diff --git a/apps/api/src/guards/oauth-callback.guard.ts b/apps/api/src/guards/oauth-callback.guard.ts new file mode 100644 index 000000000..cedbccde1 --- /dev/null +++ b/apps/api/src/guards/oauth-callback.guard.ts @@ -0,0 +1,21 @@ +import { Logger, mixin, Type } from '@nestjs/common'; +import { AuthGuard, IAuthGuard } from '@nestjs/passport'; + +export function OAuthCallbackGuard(strategy: string): Type { + class OAuthCallbackGuardMixin extends AuthGuard(strategy) { + private readonly logger = new Logger(OAuthCallbackGuard.name); + + public override handleRequest(error: Error, user: any) { + if (error) { + this.logger.error( + `Authentication with the ${strategy} strategy has failed: ${error.message}` + ); + } + + // Do not throw, the callback handler redirects to the login page instead + return user; + } + } + + return mixin(OAuthCallbackGuardMixin); +} diff --git a/apps/api/src/guards/scope.guard.ts b/apps/api/src/guards/scope.guard.ts new file mode 100644 index 000000000..025a85502 --- /dev/null +++ b/apps/api/src/guards/scope.guard.ts @@ -0,0 +1,50 @@ +import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; +import { hasScope, Scope } from '@ghostfolio/common/scopes'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + CanActivate, + ExecutionContext, + HttpException, + Injectable +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +/** + * Denies a request whose impersonation context does not cover the scopes + * required by the route. It has to be applied after the ImpersonationGuard, + * which resolves the context, hence the RequiresScope decorator applies both. + */ +@Injectable() +export class ScopeGuard implements CanActivate { + public constructor(private readonly reflector: Reflector) {} + + public canActivate(context: ExecutionContext): boolean { + const requiredScopes = this.reflector.getAllAndOverride( + REQUIRES_SCOPE_KEY, + [context.getHandler(), context.getClass()] + ); + + if (!requiredScopes?.length) { + return true; + } + + const { impersonation } = context + .switchToHttp() + .getRequest(); + + const hasRequiredScopes = requiredScopes.every((scope) => { + return hasScope(impersonation?.scopes, scope); + }); + + if (!hasRequiredScopes) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return true; + } +} diff --git a/apps/api/src/helper/activity.helper.ts b/apps/api/src/helper/activity.helper.ts new file mode 100644 index 000000000..b281106c7 --- /dev/null +++ b/apps/api/src/helper/activity.helper.ts @@ -0,0 +1,74 @@ +import { + NON_INVESTMENT_ACTIVITY_TYPES, + TAG_ID_DRAFT +} from '@ghostfolio/common/config'; + +import { Prisma, Type as ActivityType } from '@prisma/client'; +import { endOfToday, isAfter } from 'date-fns'; +import { uniqBy } from 'lodash'; + +export const WHERE_ACTIVITY_NOT_DRAFT: Prisma.OrderWhereInput = { + tags: { + none: { + id: TAG_ID_DRAFT + } + } +}; + +export function getTagsWithDraftTag({ + date, + draftTag, + endOfTodayDate = endOfToday(), + originalDate, + tags, + type +}: { + date: Date; + draftTag: T; + endOfTodayDate?: Date; + originalDate?: Date; + tags: T[]; + type: ActivityType; +}) { + if (!isDraftTagToBeAssigned({ date, endOfTodayDate, originalDate, type })) { + return tags; + } + + return uniqBy([...tags, draftTag], 'id'); +} + +export function isActivityInFuture({ + date, + endOfTodayDate = endOfToday() +}: { + date: Date; + endOfTodayDate?: Date; +}) { + return isAfter(date, endOfTodayDate); +} + +export function isDraftTagToBeAssigned({ + date, + endOfTodayDate = endOfToday(), + originalDate, + type +}: { + date: Date; + endOfTodayDate?: Date; + originalDate?: Date; + type: ActivityType; +}) { + if (NON_INVESTMENT_ACTIVITY_TYPES.includes(type)) { + return false; + } + + if (!isActivityInFuture({ date, endOfTodayDate })) { + return false; + } + + // Assign only when the date newly moves into the future, so that a tag the + // user has removed is not restored by an unrelated change + return originalDate + ? !isActivityInFuture({ endOfTodayDate, date: originalDate }) + : true; +} diff --git a/apps/api/src/helper/country.helper.ts b/apps/api/src/helper/country.helper.ts index 1d9f8f99a..2e6da5340 100644 --- a/apps/api/src/helper/country.helper.ts +++ b/apps/api/src/helper/country.helper.ts @@ -1,3 +1,4 @@ +import { Logger } from '@nestjs/common'; import { countries } from 'countries-list'; export function getCountryCodeByName({ @@ -17,5 +18,11 @@ export function getCountryCodeByName({ } } + if (name && name.toLowerCase() !== 'other') { + const logger = new Logger('getCountryCodeByName'); + + logger.warn(`Could not map the country "${name}" to a code`); + } + return undefined; } diff --git a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts index 6a9596298..e575cbacd 100644 --- a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts +++ b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts @@ -1,13 +1,8 @@ import { redactPaths } from '@ghostfolio/api/helper/object.helper'; -import { - DEFAULT_REDACTED_PATHS, - HEADER_KEY_IMPERSONATION -} from '@ghostfolio/common/config'; -import { - hasReadRestrictedAccessPermission, - isRestrictedView -} from '@ghostfolio/common/permissions'; -import { UserWithSettings } from '@ghostfolio/common/types'; +import { DEFAULT_REDACTED_PATHS } from '@ghostfolio/common/config'; +import { isRestrictedView } from '@ghostfolio/common/permissions'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; +import type { RequestWithUser } from '@ghostfolio/common/types'; import { CallHandler, @@ -29,17 +24,15 @@ export class RedactValuesInResponseInterceptor implements NestInterceptor< ): Observable { return next.handle().pipe( map((data: any) => { - const { headers, user }: { headers: Headers; user: UserWithSettings } = - context.switchToHttp().getRequest(); - - const impersonationId = - headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()]; + const { impersonation, user } = context + .switchToHttp() + .getRequest(); + // A missing impersonation context originates from a public request or + // from a route without the ImpersonationGuard, hence the monetary + // values are redacted to never expose them unintentionally if ( - hasReadRestrictedAccessPermission({ - impersonationId, - accesses: user?.accessesGet - }) || + !hasScope(impersonation?.scopes, scopes.portfolioReadValues) || isRestrictedView(user) ) { data = redactPaths({ diff --git a/apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts b/apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts new file mode 100644 index 000000000..7f9bbba09 --- /dev/null +++ b/apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts @@ -0,0 +1,110 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; +import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; + +import { Big } from 'big.js'; + +export class EmergencyFundCoverage extends Rule { + public constructor( + exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + languageCode: string, + private emergencyFundInBaseCurrency: number, + private emergencyFundHoldingsValueInBaseCurrency: number, + private cashBalanceInBaseCurrency: number + ) { + super(exchangeRateDataService, { + languageCode, + key: EmergencyFundCoverage.name + }); + } + + public evaluate(ruleSettings: Settings) { + if (!this.emergencyFundInBaseCurrency) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.emergencyFundCoverage.false.unset', + languageCode: this.getLanguageCode() + }), + value: false + }; + } + + const placeholders = { + baseCurrency: ruleSettings.baseCurrency, + emergencyFund: this.emergencyFundInBaseCurrency.toLocaleString( + ruleSettings.locale + ) + }; + + // Only the holdings tagged as emergency fund are an explicit commitment, + // the cash balance covers the remainder + if ( + new Big(this.emergencyFundHoldingsValueInBaseCurrency).gt( + this.emergencyFundInBaseCurrency + ) + ) { + return { + evaluation: this.i18nService.getTranslation({ + placeholders, + id: 'rule.emergencyFundCoverage.false.over', + languageCode: this.getLanguageCode() + }), + value: false + }; + } + + const coverageInBaseCurrency = new Big( + this.emergencyFundHoldingsValueInBaseCurrency + ).plus(this.cashBalanceInBaseCurrency); + + if (coverageInBaseCurrency.lt(this.emergencyFundInBaseCurrency)) { + return { + evaluation: this.i18nService.getTranslation({ + placeholders, + id: 'rule.emergencyFundCoverage.false.under', + languageCode: this.getLanguageCode() + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + placeholders, + id: 'rule.emergencyFundCoverage.true', + languageCode: this.getLanguageCode() + }), + value: true + }; + } + + public getConfiguration() { + return undefined; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.emergencyFundCoverage', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency = DEFAULT_CURRENCY, + locale = DEFAULT_LOCALE, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; +} diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.helper.spec.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.helper.spec.ts new file mode 100644 index 000000000..6d674255d --- /dev/null +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.helper.spec.ts @@ -0,0 +1,114 @@ +import { + activityDummyData, + assetProfileDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import * as commonHelper from '@ghostfolio/common/helper'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; + +import { AssetProfileSplit, DataSource } from '@prisma/client'; +import { Big } from 'big.js'; + +import { adjustActivityBySplits } from './asset-profile-split.helper'; + +describe('adjustActivityBySplits', () => { + it('adjusts quantity and prices using the cumulative split factor', () => { + const activity = createActivity('2020-01-01'); + const splits = [ + createSplit('2021-01-01', 2, 1), + createSplit('2022-01-01', 1, 3) + ]; + + const adjustedActivity = adjustActivityBySplits(activity, splits); + + expect(adjustedActivity).not.toBe(activity); + expect(adjustedActivity).toMatchObject({ + quantity: 20 / 3, + unitPrice: 150, + unitPriceInAssetProfileCurrency: 150, + value: 1000, + valueInBaseCurrency: 1000 + }); + expect(new Big(adjustedActivity.quantity).toFixed(15)).toBe( + new Big(20).div(3).toFixed(15) + ); + expect(activity).toMatchObject({ + quantity: 10, + unitPrice: 100, + unitPriceInAssetProfileCurrency: 100 + }); + }); + + it('only adjusts activities before the split calendar date', () => { + const split = createSplit('2024-06-15T00:00:00Z', 2, 1); + const activityOnSplitDate = createActivity('2024-06-15T18:00:00Z'); + const activityAfterSplit = createActivity('2024-06-16T00:00:00Z'); + + const adjustedActivityOnSplitDate = adjustActivityBySplits( + activityOnSplitDate, + [split] + ); + const adjustedActivityAfterSplit = adjustActivityBySplits( + activityAfterSplit, + [split] + ); + + expect(adjustedActivityOnSplitDate).toEqual(activityOnSplitDate); + expect(adjustedActivityOnSplitDate).toBe(activityOnSplitDate); + expect(adjustedActivityAfterSplit).toEqual(activityAfterSplit); + expect(adjustedActivityAfterSplit).toBe(activityAfterSplit); + }); + + it('compares stored UTC split dates without normalizing them locally', () => { + const resetHoursSpy = jest + .spyOn(commonHelper, 'resetHours') + .mockReturnValue(new Date('2024-06-14T00:00:00Z')); + + try { + const activity = createActivity('2024-06-14T12:00:00Z'); + const split = createSplit('2024-06-15T00:00:00Z', 2, 1); + + const adjustedActivity = adjustActivityBySplits(activity, [split]); + + expect(adjustedActivity.quantity).toBe(20); + } finally { + resetHoursSpy.mockRestore(); + } + }); +}); + +function createActivity(date: string): Activity { + return { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + dataSource: DataSource.YAHOO, + symbol: 'AAPL' + }, + date: parseDate(date), + quantity: 10, + type: 'BUY', + unitPrice: 100, + unitPriceInAssetProfileCurrency: 100, + value: 1000, + valueInBaseCurrency: 1000 + } as Activity; +} + +function createSplit( + date: string, + numerator: number, + denominator: number +): AssetProfileSplit { + const splitDate = new Date(date); + + return { + denominator, + numerator, + createdAt: splitDate, + date: splitDate, + id: `${date}-${numerator}-${denominator}`, + symbolProfileId: 'aapl-profile', + updatedAt: splitDate + }; +} diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts new file mode 100644 index 000000000..dda77a5aa --- /dev/null +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts @@ -0,0 +1,58 @@ +import { INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config'; +import { resetHours } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; + +import { AssetProfileSplit } from '@prisma/client'; +import { Big } from 'big.js'; +import { isBefore } from 'date-fns'; + +export function adjustActivityBySplits( + activity: Activity, + splits: AssetProfileSplit[] +): Activity { + if (!INVESTMENT_ACTIVITY_TYPES.includes(activity.type)) { + return activity; + } + + const activityDate = resetHours(activity.date); + + // Accumulate both parts of the ratio and divide only once, so that the + // cumulative split factor of consecutive splits stays exact + let denominator = new Big(1); + let numerator = new Big(1); + + for (const split of splits) { + // Skip malformed splits to not break the portfolio calculation of every + // user holding this asset profile + if (split.denominator <= 0 || split.numerator <= 0) { + continue; + } + + if (isBefore(activityDate, split.date)) { + denominator = denominator.mul(split.denominator); + numerator = numerator.mul(split.numerator); + } + } + + if (numerator.eq(denominator)) { + return activity; + } + + return { + ...activity, + quantity: new Big(activity.quantity) + .mul(numerator) + .div(denominator) + .toNumber(), + unitPrice: new Big(activity.unitPrice) + .mul(denominator) + .div(numerator) + .toNumber(), + unitPriceInAssetProfileCurrency: new Big( + activity.unitPriceInAssetProfileCurrency + ) + .mul(denominator) + .div(numerator) + .toNumber() + }; +} diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts new file mode 100644 index 000000000..be5d7caa3 --- /dev/null +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts @@ -0,0 +1,161 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; + +import { AssetProfileSplit, DataSource } from '@prisma/client'; + +import { AssetProfileSplitService } from './asset-profile-split.service'; + +describe('AssetProfileSplitService', () => { + let assetProfileSplitService: AssetProfileSplitService; + let deleteMany: jest.Mock; + let findMany: jest.Mock; + let upsert: jest.Mock; + + beforeEach(() => { + deleteMany = jest.fn(); + findMany = jest.fn(); + upsert = jest.fn(); + + assetProfileSplitService = new AssetProfileSplitService({ + assetProfileSplit: { deleteMany, findMany, upsert } + } as unknown as PrismaService); + }); + + describe('deleteById', () => { + it('scopes deletion by split and asset profile identifiers', async () => { + deleteMany.mockResolvedValue({ count: 1 }); + + const result = await assetProfileSplitService.deleteById({ + id: 'split-id', + symbolProfileId: 'profile-id' + }); + + expect(result).toBe(true); + expect(deleteMany).toHaveBeenCalledWith({ + where: { + id: 'split-id', + symbolProfileId: 'profile-id' + } + }); + }); + + it('returns false when the split belongs to another asset profile', async () => { + deleteMany.mockResolvedValue({ count: 0 }); + + const result = await assetProfileSplitService.deleteById({ + id: 'split-id', + symbolProfileId: 'other-profile-id' + }); + + expect(result).toBe(false); + expect(deleteMany).toHaveBeenCalledWith({ + where: { + id: 'split-id', + symbolProfileId: 'other-profile-id' + } + }); + }); + }); + + describe('getSplitsByUserId', () => { + it('fetches the splits of the asset profiles held by the user with one ordered query', async () => { + const splits = [ + createStoredSplit('2020-08-31'), + createStoredSplit('2021-09-16') + ]; + + findMany.mockResolvedValue(splits); + + const result = await assetProfileSplitService.getSplitsByUserId({ + userId: 'user-id' + }); + + expect(result).toBe(splits); + expect(findMany).toHaveBeenCalledTimes(1); + expect(findMany).toHaveBeenCalledWith({ + orderBy: [{ date: 'asc' }], + where: { + symbolProfile: { + activities: { + some: { + userId: 'user-id' + } + } + } + } + }); + }); + }); + + describe('upsert', () => { + it('normalizes the split date before persisting it', async () => { + const date = new Date('2024-06-15T18:30:00.000Z'); + const normalizedDate = new Date('2024-06-15T00:00:00.000Z'); + + await assetProfileSplitService.upsert({ + date, + denominator: 1, + numerator: 2, + symbolProfileId: 'profile-id' + }); + + expect(upsert).toHaveBeenCalledWith({ + create: { + date: normalizedDate, + denominator: 1, + numerator: 2, + symbolProfileId: 'profile-id' + }, + update: { + denominator: 1, + numerator: 2 + }, + where: { + symbolProfileId_date: { + date: normalizedDate, + symbolProfileId: 'profile-id' + } + } + }); + }); + }); + + describe('getSplits', () => { + it('filters by asset profile and orders splits by date ascending', async () => { + const splits = [ + createStoredSplit('2020-01-01'), + createStoredSplit('2021-01-01') + ]; + findMany.mockResolvedValue(splits); + + const result = await assetProfileSplitService.getSplits({ + dataSource: DataSource.YAHOO, + symbol: 'AAPL' + }); + + expect(result).toBe(splits); + expect(findMany).toHaveBeenCalledWith({ + orderBy: [{ date: 'asc' }], + where: { + symbolProfile: { + dataSource: DataSource.YAHOO, + symbol: 'AAPL' + } + } + }); + }); + }); +}); + +function createStoredSplit(date: string): AssetProfileSplit { + const splitDate = new Date(date); + + return { + createdAt: splitDate, + date: splitDate, + denominator: 1, + id: `${date}-split`, + numerator: 2, + symbolProfileId: 'aapl-profile', + updatedAt: splitDate + }; +} diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts index ec842caaf..31dd7935c 100644 --- a/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts @@ -52,6 +52,33 @@ export class AssetProfileSplitService { }); } + /** + * Returns the splits of all asset profiles the given user has activities + * for, in ascending order by date + */ + public async getSplitsByUserId({ + userId + }: { + userId: string; + }): Promise { + return this.prismaService.assetProfileSplit.findMany({ + orderBy: [ + { + date: 'asc' + } + ], + where: { + symbolProfile: { + activities: { + some: { + userId + } + } + } + } + }); + } + public async upsert({ date, denominator, diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 29e4e5129..7f58448b7 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -16,6 +16,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { private static baseUrl = 'https://www.trackinsight.com'; private static countriesMapping = { + 'Czech Republic': 'CZ', 'Republic of Korea': 'KR', 'Russian Federation': 'RU', Turkey: 'TR', @@ -125,13 +126,14 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { for (const [name, value] of Object.entries( holdings?.countries ?? {} )) { - response.countries.push({ - code: getCountryCodeByName({ - name, - aliases: TrackinsightDataEnhancerService.countriesMapping - }), - weight: value.weight + const code = getCountryCodeByName({ + name, + aliases: TrackinsightDataEnhancerService.countriesMapping }); + + if (code) { + response.countries.push({ code, weight: value.weight }); + } } } diff --git a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts index 749f10c12..77e83b271 100644 --- a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts @@ -1,3 +1,4 @@ +import { getCountryCodeByName } from '@ghostfolio/api/helper/country.helper'; import { getSectorName } from '@ghostfolio/api/helper/sector.helper'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; @@ -18,12 +19,17 @@ import { SymbolProfile } from '@prisma/client'; import { isISIN } from 'class-validator'; -import { countries } from 'countries-list'; import YahooFinance from 'yahoo-finance2'; import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface'; @Injectable() export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { + private static countriesMapping = { + 'Czech Republic': 'CZ', + Macau: 'MO', + Turkey: 'TR' + }; + private static sectorsMapping: Record = { basic_materials: 'Basic Materials', communication_services: 'Communication Services', @@ -130,7 +136,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { response.url = url; } } catch (error) { - this.logger.error(error); + this.logger.error(error.message); } return response; @@ -246,15 +252,14 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { ) { // Add country if asset is stock and country available - try { - const [code] = Object.entries(countries).find(([, country]) => { - return country.name === assetProfile.summaryProfile?.country; - }); + const code = getCountryCodeByName({ + aliases: YahooFinanceDataEnhancerService.countriesMapping, + name: assetProfile.summaryProfile.country + }); - if (code) { - response.countries = [{ code, weight: 1 }]; - } - } catch {} + if (code) { + response.countries = [{ code, weight: 1 }]; + } if (assetProfile.summaryProfile?.sector) { response.sectors = [ @@ -276,7 +281,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { `No data found, ${aSymbol} (${this.getName()}) may be delisted` ); } else { - this.logger.error(error); + this.logger.error(error.message); } } 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 c5de3e4e5..8eca468cb 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -141,7 +141,7 @@ export class DataProviderService implements OnModuleInit { ); } } catch (error) { - this.logger.error(error); + this.logger.error(error.message); throw error; } @@ -340,7 +340,13 @@ export class DataProviderService implements OnModuleInit { from: Date; granularity: Granularity; to: Date; - } & AssetProfileIdentifier) { + } & AssetProfileIdentifier): Promise<{ + [date: string]: DataProviderHistoricalResponse; + }> { + if (!isValid(from) || !isValid(to)) { + return {}; + } + return this.getDataProvider(DataSource[dataSource]).getDividends({ from, granularity, @@ -421,7 +427,7 @@ export class DataProviderService implements OnModuleInit { return r; }, {}); } catch (error) { - this.logger.error(error); + this.logger.error(error.message); } finally { return response; } @@ -542,7 +548,7 @@ export class DataProviderService implements OnModuleInit { result[getAssetProfileIdentifier({ dataSource, symbol })] = data; } } catch (error) { - this.logger.error(error); + this.logger.error(error.message); throw error; } 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 f7f2e7eb9..2fd101396 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 @@ -54,9 +54,11 @@ export class FinancialModelingPrepService implements DataProviderInterface, OnModuleInit { private static countriesMapping = { + 'Czech Republic': 'CZ', 'Korea (the Republic of)': 'KR', 'Russian Federation': 'RU', - 'Taiwan (Province of China)': 'TW' + 'Taiwan (Province of China)': 'TW', + Turkey: 'TR' }; private readonly logger = new Logger(FinancialModelingPrepService.name); @@ -169,9 +171,6 @@ export class FinancialModelingPrepService .then((res) => res.json()); response.countries = etfCountryWeightings - .filter(({ country: countryName }) => { - return countryName.toLowerCase() !== 'other'; - }) .map(({ country: countryName, weightPercentage }) => { return { code: getCountryCodeByName({ @@ -180,6 +179,9 @@ export class FinancialModelingPrepService }), weight: parseFloat(`${weightPercentage}`) / 100 }; + }) + .filter(({ code }) => { + return !!code; }); const etfHoldings = await this.fetchService diff --git a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts index 75fae673e..055b2364a 100644 --- a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts +++ b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts @@ -144,7 +144,7 @@ export class GoogleSheetsService implements DataProviderInterface { return response; } catch (error) { - this.logger.error(error); + this.logger.error(error.message); } return {}; diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts index a8cfa8b0b..1d09c91fe 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -215,7 +215,7 @@ export class ManualService implements DataProviderInterface { return response; } catch (error) { - this.logger.error(error); + this.logger.error(error.message); } return {}; 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 9af22b79a..f14bfec14 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 @@ -113,7 +113,7 @@ export class RapidApiService implements DataProviderInterface { } } } catch (error) { - this.logger.error(error); + this.logger.error(error.message); } return {}; diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts index 364354f6b..9f113b9e5 100644 --- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts @@ -22,6 +22,7 @@ import { import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; import { addDays, format, isSameDay } from 'date-fns'; +import { ReasonPhrases, StatusCodes } from 'http-status-codes'; import { uniqBy } from 'lodash'; import YahooFinance from 'yahoo-finance2'; import { ChartResultArray } from 'yahoo-finance2/esm/src/modules/chart'; @@ -41,6 +42,12 @@ import { SearchQuoteNonYahoo } from 'yahoo-finance2/esm/src/modules/search'; @Injectable() export class YahooFinanceService implements DataProviderInterface { + private static readonly DELISTED_ERROR_MESSAGE = + 'No data found, symbol may be delisted'; + + private static readonly RATE_LIMIT_ERROR_MESSAGE = + ReasonPhrases.TOO_MANY_REQUESTS; + private readonly logger = new Logger(YahooFinanceService.name); private readonly yahooFinance = new YahooFinance({ @@ -77,10 +84,6 @@ export class YahooFinanceService implements DataProviderInterface { symbol, to }: GetDividendsParams) { - if (isSameDay(from, to)) { - to = addDays(to, 1); - } - try { const historicalResult = this.convertToDividendResult( await this.yahooFinance.chart( @@ -91,7 +94,10 @@ export class YahooFinanceService implements DataProviderInterface { events: 'dividends', interval: granularity === 'month' ? '1mo' : '1d', period1: format(from, DATE_FORMAT), - period2: format(to, DATE_FORMAT) + period2: format( + isSameDay(from, to) ? addDays(to, 1) : to, + DATE_FORMAT + ) } ) ); @@ -107,12 +113,26 @@ export class YahooFinanceService implements DataProviderInterface { return response; } catch (error) { - this.logger.error( - `Could not get dividends for ${symbol} (${this.getName()}) from ${format( - from, - DATE_FORMAT - )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` - ); + const message = `Could not get dividends for ${symbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}`; + + if (error?.message === YahooFinanceService.DELISTED_ERROR_MESSAGE) { + this.logger.warn( + `${message}: ${YahooFinanceService.DELISTED_ERROR_MESSAGE}` + ); + } else if ( + (error?.name === 'HTTPError' && + error?.code === StatusCodes.TOO_MANY_REQUESTS) || + error?.message?.startsWith(YahooFinanceService.RATE_LIMIT_ERROR_MESSAGE) + ) { + this.logger.warn( + `${message}: ${YahooFinanceService.RATE_LIMIT_ERROR_MESSAGE}` + ); + } else { + this.logger.error(`${message}: [${error?.name}] ${error?.message}`); + } return {}; } @@ -125,10 +145,6 @@ export class YahooFinanceService implements DataProviderInterface { }: GetHistoricalParams): Promise<{ [date: string]: DataProviderHistoricalResponse; }> { - if (isSameDay(from, to)) { - to = addDays(to, 1); - } - try { const historicalResult = this.convertToHistoricalResult( await this.yahooFinance.chart( @@ -138,7 +154,10 @@ export class YahooFinanceService implements DataProviderInterface { { interval: '1d', period1: format(from, DATE_FORMAT), - period2: format(to, DATE_FORMAT) + period2: format( + isSameDay(from, to) ? addDays(to, 1) : to, + DATE_FORMAT + ) } ) ); @@ -155,7 +174,7 @@ export class YahooFinanceService implements DataProviderInterface { return response; } catch (error) { - if (error.message === 'No data found, symbol may be delisted') { + if (error?.message === YahooFinanceService.DELISTED_ERROR_MESSAGE) { throw new AssetProfileDelistedError( `No data found, ${symbol} (${this.getName()}) may be delisted` ); @@ -164,7 +183,7 @@ export class YahooFinanceService implements DataProviderInterface { `Could not get historical market data for ${symbol} (${this.getName()}) from ${format( from, DATE_FORMAT - )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` + )} to ${format(to, DATE_FORMAT)}: [${error?.name}] ${error?.message}` ); } } @@ -197,7 +216,7 @@ export class YahooFinanceService implements DataProviderInterface { try { quotes = await this.yahooFinance.quote(yahooFinanceSymbols); } catch (error) { - this.logger.error(error); + this.logger.error(error.message); this.logger.warn('Fallback to yahooFinance.quoteSummary()'); @@ -225,7 +244,7 @@ export class YahooFinanceService implements DataProviderInterface { return response; } catch (error) { - this.logger.error(error); + this.logger.error(error.message); return {}; } @@ -333,7 +352,7 @@ export class YahooFinanceService implements DataProviderInterface { if (error?.name === 'BadRequestError') { this.logger.warn(`Could not search for "${query}": ${error.message}`); } else { - this.logger.error(error); + this.logger.error(error.message); } } @@ -343,9 +362,11 @@ export class YahooFinanceService implements DataProviderInterface { private convertToDividendResult( result: ChartResultArray ): HistoricalDividendsResult { - return result.events.dividends.map(({ amount: dividends, date }) => { - return { date, dividends }; - }); + return (result.events?.dividends ?? []).map( + ({ amount: dividends, date }) => { + return { date, dividends }; + } + ); } private convertToHistoricalResult( diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 3b48ce292..375a40173 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -261,8 +261,6 @@ export class ExchangeRateDataService { const factor2 = this.exchangeRates[`${DEFAULT_CURRENCY}${aToCurrency}`]; factor = factor1 * factor2; - - this.exchangeRates[`${aFromCurrency}${aToCurrency}`] = factor; } } diff --git a/apps/api/src/services/i18n/i18n.service.ts b/apps/api/src/services/i18n/i18n.service.ts index 65c51b2f0..83eb3a3ac 100644 --- a/apps/api/src/services/i18n/i18n.service.ts +++ b/apps/api/src/services/i18n/i18n.service.ts @@ -45,7 +45,10 @@ export class I18nService implements OnModuleInit { if (placeholders) { for (const [key, value] of Object.entries(placeholders)) { - translatedText = translatedText.replace(`\${${key}}`, String(value)); + translatedText = translatedText.replace( + new RegExp(`\\$\\{${key}\\}`, 'g'), + String(value) + ); } } diff --git a/apps/api/src/services/impersonation/impersonation.service.ts b/apps/api/src/services/impersonation/impersonation.service.ts index 71c543a43..1432c14aa 100644 --- a/apps/api/src/services/impersonation/impersonation.service.ts +++ b/apps/api/src/services/impersonation/impersonation.service.ts @@ -1,50 +1,110 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { UserSettings } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import { + getScopesOfAccess, + getScopesOfOwnAccess, + getScopesOfUnrestrictedImpersonation +} from '@ghostfolio/common/scopes'; +import type { + ImpersonationContext, + UserWithSettings +} from '@ghostfolio/common/types'; -import { Inject, Injectable } from '@nestjs/common'; -import { REQUEST } from '@nestjs/core'; +import { Injectable } from '@nestjs/common'; +import { Access } from '@prisma/client'; @Injectable() export class ImpersonationService { - public constructor( - private readonly prismaService: PrismaService, - @Inject(REQUEST) private readonly request: RequestWithUser - ) {} + public constructor(private readonly prismaService: PrismaService) {} - public async validateImpersonationId(aId = '') { - if (this.request.user) { + public async resolve({ + impersonationId, + user + }: { + impersonationId?: string; + user?: UserWithSettings; + }): Promise { + const { access, userId: impersonatedUserId } = + await this.validateImpersonation({ impersonationId, user }); + + if (!impersonatedUserId) { + return { + isActive: false, + scopes: getScopesOfOwnAccess(), + userId: user?.id, + userSettings: user?.settings?.settings ?? {} + }; + } + + const settings = await this.prismaService.settings.findUnique({ + where: { userId: impersonatedUserId } + }); + + return { + accessId: impersonationId, + isActive: true, + // An access which has not been granted explicitly originates from the + // permission to impersonate all users + scopes: access + ? getScopesOfAccess(access) + : getScopesOfUnrestrictedImpersonation(), + userId: impersonatedUserId, + userSettings: { + ...((settings?.settings ?? {}) as UserSettings), + baseCurrency: + (settings?.settings as UserSettings)?.baseCurrency ?? DEFAULT_CURRENCY + } + }; + } + + private async validateImpersonation({ + impersonationId, + user + }: { + impersonationId?: string; + user?: UserWithSettings; + }): Promise<{ access?: Access; userId: string | null }> { + if (!impersonationId) { + return { userId: null }; + } + + if (user) { const accessObject = await this.prismaService.access.findFirst({ where: { - granteeUserId: this.request.user.id, - id: aId + granteeUserId: user.id, + id: impersonationId } }); if (accessObject?.userId) { - return accessObject.userId; + return { access: accessObject, userId: accessObject.userId }; } else if ( - hasPermission( - this.request.user.permissions, - permissions.impersonateAllUsers - ) + hasPermission(user.permissions, permissions.impersonateAllUsers) ) { - return aId; + // The identifier is a user id in this case, hence verify its existence + const impersonatedUser = await this.prismaService.user.findUnique({ + select: { id: true }, + where: { id: impersonationId } + }); + + return { userId: impersonatedUser?.id ?? null }; } } else { // Public access const accessObject = await this.prismaService.access.findFirst({ where: { granteeUserId: null, - user: { id: aId } + user: { id: impersonationId } } }); if (accessObject?.userId) { - return accessObject.userId; + return { access: accessObject, userId: accessObject.userId }; } } - return null; + return { userId: null }; } } diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts b/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts index 8b7e3489f..008603fa8 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts @@ -85,7 +85,7 @@ export class DataGatheringProcessor { return job.discard(); } - this.logger.error(error); + this.logger.error(error.message); throw error; } @@ -198,7 +198,7 @@ export class DataGatheringProcessor { return job.discard(); } - this.logger.error(error); + this.logger.error(error.message); throw error; } diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index 1a2fa720e..17906a333 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -29,7 +29,7 @@ import { import { InjectQueue } from '@nestjs/bull'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { JobOptions, Queue } from 'bull'; +import { Job, JobOptions, Queue } from 'bull'; import { format, min, subDays, subMilliseconds, subYears } from 'date-fns'; import { isEmpty } from 'lodash'; import ms, { StringValue } from 'ms'; @@ -285,7 +285,7 @@ export class DataGatheringService { date: date ?? item.date })); - await this.gatherSymbols({ + return this.gatherSymbols({ dataGatheringItems, force: true, priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH @@ -322,7 +322,7 @@ export class DataGatheringService { }); } } catch (error) { - this.logger.error(error); + this.logger.error(error.message); } finally { return undefined; } @@ -336,8 +336,8 @@ export class DataGatheringService { dataGatheringItems: DataGatheringItem[]; force?: boolean; priority: number; - }) { - await this.addJobsToQueue( + }): Promise { + return this.addJobsToQueue( dataGatheringItems.map(({ dataSource, date, symbol }) => { return { data: { diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts index 2ade39a8a..06b87f733 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts @@ -101,7 +101,7 @@ export class PortfolioSnapshotProcessor { return snapshot; } catch (error) { - this.logger.error(error); + this.logger.error(error.message); throw new Error(error); } diff --git a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts index 21d009805..d9aa34c4b 100644 --- a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts +++ b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts @@ -143,7 +143,7 @@ export class StatisticsGatheringProcessor { return pull_count; } catch (error) { - this.logger.error(error); + this.logger.error(error.message); throw error; } @@ -173,7 +173,7 @@ export class StatisticsGatheringProcessor { value }); } catch (error) { - this.logger.error(error); + this.logger.error(error.message); throw error; } @@ -192,7 +192,7 @@ export class StatisticsGatheringProcessor { return stargazers_count; } catch (error) { - this.logger.error(error); + this.logger.error(error.message); throw error; } @@ -221,7 +221,7 @@ export class StatisticsGatheringProcessor { return data.attributes.availability / 100; } catch (error) { - this.logger.error(error); + this.logger.error(error.message); throw error; } diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index de052f9a1..0f23b03ea 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -1,4 +1,5 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { TAG_ID_DRAFT } from '@ghostfolio/common/config'; import { HttpException, Injectable } from '@nestjs/common'; import { Prisma, Tag } from '@prisma/client'; @@ -9,9 +10,13 @@ export class TagService { public constructor(private readonly prismaService: PrismaService) {} public async createTag(data: Prisma.TagCreateInput) { - return this.prismaService.tag.create({ - data - }); + try { + return await this.prismaService.tag.create({ + data + }); + } catch (error) { + throw this.getExceptionForError(error); + } } public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise { @@ -120,10 +125,14 @@ export class TagService { data: Prisma.TagUpdateInput; where: Prisma.TagWhereUniqueInput; }): Promise { - return this.prismaService.tag.update({ - data, - where - }); + try { + return await this.prismaService.tag.update({ + data, + where + }); + } catch (error) { + throw this.getExceptionForError(error); + } } public async validateTagIds({ @@ -160,4 +169,37 @@ export class TagService { ); } } + + public async validateTagIdsWithoutDraftTag({ + tagIds, + userId + }: { + tagIds: string[]; + userId: string; + }) { + // The "DRAFT" tag qualifies an individual activity and can therefore + // neither be assigned to an account nor to all activities of a holding + if (tagIds?.includes(TAG_ID_DRAFT)) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + return this.validateTagIds({ tagIds, userId }); + } + + private getExceptionForError(error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + return new HttpException( + getReasonPhrase(StatusCodes.CONFLICT), + StatusCodes.CONFLICT + ); + } + + return error; + } } diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html index 9d87cbd79..77da69d23 100644 --- a/apps/client/src/app/components/access-table/access-table.component.html +++ b/apps/client/src/app/components/access-table/access-table.component.html @@ -18,10 +18,10 @@ Permission
- @if (element.permissions.includes('READ')) { + @if (hasScopeToReadValues(element)) { View - } @else if (element.permissions.includes('READ_RESTRICTED')) { + } @else { Restricted view } diff --git a/apps/client/src/app/components/access-table/access-table.component.ts b/apps/client/src/app/components/access-table/access-table.component.ts index 00c5c1a28..3886fc59c 100644 --- a/apps/client/src/app/components/access-table/access-table.component.ts +++ b/apps/client/src/app/components/access-table/access-table.component.ts @@ -1,6 +1,7 @@ import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { Access, User } from '@ghostfolio/common/interfaces'; import { publicRoutes } from '@ghostfolio/common/routes/routes'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; @@ -100,6 +101,10 @@ export class GfAccessTableComponent { return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`; } + protected hasScopeToReadValues({ scopes: scopesOfAccess }: Access) { + return hasScope(scopesOfAccess, scopes.portfolioReadValues); + } + protected onCopyUrlToClipboard(aId: string) { this.clipboard.copy(this.getPublicUrl(aId)); 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 a0350ee6b..518458e8a 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 @@ -14,11 +14,8 @@ import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; -import { - hasPermission, - hasReadRestrictedAccessPermission, - permissions -} from '@ghostfolio/common/permissions'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; @@ -176,6 +173,8 @@ export class GfAccountDetailDialogComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.initialize(); + + this.refreshUser(); }); } @@ -195,6 +194,8 @@ export class GfAccountDetailDialogComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.initialize(); + + this.refreshUser(); }); } @@ -229,10 +230,8 @@ export class GfAccountDetailDialogComponent implements OnInit { protected showValuesInPercentage() { return ( - hasReadRestrictedAccessPermission({ - accesses: this.user?.access, - impersonationId: this.data.impersonationId - }) || this.user?.settings?.isRestrictedView + !hasScope(this.user?.scopes, scopes.portfolioReadValues) || + this.user?.settings?.isRestrictedView ); } @@ -413,4 +412,11 @@ export class GfAccountDetailDialogComponent implements OnInit { this.fetchChart(); this.fetchPortfolioHoldings(); } + + private refreshUser() { + this.userService + .get(true) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(); + } } 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 cb3246c00..1ca0a27dc 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 @@ -160,7 +160,6 @@ [showActions]=" data.hasPermissionToCreateActivity && !data.impersonationId && - user?.settings?.isExperimentalFeatures && !user?.settings?.isRestrictedView " [sortColumn]="sortColumn" diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index 8fc012488..314fd21b5 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -848,13 +848,12 @@ export class GfAssetProfileDialogComponent implements OnInit { takeUntilDestroyed(this.destroyRef) ) .subscribe(({ price }) => { + const currency = this.assetProfileForm.controls.currency.value; + this.notificationService.alert({ - title: - $localize`The current market price is` + - ' ' + - price + - ' ' + - this.assetProfileForm.controls.currency.value + title: `${$localize`The current market price is`} ${price}${ + currency ? ` ${currency}` : '' + }` }); }); } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index bcda048f8..b59b7e39f 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -511,9 +511,7 @@
- - @if (false && user?.settings?.isExperimentalFeatures) { + @if (user?.settings?.isExperimentalFeatures) { diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss index e02c91e3d..4dc8cf6b5 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss @@ -1,6 +1,15 @@ +@use '@angular/material' as mat; + :host { display: block; + @include mat.form-field-overrides( + ( + container-height: 2rem, + container-vertical-padding: 0.33rem + ) + ); + .chart-container { aspect-ratio: 16 / 9; diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 35f072d72..eaccbb332 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -217,7 +217,7 @@ Me - @for (accessItem of user()?.access; track accessItem) { + @for (accessItem of user()?.access; track accessItem.id) { - - +