Browse Source

Merge branch 'main' into feature/add-zh-tw-locale

pull/7565/head
judragon003 1 day ago
committed by GitHub
parent
commit
ce2772c2a8
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 163
      CHANGELOG.md
  2. 22
      apps/api/src/app/access/access.controller.ts
  3. 46
      apps/api/src/app/account/account.controller.ts
  4. 22
      apps/api/src/app/account/account.service.ts
  5. 55
      apps/api/src/app/activities/activities.controller.ts
  6. 2
      apps/api/src/app/activities/activities.module.ts
  7. 337
      apps/api/src/app/activities/activities.service.spec.ts
  8. 492
      apps/api/src/app/activities/activities.service.ts
  9. 2
      apps/api/src/app/admin/admin.controller.ts
  10. 2
      apps/api/src/app/admin/queue/queue.controller.ts
  11. 7
      apps/api/src/app/app.module.ts
  12. 2
      apps/api/src/app/auth-device/auth-device.controller.ts
  13. 18
      apps/api/src/app/auth/api-key.strategy.ts
  14. 11
      apps/api/src/app/auth/auth.controller.ts
  15. 21
      apps/api/src/app/auth/auth.module.ts
  16. 31
      apps/api/src/app/auth/oidc-state.store.ts
  17. 14
      apps/api/src/app/auth/oidc.strategy.ts
  18. 9
      apps/api/src/app/auth/web-auth.service.ts
  19. 2
      apps/api/src/app/cache/cache.controller.ts
  20. 1
      apps/api/src/app/endpoints/ai/ai.controller.ts
  21. 3
      apps/api/src/app/endpoints/ai/ai.service.ts
  22. 2
      apps/api/src/app/endpoints/api-keys/api-keys.controller.ts
  23. 9
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  24. 179
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts
  25. 56
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  26. 24
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  27. 17
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  28. 12
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  29. 2
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  30. 2
      apps/api/src/app/endpoints/public/public.controller.ts
  31. 2
      apps/api/src/app/endpoints/tags/tags.controller.ts
  32. 23
      apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
  33. 230
      apps/api/src/app/import/import.service.ts
  34. 2
      apps/api/src/app/platform/platform.controller.ts
  35. 1
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  36. 4
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  37. 276
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts
  38. 59
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  39. 123
      apps/api/src/app/portfolio/portfolio.controller.ts
  40. 177
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  41. 257
      apps/api/src/app/portfolio/portfolio.service.ts
  42. 2
      apps/api/src/app/subscription/subscription.controller.ts
  43. 2
      apps/api/src/app/symbol/symbol.service.ts
  44. 45
      apps/api/src/app/user/user.controller.ts
  45. 60
      apps/api/src/app/user/user.service.ts
  46. 127
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  47. 12
      apps/api/src/decorators/allow-during-impersonation.decorator.ts
  48. 28
      apps/api/src/decorators/impersonation.decorator.ts
  49. 41
      apps/api/src/decorators/requires-scope.decorator.spec.ts
  50. 26
      apps/api/src/decorators/requires-scope.decorator.ts
  51. 53
      apps/api/src/guards/impersonation-write.guard.ts
  52. 25
      apps/api/src/guards/impersonation.guard.ts
  53. 21
      apps/api/src/guards/oauth-callback.guard.ts
  54. 50
      apps/api/src/guards/scope.guard.ts
  55. 74
      apps/api/src/helper/activity.helper.ts
  56. 7
      apps/api/src/helper/country.helper.ts
  57. 29
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  58. 110
      apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts
  59. 114
      apps/api/src/services/asset-profile-split/asset-profile-split.helper.spec.ts
  60. 58
      apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts
  61. 161
      apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts
  62. 27
      apps/api/src/services/asset-profile-split/asset-profile-split.service.ts
  63. 10
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  64. 19
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  65. 14
      apps/api/src/services/data-provider/data-provider.service.ts
  66. 10
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  67. 2
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  68. 2
      apps/api/src/services/data-provider/manual/manual.service.ts
  69. 2
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  70. 61
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  71. 2
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  72. 5
      apps/api/src/services/i18n/i18n.service.ts
  73. 100
      apps/api/src/services/impersonation/impersonation.service.ts
  74. 4
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  75. 10
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  76. 2
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  77. 8
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  78. 46
      apps/api/src/services/tag/tag.service.ts
  79. 4
      apps/client/src/app/components/access-table/access-table.component.html
  80. 5
      apps/client/src/app/components/access-table/access-table.component.ts
  81. 24
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  82. 1
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  83. 11
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  84. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  85. 9
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss
  86. 2
      apps/client/src/app/components/header/header.component.html
  87. 11
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  88. 2
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  89. 62
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  90. 27
      apps/client/src/app/components/home-holdings/home-holdings.html
  91. 6
      apps/client/src/app/components/home-holdings/home-holdings.scss
  92. 11
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  93. 14
      apps/client/src/app/components/markets/markets.component.ts
  94. 2
      apps/client/src/app/components/markets/markets.html
  95. 3
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts
  96. 7
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  97. 58
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  98. 37
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  99. 18
      apps/client/src/app/components/user-account-settings/user-account-settings.scss
  100. 296
      apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts

163
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/), 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). 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 ## 3.44.0 - 2026-08-07
### Added ### Added

22
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; 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 { SubscriptionType } from '@ghostfolio/common/enums';
import { Access, AccessSettings } from '@ghostfolio/common/interfaces'; import { Access, AccessSettings } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { getScopesOfAccess } from '@ghostfolio/common/scopes';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -26,6 +28,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
@AllowDuringImpersonation()
@Controller('access') @Controller('access')
export class AccessController { export class AccessController {
public constructor( public constructor(
@ -45,13 +48,16 @@ export class AccessController {
where: { userId: this.request.user.id } where: { userId: this.request.user.id }
}); });
return accessesWithGranteeUser.map( return accessesWithGranteeUser.map((accessItem) => {
({ alias, granteeUser, id, permissions, settings }) => { const { alias, granteeUser, id, permissions, settings } = accessItem;
const scopes = getScopesOfAccess(accessItem);
if (granteeUser) { if (granteeUser) {
return { return {
alias, alias,
id, id,
permissions, permissions,
scopes,
grantee: granteeUser?.id, grantee: granteeUser?.id,
settings: settings as AccessSettings, settings: settings as AccessSettings,
type: 'PRIVATE' type: 'PRIVATE'
@ -62,12 +68,12 @@ export class AccessController {
alias, alias,
id, id,
permissions, permissions,
scopes,
grantee: 'Public', grantee: 'Public',
settings: settings as AccessSettings, settings: settings as AccessSettings,
type: 'PUBLIC' type: 'PUBLIC'
}; };
} });
);
} }
@HasPermission(permissions.createAccess) @HasPermission(permissions.createAccess)
@ -93,6 +99,10 @@ export class AccessController {
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: undefined, : undefined,
permissions: data.permissions, permissions: data.permissions,
scopes: getScopesOfAccess({
granteeUserId: data.granteeUserId,
permissions: data.permissions
}),
settings: this.accessService.buildSettings(data.filters), settings: this.accessService.buildSettings(data.filters),
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}); });
@ -162,6 +172,10 @@ export class AccessController {
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: { disconnect: true }, : { disconnect: true },
permissions: data.permissions, permissions: data.permissions,
scopes: getScopesOfAccess({
granteeUserId: data.granteeUserId,
permissions: data.permissions ?? originalAccess.permissions
}),
settings: this.accessService.buildSettings(data.filters) settings: this.accessService.buildSettings(data.filters)
}, },
where: { id } where: { id }

46
apps/api/src/app/account/account.controller.ts

@ -1,13 +1,12 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.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 { 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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; 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 { 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 { 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 { import {
CreateAccountDto, CreateAccountDto,
TransferBalanceDto, TransferBalanceDto,
@ -19,14 +18,17 @@ import {
AccountsResponse AccountsResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; 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 { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -49,10 +51,8 @@ export class AccountController {
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser
private readonly userService: UserService
) {} ) {}
@Delete(':id') @Delete(':id')
@ -85,18 +85,15 @@ export class AccountController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @RequiresScope(scopes.accountRead)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts( public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId }: ImpersonationContext,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('query') filterBySearchQuery?: string, @Query('query') filterBySearchQuery?: string,
@Query('symbol') filterBySymbol?: string @Query('symbol') filterBySymbol?: string
): Promise<AccountsResponse> { ): Promise<AccountsResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByDataSource, filterByDataSource,
filterBySearchQuery, filterBySearchQuery,
@ -105,25 +102,22 @@ export class AccountController {
return this.portfolioService.getAccountsWithAggregations({ return this.portfolioService.getAccountsWithAggregations({
filters, filters,
userId: impersonationUserId || this.request.user.id, userId,
withExcludedAccounts: true withExcludedAccounts: true
}); });
} }
@Get(':id') @Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @RequiresScope(scopes.accountRead)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById( public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId }: ImpersonationContext,
@Param('id') id: string @Param('id') id: string
): Promise<AccountResponse> { ): Promise<AccountResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const accountsWithAggregations = const accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations({ await this.portfolioService.getAccountsWithAggregations({
userId,
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true withExcludedAccounts: true
}); });
@ -131,22 +125,16 @@ export class AccountController {
} }
@Get(':id/balances') @Get(':id/balances')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @RequiresScope(scopes.accountRead)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById( public async getAccountBalancesById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId, userSettings }: ImpersonationContext,
@Param('id') id: string @Param('id') id: string
): Promise<AccountBalancesResponse> { ): Promise<AccountBalancesResponse> {
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({ return this.accountBalanceService.getAccountBalances({
userId, userId,
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
userCurrency: settings.settings.baseCurrency userCurrency: userSettings.baseCurrency
}); });
} }

22
apps/api/src/app/account/account.service.ts

@ -89,7 +89,10 @@ export class AccountService {
orderBy?: Prisma.AccountOrderByWithRelationInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(AccountWithBalance & { (AccountWithBalance & {
activities?: (Order & { SymbolProfile?: SymbolProfile })[]; activities?: (Order & {
SymbolProfile?: SymbolProfile;
tags?: Pick<Tag, 'id'>[];
})[];
balances?: AccountBalance[]; balances?: AccountBalance[];
platform?: Platform; platform?: Platform;
tags?: Tag[]; tags?: Tag[];
@ -172,7 +175,7 @@ export class AccountService {
tagIds?: string[]; tagIds?: string[];
userId: string; userId: string;
}): Promise<Account> { }): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId }); await this.tagService.validateTagIdsWithoutDraftTag({ tagIds, userId });
const account = await this.prismaService.account.create({ const account = await this.prismaService.account.create({
data: { data: {
@ -237,15 +240,10 @@ export class AccountService {
}); });
return accounts.map((account) => { return accounts.map((account) => {
let activitiesCount = 0; const result = {
...account,
for (const { isDraft } of account.activities) { activitiesCount: account.activities.length
if (!isDraft) { };
activitiesCount += 1;
}
}
const result = { ...account, activitiesCount };
delete result.activities; delete result.activities;
@ -317,7 +315,7 @@ export class AccountService {
userId: string; userId: string;
where: Prisma.AccountWhereUniqueInput; where: Prisma.AccountWhereUniqueInput;
}): Promise<Account> { }): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId }); await this.tagService.validateTagIdsWithoutDraftTag({ tagIds, userId });
const account = await this.prismaService.account.update({ const account = await this.prismaService.account.update({
data: { data: {

55
apps/api/src/app/activities/activities.controller.ts

@ -1,31 +1,33 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.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 { 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 { 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 { 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 { 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { import {
ActivitiesResponse, ActivitiesResponse,
ActivityResponse ActivityResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; 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 { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -52,7 +54,6 @@ export class ActivitiesController {
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -61,7 +62,6 @@ export class ActivitiesController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteActivities( public async deleteActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query() @Query()
{ {
accounts, accounts,
@ -73,13 +73,6 @@ export class ActivitiesController {
tags tags
}: ActivitiesFilterDto }: ActivitiesFilterDto
): Promise<number> { ): Promise<number> {
if (impersonationId) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let endDate: Date; let endDate: Date;
let startDate: Date; let startDate: Date;
@ -128,12 +121,12 @@ export class ActivitiesController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @RequiresScope(scopes.activityRead)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllActivities( public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId, userSettings }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -166,11 +159,6 @@ export class ActivitiesController {
filterByTags: tags filterByTags: tags
}); });
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.activitiesService.getActivities({ const { activities, count } = await this.activitiesService.getActivities({
endDate, endDate,
filters, filters,
@ -179,10 +167,10 @@ export class ActivitiesController {
sortDirection, sortDirection,
startDate, startDate,
take, take,
userCurrency, userId,
includeDrafts: true, includeDrafts: true,
types: activityTypes, types: activityTypes,
userId: impersonationUserId || this.request.user.id, userCurrency: userSettings.baseCurrency,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
@ -190,21 +178,17 @@ export class ActivitiesController {
} }
@Get(':id') @Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @RequiresScope(scopes.activityRead)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getActivityById( public async getActivityById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId, userSettings }: ImpersonationContext,
@Param('id') id: string @Param('id') id: string
): Promise<ActivityResponse> { ): Promise<ActivityResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities } = await this.activitiesService.getActivities({ const { activities } = await this.activitiesService.getActivities({
userCurrency, userId,
includeDrafts: true, includeDrafts: true,
userId: impersonationUserId || this.request.user.id, userCurrency: userSettings.baseCurrency,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
@ -287,9 +271,9 @@ export class ActivitiesController {
userId: this.request.user.id 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 // 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({ this.dataGatheringService.gatherSymbols({
dataGatheringItems: [ dataGatheringItems: [
{ {
@ -369,6 +353,7 @@ export class ActivitiesController {
}), }),
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
originalDate: originalActivity.date,
userId: this.request.user.id, userId: this.request.user.id,
where: { where: {
id id

2
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 { 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 { 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 { 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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -26,6 +27,7 @@ import { ActivitiesService } from './activities.service';
exports: [ActivitiesService], exports: [ActivitiesService],
imports: [ imports: [
ApiModule, ApiModule,
AssetProfileSplitModule,
BenchmarkModule, BenchmarkModule,
CacheModule, CacheModule,
DataGatheringQueueModule, DataGatheringQueueModule,

337
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
};
}

492
apps/api/src/app/activities/activities.service.ts

@ -7,7 +7,15 @@ import {
isAccountBalanceInFuture, isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper'; } 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 { 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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { 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_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
NON_INVESTMENT_ACTIVITY_TYPES, NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
getAssetProfileIdentifier, getAssetProfileIdentifier,
isDraftActivity,
isValidCustomAssetProfileSymbol isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -49,7 +59,7 @@ import {
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -58,6 +68,7 @@ export class ActivitiesService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly assetProfileSplitService: AssetProfileSplitService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -112,7 +123,7 @@ export class ActivitiesService {
tags, tags,
userId userId
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
await this.tagService.validateTagIds({ await this.tagService.validateTagIdsWithoutDraftTag({
userId, userId,
tagIds: tags.map(({ id }) => { tagIds: tags.map(({ id }) => {
return id; return id;
@ -120,6 +131,7 @@ export class ActivitiesService {
}); });
const activities = await this.prismaService.order.findMany({ const activities = await this.prismaService.order.findMany({
include: { tags: { select: { id: true } } },
where: { where: {
userId, userId,
SymbolProfile: { SymbolProfile: {
@ -129,20 +141,27 @@ export class ActivitiesService {
} }
}); });
const tagsToAssign = tags.map(({ id }) => {
return { id };
});
await Promise.all( await Promise.all(
activities.map(({ id }) => activities.map((activity) => {
this.prismaService.order.update({ // 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: { data: {
tags: { tags: {
// The set operation replaces all existing connections with the provided ones set: tagsToSet
set: tags.map((tag) => {
return { id: tag.id };
})
} }
}, },
where: { id } where: { id: activity.id }
});
}) })
)
); );
this.eventEmitter.emit( this.eventEmitter.emit(
@ -261,17 +280,19 @@ export class ActivitiesService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) const tagsToConnect = getTagsWithDraftTag({
? false tags,
: isAfter(data.date as Date, endOfToday()); date: data.date as Date,
draftTag: { id: TAG_ID_DRAFT },
type: data.type
});
const activity = await this.prismaService.order.create({ const activity = await this.prismaService.order.create({
data: { data: {
...orderData, ...orderData,
account, account,
isDraft,
tags: { tags: {
connect: tags connect: tagsToConnect
} }
}, },
include: { SymbolProfile: true } include: { SymbolProfile: true }
@ -290,7 +311,7 @@ export class ActivitiesService {
accountId, accountId,
userId, userId,
amount: amount.toNumber(), amount: amount.toNumber(),
currency: data.SymbolProfile.connectOrCreate.create.currency, currency: activity.currency ?? activity.SymbolProfile.currency,
date: data.date as Date date: data.date as Date
}); });
} }
@ -372,36 +393,32 @@ export class ActivitiesService {
types?: ActivityType[]; types?: ActivityType[];
userId: string; userId: string;
}): Promise<number> { }): Promise<number> {
const { activities } = await this.getActivities({ const where = this.getWhereClause({
endDate, endDate,
filters, filters,
startDate, startDate,
types, types,
userId, userId,
includeDrafts: true, includeDrafts: true,
userCurrency: undefined,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
const { count } = await this.prismaService.order.deleteMany({ const activities = await this.prismaService.order.findMany({
where: { where,
id: { distinct: ['symbolProfileId'],
in: activities.map(({ id }) => { select: { symbolProfileId: true }
return id;
})
}
}
}); });
const symbolProfiles = const { count } = await this.prismaService.order.deleteMany({ where });
await this.symbolProfileService.getSymbolProfilesByIds(
const [benchmarkAssetProfiles, symbolProfiles] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.symbolProfileService.getSymbolProfilesByIds(
activities.map(({ symbolProfileId }) => { activities.map(({ symbolProfileId }) => {
return symbolProfileId; return symbolProfileId;
}) })
); )
]);
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
for (const { for (const {
activitiesCount, activitiesCount,
@ -518,7 +535,6 @@ export class ActivitiesService {
feeInAssetProfileCurrency: 0, feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0, feeInBaseCurrency: 0,
id: balanceItem.id, id: balanceItem.id,
isDraft: false,
quantity: 1, quantity: 1,
symbolProfileId: account.currency, symbolProfileId: account.currency,
type: ActivityType.BUY, type: ActivityType.BUY,
@ -607,160 +623,19 @@ export class ActivitiesService {
{ date: 'asc' } { 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) { if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
} }
if (types?.length > 0) { const where = this.getWhereClause({
where.type = { in: types }; endDate,
} filters,
includeDrafts,
if (withExcludedAccountsAndActivities === false) { startDate,
where.OR = [{ account: null }, { account: WHERE_ACCOUNT_NOT_EXCLUDED }]; types,
userId,
where.tags = { withExcludedAccountsAndActivities
none: { });
id: TAG_ID_EXCLUDE_FROM_ANALYSIS
}
};
}
const [orders, count] = await Promise.all([ const [orders, count] = await Promise.all([
this.orders({ this.orders({
@ -894,12 +769,26 @@ export class ActivitiesService {
/** Whether to include cash activities in the result. */ /** Whether to include cash activities in the result. */
withCash?: boolean; withCash?: boolean;
}) { }) {
const activities = await this.getActivities({ const [activities, splits] = await Promise.all([
this.getActivities({
filters, filters,
userCurrency, userCurrency,
userId, userId,
withExcludedAccountsAndActivities: false // TODO 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)) { if (withCash && !this.areCashActivitiesExcludedByFilters(filters)) {
const cashDetails = await this.accountService.getCashDetails({ 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<string[]> {
const activitiesByUser = await this.prismaService.order.groupBy({
by: ['userId'],
where: {
symbolProfileId
}
});
return activitiesByUser.map(({ userId }) => {
return userId;
});
}
public async order( public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> { ): Promise<Order | null> {
@ -952,6 +860,7 @@ export class ActivitiesService {
public async updateActivity({ public async updateActivity({
data, data,
originalDate,
userId, userId,
where where
}: { }: {
@ -963,9 +872,11 @@ export class ActivitiesService {
tags?: { id: string }[]; tags?: { id: string }[];
type?: ActivityType; type?: ActivityType;
}; };
originalDate: Date;
userId: string; userId: string;
where: Prisma.OrderWhereUniqueInput; where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> { }): Promise<Order> {
const areTagsProvided = data.tags !== undefined;
const tags = data.tags ?? []; const tags = data.tags ?? [];
await this.tagService.validateTagIds({ await this.tagService.validateTagIds({
@ -979,8 +890,6 @@ export class ActivitiesService {
data.comment = null; data.comment = null;
} }
let isDraft = false;
if ( if (
NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) || NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' && (data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' &&
@ -991,10 +900,9 @@ export class ActivitiesService {
} else { } else {
delete data.SymbolProfile.update; delete data.SymbolProfile.update;
isDraft = isAfter(data.date as Date, endOfToday()); if (!isActivityInFuture({ date: data.date as Date })) {
// Gather symbol data of order in the background, if the date is not in
if (!isDraft) { // the future
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols({ this.dataGatheringService.gatherSymbols({
dataGatheringItems: [ dataGatheringItems: [
{ {
@ -1014,14 +922,35 @@ export class ActivitiesService {
delete data.symbol; delete data.symbol;
delete data.tags; 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({ const activity = await this.prismaService.order.update({
where, where,
data: { data: {
...data, ...data,
isDraft, tags: tagsToUpdate
tags: {
set: tags
}
} }
}); });
@ -1035,6 +964,181 @@ export class ActivitiesService {
return activity; 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: { private async orders(params: {
include?: Prisma.OrderInclude; include?: Prisma.OrderInclude;
skip?: number; skip?: number;

2
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { 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 { AdminService } from './admin.service';
import { PropertyKeyPipe } from './pipes/property-key.pipe'; import { PropertyKeyPipe } from './pipes/property-key.pipe';
@AllowDuringImpersonation()
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
private readonly logger = new Logger(AdminController.name); private readonly logger = new Logger(AdminController.name);

2
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { AdminJobs } from '@ghostfolio/common/interfaces'; import { AdminJobs } from '@ghostfolio/common/interfaces';
@ -16,6 +17,7 @@ import { JobStatus } from 'bull';
import { QueueService } from './queue.service'; import { QueueService } from './queue.service';
@AllowDuringImpersonation()
@Controller('admin/queue') @Controller('admin/queue')
export class QueueController { export class QueueController {
public constructor(private readonly queueService: QueueService) {} public constructor(private readonly queueService: QueueService) {}

7
apps/api/src/app/app.module.ts

@ -1,5 +1,6 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { EventsModule } from '@ghostfolio/api/events/events.module';
import { PortfolioSnapshotComputationExceptionFilter } from '@ghostfolio/api/filters/portfolio-snapshot-computation-exception.filter'; 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 { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper';
import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.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 { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; 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 { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
@ -191,6 +192,10 @@ import { UserModule } from './user/user.module';
{ {
provide: APP_FILTER, provide: APP_FILTER,
useClass: PortfolioSnapshotComputationExceptionFilter useClass: PortfolioSnapshotComputationExceptionFilter
},
{
provide: APP_GUARD,
useClass: ImpersonationWriteGuard
} }
] ]
}) })

2
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 { 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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -16,6 +17,7 @@ import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@AllowDuringImpersonation()
@Controller('auth-device') @Controller('auth-device')
export class AuthDeviceController { export class AuthDeviceController {
public constructor( public constructor(

18
apps/api/src/app/auth/api-key.strategy.ts

@ -56,22 +56,20 @@ export class ApiKeyStrategy extends PassportStrategy(
} }
private async validateApiKey(apiKey: string) { private async validateApiKey(apiKey: string) {
if (!apiKey) { if (apiKey) {
throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED
);
}
try { try {
const { id } = await this.apiKeyService.getUserByApiKey(apiKey); const { id } = await this.apiKeyService.getUserByApiKey(apiKey);
const user = await this.userService.user({ id });
if (user) {
return user;
}
} catch {}
}
return this.userService.user({ id });
} catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED), getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED StatusCodes.UNAUTHORIZED
); );
} }
}
} }

11
apps/api/src/app/auth/auth.controller.ts

@ -1,6 +1,8 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; 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 { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { import {
@ -27,6 +29,7 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@AllowDuringImpersonation()
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
public constructor( public constructor(
@ -60,13 +63,13 @@ export class AuthController {
} }
@Get('google/callback') @Get('google/callback')
@UseGuards(AuthGuard('google')) @UseGuards(OAuthCallbackGuard('google'))
@Version(VERSION_NEUTRAL) @Version(VERSION_NEUTRAL)
public googleLoginCallback( public googleLoginCallback(
@Req() request: Request, @Req() request: Request,
@Res() response: Response @Res() response: Response
) { ) {
const jwt: string = (request.user as any).jwt; const jwt: string = (request.user as any)?.jwt;
if (jwt) { if (jwt) {
response.redirect( response.redirect(
@ -96,10 +99,10 @@ export class AuthController {
} }
@Get('oidc/callback') @Get('oidc/callback')
@UseGuards(AuthGuard('oidc')) @UseGuards(OAuthCallbackGuard('oidc'))
@Version(VERSION_NEUTRAL) @Version(VERSION_NEUTRAL)
public oidcLoginCallback(@Req() request: Request, @Res() response: Response) { 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) { if (jwt) {
response.redirect( response.redirect(

21
apps/api/src/app/auth/auth.module.ts

@ -69,7 +69,7 @@ import { OidcStrategy } from './oidc.strategy';
const issuer = configurationService.get('OIDC_ISSUER'); const issuer = configurationService.get('OIDC_ISSUER');
const scope = configurationService.get('OIDC_SCOPE'); const scope = configurationService.get('OIDC_SCOPE');
const callbackUrl = const callbackURL =
configurationService.get('OIDC_CALLBACK_URL') || configurationService.get('OIDC_CALLBACK_URL') ||
`${configurationService.get('ROOT_URL')}/api/auth/oidc/callback`; `${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 = { const options: StrategyOptions = {
authorizationURL, authorizationURL,
callbackURL,
clientID,
clientSecret,
issuer, issuer,
scope, scope,
tokenURL, tokenURL,
userInfoURL, userInfoURL
callbackURL: callbackUrl,
clientID: configurationService.get('OIDC_CLIENT_ID'),
clientSecret: configurationService.get('OIDC_CLIENT_SECRET')
}; };
return new OidcStrategy(authService, options); return new OidcStrategy(authService, options);

31
apps/api/src/app/auth/oidc-state.store.ts

@ -1,17 +1,24 @@
import type { Request } from 'express';
import ms from 'ms'; 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. * Custom state store for OIDC authentication that doesn't rely on express-session.
* This store manages OAuth2 state parameters in memory with automatic cleanup. * 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 readonly STATE_EXPIRY_MS = ms('10 minutes');
private stateMap = new Map< private stateMap = new Map<
string, string,
{ {
appState?: unknown; appState?: unknown;
ctx: { issued?: Date; maxAge?: number; nonce?: string }; ctx: SessionStoreContext;
meta?: unknown; meta?: unknown;
timestamp: number; timestamp: number;
} }
@ -19,14 +26,13 @@ export class OidcStateStore {
/** /**
* Store request state. * Store request state.
* Signature matches passport-openidconnect SessionStore
*/ */
public store( public store(
_req: unknown, _req: Request,
_meta: unknown, ctx: SessionStoreContext,
appState: unknown, appState: unknown,
ctx: { maxAge?: number; nonce?: string; issued?: Date }, meta: unknown,
callback: (err: Error | null, handle?: string) => void callback: SessionStoreCallback
) { ) {
try { try {
// Generate a unique handle for this state // Generate a unique handle for this state
@ -35,7 +41,7 @@ export class OidcStateStore {
this.stateMap.set(handle, { this.stateMap.set(handle, {
appState, appState,
ctx, ctx,
meta: _meta, meta,
timestamp: Date.now() timestamp: Date.now()
}); });
@ -50,16 +56,11 @@ export class OidcStateStore {
/** /**
* Verify request state. * Verify request state.
* Signature matches passport-openidconnect SessionStore
*/ */
public verify( public verify(
_req: unknown, _req: Request,
handle: string, handle: string,
callback: ( callback: SessionVerifyCallback
err: Error | null,
appState?: unknown,
ctx?: { maxAge?: number; nonce?: string; issued?: Date }
) => void
) { ) {
try { try {
const data = this.stateMap.get(handle); const data = this.stateMap.get(handle);

14
apps/api/src/app/auth/oidc.strategy.ts

@ -15,10 +15,10 @@ import { OidcStateStore } from './oidc-state.store';
@Injectable() @Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
private readonly logger = new Logger(OidcStrategy.name);
private static readonly stateStore = new OidcStateStore(); private static readonly stateStore = new OidcStateStore();
private readonly logger = new Logger(OidcStrategy.name);
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
options: StrategyOptions options: StrategyOptions
@ -48,11 +48,6 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
params?.sub ?? params?.sub ??
context?.claims?.sub; context?.claims?.sub;
const jwt = await this.authService.validateOAuthLogin({
thirdPartyId,
provider: Provider.OIDC
});
if (!thirdPartyId) { if (!thirdPartyId) {
this.logger.error( this.logger.error(
`Missing subject identifier in OIDC response from ${issuer}` `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'); throw new Error('Missing subject identifier in OIDC response');
} }
const jwt = await this.authService.validateOAuthLogin({
thirdPartyId,
provider: Provider.OIDC
});
return { jwt }; return { jwt };
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error);

9
apps/api/src/app/auth/web-auth.service.ts

@ -99,6 +99,11 @@ export class WebAuthService {
): Promise<AuthDeviceDto> { ): Promise<AuthDeviceDto> {
const user = this.request.user; const user = this.request.user;
const expectedChallenge = user.authChallenge; const expectedChallenge = user.authChallenge;
if (!expectedChallenge) {
throw new Error('Missing authentication challenge');
}
let verification: VerifiedRegistrationResponse; let verification: VerifiedRegistrationResponse;
try { try {
@ -213,7 +218,7 @@ export class WebAuthService {
id: isoBase64URL.fromBuffer(device.credentialId), id: isoBase64URL.fromBuffer(device.credentialId),
publicKey: device.credentialPublicKey publicKey: device.credentialPublicKey
}, },
expectedChallenge: `${user.authChallenge}`, expectedChallenge: `${user?.authChallenge}`,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID, expectedRPID: this.rpID,
requireUserVerification: false, requireUserVerification: false,
@ -243,7 +248,7 @@ export class WebAuthService {
}); });
return this.jwtService.sign({ return this.jwtService.sign({
id: user.id id: user?.id
}); });
} }

2
apps/api/src/app/cache/cache.controller.ts

@ -1,4 +1,5 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; 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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -6,6 +7,7 @@ import { permissions } from '@ghostfolio/common/permissions';
import { Controller, Post, UseGuards } from '@nestjs/common'; import { Controller, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@AllowDuringImpersonation()
@Controller('cache') @Controller('cache')
export class CacheController { export class CacheController {
public constructor(private readonly redisCacheService: RedisCacheService) {} public constructor(private readonly redisCacheService: RedisCacheService) {}

1
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -46,7 +46,6 @@ export class AiController {
const prompt = await this.aiService.getPrompt({ const prompt = await this.aiService.getPrompt({
filters, filters,
mode, mode,
impersonationId: undefined,
languageCode: this.request.user.settings.settings.language, languageCode: this.request.user.settings.settings.language,
userCurrency: this.request.user.settings.settings.baseCurrency, userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id

3
apps/api/src/app/endpoints/ai/ai.service.ts

@ -70,14 +70,12 @@ export class AiService {
public async getPrompt({ public async getPrompt({
filters, filters,
impersonationId,
languageCode, languageCode,
mode, mode,
userCurrency, userCurrency,
userId userId
}: { }: {
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
languageCode: string; languageCode: string;
mode: AiPromptMode; mode: AiPromptMode;
userCurrency: string; userCurrency: string;
@ -85,7 +83,6 @@ export class AiService {
}) { }) {
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
filters, filters,
impersonationId,
userId userId
}); });

2
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; 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 { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@AllowDuringImpersonation()
@Controller('api-keys') @Controller('api-keys')
export class ApiKeysController { export class ApiKeysController {
public constructor( public constructor(

9
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { 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'; import { AssetProfilesService } from './asset-profiles.service';
@AllowDuringImpersonation()
@Controller('asset-profiles') @Controller('asset-profiles')
export class AssetProfilesController { export class AssetProfilesController {
public constructor( public constructor(
@ -167,7 +169,12 @@ export class AssetProfilesController {
permissions.deleteAssetProfileSplitOfOwnAssetProfile permissions.deleteAssetProfileSplitOfOwnAssetProfile
}); });
return this.assetProfilesService.deleteSplit({ id, symbolProfileId }); return this.assetProfilesService.deleteSplit({
dataSource,
id,
symbol,
symbolProfileId
});
} }
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)

179
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<void>((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);
});
}

56
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 { 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 { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -25,6 +26,7 @@ import {
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@ -36,6 +38,7 @@ export class AssetProfilesService {
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -62,18 +65,24 @@ export class AssetProfilesService {
symbolProfileId symbolProfileId
}); });
await this.dataGatheringService.gatherSymbol({ dataSource, symbol }); await this.gatherSymbolAndEmitPortfolioChangedEvents({
dataSource,
symbol,
symbolProfileId
});
return assetProfileSplit; return assetProfileSplit;
} }
public async deleteSplit({ public async deleteSplit({
dataSource,
id, id,
symbol,
symbolProfileId symbolProfileId
}: { }: {
id: string; id: string;
symbolProfileId: string; symbolProfileId: string;
}) { } & AssetProfileIdentifier) {
const isDeleted = await this.assetProfileSplitService.deleteById({ const isDeleted = await this.assetProfileSplitService.deleteById({
id, id,
symbolProfileId symbolProfileId
@ -82,6 +91,12 @@ export class AssetProfilesService {
if (!isDeleted) { if (!isDeleted) {
throw new NotFoundException(); throw new NotFoundException();
} }
await this.gatherSymbolAndEmitPortfolioChangedEvents({
dataSource,
symbol,
symbolProfileId
});
} }
public async getAssetProfile({ public async getAssetProfile({
@ -425,6 +440,43 @@ export class AssetProfilesService {
return assetProfile; 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({ private getAssetProfileDataUpdate({
countries, countries,
holdings, holdings,

24
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 { 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 { 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 { 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 { 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type { import type {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetailsResponse, BenchmarkMarketDataDetailsResponse,
BenchmarkResponse BenchmarkResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; 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 { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject,
Param, Param,
Post, Post,
Query, Query,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -36,13 +36,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarksService } from './benchmarks.service'; import { BenchmarksService } from './benchmarks.service';
import { GetBenchmarkMarketDataDto } from './get-benchmark-market-data.dto'; import { GetBenchmarkMarketDataDto } from './get-benchmark-market-data.dto';
@AllowDuringImpersonation()
@Controller('benchmarks') @Controller('benchmarks')
export class BenchmarksController { export class BenchmarksController {
public constructor( public constructor(
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly benchmarksService: BenchmarksService, private readonly benchmarksService: BenchmarksService
@Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@ -112,10 +112,10 @@ export class BenchmarksController {
} }
@Get(':dataSource/:symbol/:startDateString') @Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @RequiresScope(scopes.portfolioRead)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataForUser( public async getBenchmarkMarketDataForUser(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId, userSettings }: ImpersonationContext,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@ -147,12 +147,12 @@ export class BenchmarksController {
dataSource, dataSource,
endDate, endDate,
filters, filters,
impersonationId,
startDate, startDate,
symbol, symbol,
userId,
userSettings,
withExcludedAccounts, withExcludedAccounts,
dateRange: range, dateRange: range
user: this.request.user
}); });
} }
} }

17
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -7,9 +7,10 @@ import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetailsResponse, BenchmarkMarketDataDetailsResponse,
Filter Filter,
UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format, isSameDay } from 'date-fns'; import { format, isSameDay } from 'date-fns';
@ -32,28 +33,26 @@ export class BenchmarksService {
dateRange, dateRange,
endDate = new Date(), endDate = new Date(),
filters, filters,
impersonationId,
startDate, startDate,
symbol, symbol,
user, userId,
userSettings,
withExcludedAccounts withExcludedAccounts
}: { }: {
dateRange: DateRange; dateRange: DateRange;
endDate?: Date; endDate?: Date;
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
startDate: Date; startDate: Date;
user: UserWithSettings; userId: string;
userSettings: UserSettings;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetailsResponse> { } & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetailsResponse> {
const marketData: { date: string; value: number }[] = []; const marketData: { date: string; value: number }[] = [];
const userCurrency = user.settings.settings.baseCurrency; const userCurrency = userSettings.baseCurrency;
const userId = user.id;
const { chart } = await this.portfolioService.getPerformance({ const { chart } = await this.portfolioService.getPerformance({
dateRange, dateRange,
filters, filters,
impersonationId,
userId, userId,
withExcludedAccounts withExcludedAccounts
}); });

12
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -115,7 +115,7 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }
@ -157,7 +157,7 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }
@ -199,7 +199,7 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }
@ -226,7 +226,7 @@ export class GhostfolioService {
return marketDataOfMarkets; return marketDataOfMarkets;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }
@ -314,7 +314,7 @@ export class GhostfolioService {
return results; return results;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }
@ -393,7 +393,7 @@ export class GhostfolioService {
return results; return results;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }

2
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 { 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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; 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 { parseISO } from 'date-fns';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@AllowDuringImpersonation()
@Controller('market-data') @Controller('market-data')
export class MarketDataController { export class MarketDataController {
public constructor( public constructor(

2
apps/api/src/app/endpoints/public/public.controller.ts

@ -78,7 +78,6 @@ export class PublicController {
] = await Promise.all([ ] = await Promise.all([
this.portfolioService.getDetails({ this.portfolioService.getDetails({
filters, filters,
impersonationId: access.userId,
userId: user.id, userId: user.id,
withMarkets: true withMarkets: true
}), }),
@ -86,7 +85,6 @@ export class PublicController {
return this.portfolioService.getPerformance({ return this.portfolioService.getPerformance({
dateRange, dateRange,
filters, filters,
impersonationId: undefined,
userId: user.id userId: user.id
}); });
}) })

2
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
@ -23,6 +24,7 @@ import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@AllowDuringImpersonation()
@Controller('tags') @Controller('tags')
export class TagsController { export class TagsController {
public constructor( public constructor(

23
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts

@ -1,20 +1,23 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.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 { 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 { 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 { 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 { CreateWatchlistItemDto } from '@ghostfolio/common/dtos';
import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; 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 { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -32,7 +35,6 @@ import { WatchlistService } from './watchlist.service';
@Controller('watchlist') @Controller('watchlist')
export class WatchlistController { export class WatchlistController {
public constructor( public constructor(
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly watchlistService: WatchlistService private readonly watchlistService: WatchlistService
) {} ) {}
@ -81,17 +83,12 @@ export class WatchlistController {
@Get() @Get()
@HasPermission(permissions.readWatchlist) @HasPermission(permissions.readWatchlist)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @RequiresScope(scopes.watchlistRead)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getWatchlistItems( public async getWatchlistItems(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Impersonation() { userId }: ImpersonationContext
): Promise<WatchlistResponse> { ): Promise<WatchlistResponse> {
const impersonationUserId = const watchlist = await this.watchlistService.getWatchlistItems(userId);
await this.impersonationService.validateImpersonationId(impersonationId);
const watchlist = await this.watchlistService.getWatchlistItems(
impersonationUserId || this.request.user.id
);
return { return {
watchlist watchlist

230
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 { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -13,9 +14,14 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
ghostfolioPrefix, ghostfolioPrefix,
NON_INVESTMENT_ACTIVITY_TYPES, NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos'; import {
CreateAccountWithBalancesDto,
CreateAssetProfileDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol, isValidCustomAssetProfileSymbol,
@ -34,9 +40,10 @@ import {
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma } from '@prisma/client'; import { Account, DataSource, Prisma } from '@prisma/client';
import { Big } from 'big.js'; 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 { omit, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -96,7 +103,9 @@ export class ImportService {
filters, filters,
userCurrency, userCurrency,
userId, userId,
startDate: parseDate(dateOfFirstActivity) includeDrafts: true,
startDate: parseDate(dateOfFirstActivity),
withExcludedAccountsAndActivities: true
}), }),
this.symbolProfileService.getSymbolProfiles([ this.symbolProfileService.getSymbolProfiles([
{ {
@ -117,6 +126,8 @@ export class ImportService {
return await Promise.all( return await Promise.all(
Object.entries(dividends).map(([dateString, { marketPrice }]) => { Object.entries(dividends).map(([dateString, { marketPrice }]) => {
const date = parseDate(dateString);
const quantity = const quantity =
historicalData.find((historicalDataItem) => { historicalData.find((historicalDataItem) => {
return historicalDataItem.date === dateString; return historicalDataItem.date === dateString;
@ -124,10 +135,8 @@ export class ImportService {
const value = new Big(quantity).mul(marketPrice).toNumber(); const value = new Big(quantity).mul(marketPrice).toNumber();
const date = parseDate(dateString);
const isDuplicate = activities.some((activity) => { const isDuplicate = activities.some((activity) => {
return ( return (
activity.accountId === account?.id &&
activity.assetProfile.currency === assetProfile.currency && activity.assetProfile.currency === assetProfile.currency &&
activity.assetProfile.dataSource === assetProfile.dataSource && activity.assetProfile.dataSource === assetProfile.dataSource &&
isSameSecond(activity.date, date) && isSameSecond(activity.date, date) &&
@ -158,7 +167,6 @@ export class ImportService {
feeInAssetProfileCurrency: 0, feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0, feeInBaseCurrency: 0,
id: assetProfile.id, id: assetProfile.id,
isDraft: false,
symbolProfileId: assetProfile.id, symbolProfileId: assetProfile.id,
type: 'DIVIDEND', type: 'DIVIDEND',
unitPrice: marketPrice, 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) { if (platformsDto?.length) {
const canCreatePlatform = hasPermission( const canCreatePlatform = hasPermission(
user.permissions, user.permissions,
@ -326,16 +387,28 @@ export class ImportService {
} }
} }
if (!isDryRun && accountsWithBalancesDto?.length) { if (accountsWithBalancesDto?.length) {
const [existingAccounts, existingPlatforms] = await Promise.all([ const [
existingAccountsOfOtherUsers,
existingAccountsOfUser,
existingPlatforms
] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
where: { where: {
id: { id: {
in: accountsWithBalancesDto.map(({ id }) => { in: accountsWithBalancesDto
.filter(({ id }) => {
return Boolean(id);
})
.map(({ id }) => {
return id; return id;
}) })
},
userId: { not: user.id }
} }
} }),
this.accountService.accounts({
where: { userId: user.id }
}), }),
this.platformService.getPlatforms() this.platformService.getPlatforms()
]); ]);
@ -347,13 +420,51 @@ export class ImportService {
); );
for (const accountWithBalances of accountsWithBalancesDto) { for (const accountWithBalances of accountsWithBalancesDto) {
// Check if there is any existing account with the same ID // Skip the account if it already belongs to the user
const accountWithSameId = existingAccounts.find((existingAccount) => { if (
return existingAccount.id === accountWithBalances.id; 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 (accountToReuse) {
if (!accountWithSameId || accountWithSameId.userId !== user.id) { // 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;
}
continue;
}
if (isDryRun) {
continue;
}
// 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 account = omit(accountWithBalances, [ const account = omit(accountWithBalances, [
'balance', 'balance',
'balances', 'balances',
@ -367,7 +478,7 @@ export class ImportService {
delete account.platformId; delete account.platformId;
if (accountWithSameId) { if (accountWithSameIdOfOtherUser) {
oldAccountId = account.id; oldAccountId = account.id;
delete account.id; delete account.id;
} }
@ -417,12 +528,11 @@ export class ImportService {
}); });
// Store the new to old account ID mappings for updating activities // Store the new to old account ID mappings for updating activities
if (accountWithSameId && oldAccountId) { if (accountWithSameIdOfOtherUser && oldAccountId) {
accountIdMapping[oldAccountId] = newAccount.id; accountIdMapping[oldAccountId] = newAccount.id;
} }
} }
} }
}
if (assetProfilesWithMarketDataDto?.length) { if (assetProfilesWithMarketDataDto?.length) {
const customAssetProfileNames = assetProfilesWithMarketDataDto const customAssetProfileNames = assetProfilesWithMarketDataDto
@ -537,12 +647,12 @@ export class ImportService {
activity.symbol = assetProfileSymbolMapping[activity.symbol]; activity.symbol = assetProfileSymbolMapping[activity.symbol];
} }
if (!isDryRun) { // If an account is created or reused, then update the accountId in all activities
// If a new account is created, then update the accountId in all activities
if (accountIdMapping[activity.accountId]) { if (accountIdMapping[activity.accountId]) {
activity.accountId = accountIdMapping[activity.accountId]; activity.accountId = accountIdMapping[activity.accountId];
} }
if (!isDryRun) {
// If a new tag is created, then update the tag ID in all activities // If a new tag is created, then update the tag ID in all activities
activity.tags = (activity.tags ?? []).map((tagId) => { activity.tags = (activity.tags ?? []).map((tagId) => {
return tagIdMapping[tagId] ?? tagId; return tagIdMapping[tagId] ?? tagId;
@ -570,7 +680,18 @@ export class ImportService {
); );
if (isDryRun) { if (isDryRun) {
accountsWithBalancesDto.forEach(({ 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 }); accounts.push({ id, name });
}); });
} }
@ -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[] = []; const activities: Activity[] = [];
for (const activity of activitiesExtendedWithErrors) { for (const activity of activitiesExtendedWithErrors) {
@ -655,6 +781,13 @@ export class ImportService {
}); });
if (isDryRun) { if (isDryRun) {
const previewTags = getTagsWithDraftTag({
date,
draftTag,
type,
tags: validatedTags
});
order = { order = {
comment, comment,
currency, currency,
@ -668,7 +801,6 @@ export class ImportService {
accountUserId: undefined, accountUserId: undefined,
createdAt: new Date(), createdAt: new Date(),
id: randomUUID(), id: randomUUID(),
isDraft: isAfter(date, endOfToday()),
SymbolProfile: { SymbolProfile: {
assetClass, assetClass,
assetSubClass, assetSubClass,
@ -697,7 +829,7 @@ export class ImportService {
userId: dataSource === 'MANUAL' ? user.id : undefined userId: dataSource === 'MANUAL' ? user.id : undefined
}, },
symbolProfileId: undefined, symbolProfileId: undefined,
tags: validatedTags, tags: previewTags,
updatedAt: new Date(), updatedAt: new Date(),
userId: user.id userId: user.id
}; };
@ -826,10 +958,10 @@ export class ImportService {
unitPrice unitPrice
}) => { }) => {
const date = parseISO(dateString); const date = parseISO(dateString);
const isDuplicate = existingActivities.some((activity) => { const isDuplicate = existingActivities.some((activity) => {
return ( return (
activity.accountId === accountId && (activity.comment || null) === (comment || null) &&
activity.comment === comment &&
(activity.currency === currency || (activity.currency === currency ||
activity.assetProfile.currency === currency) && activity.assetProfile.currency === currency) &&
activity.assetProfile.dataSource === dataSource && 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[]) { private isUniqueAccount(accounts: AccountWithValue[]) {
const uniqueAccountIds = new Set<string>(); const uniqueAccountIds = new Set<string>();

2
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
@ -20,6 +21,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
@AllowDuringImpersonation()
@Controller('platform') @Controller('platform')
export class PlatformController { export class PlatformController {
public constructor(private readonly platformService: PlatformService) {} public constructor(private readonly platformService: PlatformService) {}

1
apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts

@ -12,7 +12,6 @@ export const activityDummyData = {
feeInAssetProfileCurrency: undefined, feeInAssetProfileCurrency: undefined,
feeInBaseCurrency: undefined, feeInBaseCurrency: undefined,
id: undefined, id: undefined,
isDraft: false,
symbolProfileId: undefined, symbolProfileId: undefined,
unitPrice: undefined, unitPrice: undefined,
unitPriceInAssetProfileCurrency: undefined, unitPriceInAssetProfileCurrency: undefined,

4
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 { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; 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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -124,6 +125,9 @@ describe('PortfolioCalculator', () => {
activitiesService = new ActivitiesService( activitiesService = new ActivitiesService(
accountBalanceService, accountBalanceService,
accountService, accountService,
{
getSplitsByUserId: jest.fn().mockResolvedValue([])
} as unknown as AssetProfileSplitService,
null, null,
null, null,
dataProviderService, dataProviderService,

276
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
};
}

59
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 totalInvestmentWithCurrencyEffect = new Big(0);
let totalLiabilities = new Big(0); let totalLiabilities = new Big(0);
let totalLiabilitiesInBaseCurrency = new Big(0); let totalLiabilitiesInBaseCurrency = new Big(0);
let totalQuantity = new Big(0);
let totalQuantityFromBuyTransactions = new Big(0); let totalQuantityFromBuyTransactions = new Big(0);
let totalUnits = new Big(0);
let valueAtStartDate: Big; let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: 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( let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ assetProfile }) => { this.activities.filter((activities) => {
return assetProfile.symbol === symbol; return activities.assetProfile.symbol === symbol;
}) })
); );
@ -295,10 +295,10 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
!unitPriceAtEndDate || !unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) (!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) { ) {
// A missing market price can only affect the units which are held. The // A missing market price can only affect the quantity which is held. The
// dividends, the interest and the liabilities do not hold any units and // dividends, the interest and the liabilities do not hold any quantity
// are therefore not in error. // and are therefore not in error.
const hasActivitiesWithUnits = orders.some(({ type }) => { const hasActivitiesWithQuantity = orders.some(({ type }) => {
return ['BUY', 'SELL'].includes(type); return ['BUY', 'SELL'].includes(type);
}); });
@ -316,7 +316,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
grossPerformancePercentage: new Big(0), grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0), grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: hasActivitiesWithUnits, hasErrors: hasActivitiesWithQuantity,
initialValue: new Big(0), initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0), initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {}, investmentValuesAccumulated: {},
@ -489,12 +489,12 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
order.unitPriceFromMarketData?.mul(exchangeRateAtOrderDate ?? 1) ?? order.unitPriceFromMarketData?.mul(exchangeRateAtOrderDate ?? 1) ??
new Big(0); new Big(0);
const valueOfInvestmentBeforeTransaction = totalUnits.mul( const valueOfInvestmentBeforeTransaction = totalQuantity.mul(
marketPriceInBaseCurrency marketPriceInBaseCurrency
); );
const valueOfInvestmentBeforeTransactionWithCurrencyEffect = const valueOfInvestmentBeforeTransactionWithCurrencyEffect =
totalUnits.mul(marketPriceInBaseCurrencyWithCurrencyEffect); totalQuantity.mul(marketPriceInBaseCurrencyWithCurrencyEffect);
if (!investmentAtStartDate && i >= indexOfStartOrder) { if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0); investmentAtStartDate = totalInvestment ?? new Big(0);
@ -531,16 +531,19 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
transactionInvestmentWithCurrencyEffect transactionInvestmentWithCurrencyEffect
); );
} else if (order.type === 'SELL') { } else if (order.type === 'SELL') {
if (totalUnits.gt(0)) { if (totalQuantity.gt(0)) {
const remainingQuantity = totalQuantity.minus(order.quantity);
transactionInvestment = totalInvestment transactionInvestment = totalInvestment
.div(totalUnits) .mul(remainingQuantity)
.mul(order.quantity) .div(totalQuantity)
.mul(getFactor(order.type)); .minus(totalInvestment);
transactionInvestmentWithCurrencyEffect = transactionInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect totalInvestmentWithCurrencyEffect
.div(totalUnits) .mul(remainingQuantity)
.mul(order.quantity) .div(totalQuantity)
.mul(getFactor(order.type)); .minus(totalInvestmentWithCurrencyEffect);
} }
} }
@ -589,11 +592,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
order.feeInBaseCurrencyWithCurrencyEffect ?? 0 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 marketPriceInBaseCurrencyWithCurrencyEffect
); );
@ -634,7 +639,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
totalQuantityFromBuyTransactions totalQuantityFromBuyTransactions
); );
if (totalUnits.eq(0)) { if (totalQuantity.eq(0)) {
// Reset tracking variables when position is fully closed // Reset tracking variables when position is fully closed
totalInvestmentFromBuyTransactions = new Big(0); totalInvestmentFromBuyTransactions = new Big(0);
totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
@ -827,14 +832,14 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
) )
: new Big(0); : new Big(0);
const feesPerUnit = totalUnits.gt(0) const feesPerUnit = totalQuantity.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits) ? fees.minus(feesAtStartDate).div(totalQuantity)
: new Big(0); : new Big(0);
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0) const feesPerUnitWithCurrencyEffect = totalQuantity.gt(0)
? feesWithCurrencyEffect ? feesWithCurrencyEffect
.minus(feesAtStartDateWithCurrencyEffect) .minus(feesAtStartDateWithCurrencyEffect)
.div(totalUnits) .div(totalQuantity)
: new Big(0); : new Big(0);
const netPerformancePercentage = const netPerformancePercentage =
@ -1006,7 +1011,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
grossPerformance: totalGrossPerformance, grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect, totalGrossPerformanceWithCurrencyEffect,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), hasErrors: totalQuantity.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance, netPerformance: totalNetPerformance,
timeWeightedInvestment: timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate, timeWeightedAverageInvestmentBetweenStartAndEndDate,

123
apps/api/src/app/portfolio/portfolio.controller.ts

@ -1,6 +1,7 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; 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 { 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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import {
hasNotDefinedValuesInObject, 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 { 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import { UNKNOWN_KEY } from '@ghostfolio/common/config';
HEADER_KEY_IMPERSONATION,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
PortfolioDetails, PortfolioDetails,
@ -28,18 +25,17 @@ import {
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioReportResponse PortfolioReportResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import { isRestrictedView, permissions } from '@ghostfolio/common/permissions';
hasReadRestrictedAccessPermission, import { hasScope, scopes } from '@ghostfolio/common/scopes';
isRestrictedView, import type {
permissions ImpersonationContext,
} from '@ghostfolio/common/permissions'; RequestWithUser
import type { RequestWithUser } from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -69,19 +65,18 @@ export class PortfolioController {
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser
private readonly userService: UserService
) {} ) {}
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @RequiresScope(scopes.portfolioRead)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation()
{ scopes: impersonationScopes, userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts: filterByAccounts, accounts: filterByAccounts,
@ -120,10 +115,9 @@ export class PortfolioController {
summary summary
} = await this.portfolioService.getDetails({ } = await this.portfolioService.getDetails({
filters, filters,
impersonationId, userId,
withMarkets, withMarkets,
dateRange: range, dateRange: range,
userId: this.request.user.id,
withSummary: true withSummary: true
}); });
@ -134,10 +128,7 @@ export class PortfolioController {
let portfolioSummary = summary; let portfolioSummary = summary;
if ( if (
hasReadRestrictedAccessPermission({ !hasScope(impersonationScopes, scopes.portfolioReadValues) ||
impersonationId,
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
const totalInvestment = Object.values(holdings) const totalInvestment = Object.values(holdings)
@ -178,10 +169,7 @@ export class PortfolioController {
if ( if (
hasDetails === false || hasDetails === false ||
hasReadRestrictedAccessPermission({ !hasScope(impersonationScopes, scopes.portfolioReadValues) ||
impersonationId,
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
Object.values(markets ?? {}).forEach((market) => { Object.values(markets ?? {}).forEach((market) => {
@ -323,10 +311,11 @@ export class PortfolioController {
} }
@Get('dividends') @Get('dividends')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @RequiresScope(scopes.portfolioRead)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getDividends( public async getDividends(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation()
{ scopes: impersonationScopes, userId, userSettings }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -346,12 +335,7 @@ export class PortfolioController {
filterByTags: tags filterByTags: tags
}); });
const impersonationUserId = const userCurrency = userSettings.baseCurrency;
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 { endDate, startDate } = getIntervalFromDateRange({ const { endDate, startDate } = getIntervalFromDateRange({
dateRange: range dateRange: range
@ -372,10 +356,7 @@ export class PortfolioController {
}); });
if ( if (
hasReadRestrictedAccessPermission({ !hasScope(impersonationScopes, scopes.portfolioReadValues) ||
impersonationId,
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
const maxDividend = dividends.reduce( const maxDividend = dividends.reduce(
@ -405,17 +386,16 @@ export class PortfolioController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @RequiresScope(scopes.portfolioRead)
public async getHolding( public async getHolding(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId }: ImpersonationContext,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<PortfolioHoldingResponse> { ): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getHolding({ const holding = await this.portfolioService.getHolding({
dataSource, dataSource,
impersonationId,
symbol, symbol,
userId: this.request.user.id userId
}); });
if (!holding) { if (!holding) {
@ -429,12 +409,12 @@ export class PortfolioController {
} }
@Get('holdings') @Get('holdings')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @RequiresScope(scopes.portfolioRead)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getHoldings( public async getHoldings(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -459,19 +439,19 @@ export class PortfolioController {
const holdings = await this.portfolioService.getHoldings({ const holdings = await this.portfolioService.getHoldings({
filters, filters,
impersonationId, userId,
dateRange: range, dateRange: range
userId: this.request.user.id
}); });
return { holdings }; return { holdings };
} }
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @RequiresScope(scopes.portfolioRead)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getInvestments( public async getInvestments(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation()
{ scopes: impersonationScopes, userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -495,16 +475,12 @@ export class PortfolioController {
await this.portfolioService.getInvestments({ await this.portfolioService.getInvestments({
filters, filters,
groupBy, groupBy,
impersonationId, userId,
dateRange: range, dateRange: range
userId: this.request.user.id
}); });
if ( if (
hasReadRestrictedAccessPermission({ !hasScope(impersonationScopes, scopes.portfolioReadValues) ||
impersonationId,
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
const maxInvestment = investments.reduce( const maxInvestment = investments.reduce(
@ -543,13 +519,14 @@ export class PortfolioController {
} }
@Get('performance') @Get('performance')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @RequiresScope(scopes.portfolioRead)
@UseInterceptors(PerformanceLoggingInterceptor) @UseInterceptors(PerformanceLoggingInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2') @Version('2')
public async getPerformanceV2( public async getPerformanceV2(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation()
{ scopes: impersonationScopes, userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -571,17 +548,13 @@ export class PortfolioController {
const performanceInformation = await this.portfolioService.getPerformance({ const performanceInformation = await this.portfolioService.getPerformance({
filters, filters,
impersonationId, userId,
withExcludedAccounts, withExcludedAccounts,
dateRange: range, dateRange: range
userId: this.request.user.id
}); });
if ( if (
hasReadRestrictedAccessPermission({ !hasScope(impersonationScopes, scopes.portfolioReadValues) ||
impersonationId,
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user) || isRestrictedView(this.request.user) ||
this.request.user.settings.settings.viewMode === 'ZEN' this.request.user.settings.settings.viewMode === 'ZEN'
) { ) {
@ -657,18 +630,18 @@ export class PortfolioController {
} }
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @RequiresScope(scopes.portfolioRead)
public async getReport( public async getReport(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Impersonation()
{ scopes: impersonationScopes, userId }: ImpersonationContext
): Promise<PortfolioReportResponse> { ): Promise<PortfolioReportResponse> {
const report = await this.portfolioService.getReport({ const report = await this.portfolioService.getReport({ userId });
impersonationId,
userId: this.request.user.id
});
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && !hasScope(impersonationScopes, scopes.portfolioReadValues) ||
this.request.user.subscription?.type === SubscriptionType.Basic isRestrictedView(this.request.user) ||
(this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription?.type === SubscriptionType.Basic)
) { ) {
for (const category of report.xRay.categories) { for (const category of report.xRay.categories) {
category.rules = null; category.rules = null;

177
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -30,7 +29,6 @@ describe('PortfolioService', () => {
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let dataProviderService: DataProviderService; let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let impersonationService: ImpersonationService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioService: PortfolioService; let portfolioService: PortfolioService;
let symbolProfileService: SymbolProfileService; let symbolProfileService: SymbolProfileService;
@ -68,6 +66,7 @@ describe('PortfolioService', () => {
accountService, accountService,
null, null,
null, null,
null,
dataProviderService, dataProviderService,
null, null,
exchangeRateDataService, exchangeRateDataService,
@ -77,8 +76,6 @@ describe('PortfolioService', () => {
null null
); );
impersonationService = new ImpersonationService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory( portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService, configurationService,
null, null,
@ -110,7 +107,6 @@ describe('PortfolioService', () => {
dataProviderService, dataProviderService,
exchangeRateDataService, exchangeRateDataService,
null, null,
impersonationService,
null, null,
null, null,
symbolProfileService, symbolProfileService,
@ -245,16 +241,11 @@ describe('PortfolioService', () => {
.spyOn(dataProviderService, 'getDataSourceForExchangeRates') .spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO); .mockReturnValue(DataSource.YAHOO);
jest
.spyOn(impersonationService, 'validateImpersonationId')
.mockResolvedValue(null);
jest jest
.spyOn(symbolProfileService, 'getSymbolProfiles') .spyOn(symbolProfileService, 'getSymbolProfiles')
.mockResolvedValue([]); .mockResolvedValue([]);
jest.spyOn(userService, 'user').mockResolvedValue({ jest.spyOn(userService, 'user').mockResolvedValue({
accessesGet: [],
accounts: [], accounts: [],
activityCount: 0, activityCount: 0,
dataProviderGhostfolioDailyRequests: 0, dataProviderGhostfolioDailyRequests: 0,
@ -331,7 +322,6 @@ describe('PortfolioService', () => {
const { holdings } = await portfolioService.getDetails({ const { holdings } = await portfolioService.getDetails({
filters: [], filters: [],
impersonationId: userDummyData.id,
userId: userDummyData.id userId: userDummyData.id
}); });
@ -371,10 +361,6 @@ describe('PortfolioService', () => {
.spyOn(activitiesService, 'getActivities') .spyOn(activitiesService, 'getActivities')
.mockResolvedValue({ activities: [], count: 0 }); .mockResolvedValue({ activities: [], count: 0 });
jest
.spyOn(impersonationService, 'validateImpersonationId')
.mockResolvedValue(null);
jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({ jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({
performance: { performance: {
currentValueInBaseCurrency: 3000, currentValueInBaseCurrency: 3000,
@ -408,7 +394,6 @@ describe('PortfolioService', () => {
balanceInBaseCurrency: 1000, balanceInBaseCurrency: 1000,
emergencyFundHoldingsValueInBaseCurrency: 0, emergencyFundHoldingsValueInBaseCurrency: 0,
filteredValueInBaseCurrency: new Big(3000), filteredValueInBaseCurrency: new Big(3000),
impersonationId: undefined,
userCurrency: 'CHF', userCurrency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -426,7 +411,10 @@ describe('PortfolioService', () => {
return ( return (
portfolioService as unknown as { portfolioService as unknown as {
getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{ getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{
accounts: Record<string, { valueInBaseCurrency: number }>; accounts: Record<
string,
{ quantity?: number; valueInBaseCurrency: number }
>;
platforms: Record<string, { valueInBaseCurrency: number }>; platforms: Record<string, { valueInBaseCurrency: number }>;
}>; }>;
} }
@ -443,6 +431,10 @@ describe('PortfolioService', () => {
}; };
beforeEach(() => { beforeEach(() => {
jest
.spyOn(accountService, 'accounts')
.mockResolvedValue([account] as unknown as AccountWithBalance[]);
jest jest
.spyOn(accountService, 'getAccounts') .spyOn(accountService, 'getAccounts')
.mockResolvedValue([account] as unknown as AccountWithBalance[]); .mockResolvedValue([account] as unknown as AccountWithBalance[]);
@ -510,5 +502,156 @@ describe('PortfolioService', () => {
expect(accounts[UNKNOWN_KEY]).toBeUndefined(); expect(accounts[UNKNOWN_KEY]).toBeUndefined();
expect(platforms[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);
});
}); });
}); });

257
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 { 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 { 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 { 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 { 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 { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume';
import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power'; 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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
getAnnualizedPerformancePercent, getAnnualizedPerformancePercent,
@ -33,6 +33,7 @@ import {
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
TAG_ID_DRAFT,
TAG_ID_EMERGENCY_FUND, TAG_ID_EMERGENCY_FUND,
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
UNKNOWN_KEY UNKNOWN_KEY
@ -42,7 +43,9 @@ import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
getSum, getSum,
isAccountExcluded, isAccountExcluded,
parseDate isDraftActivity,
parseDate,
resolveUserSettings
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AccountsResponse, AccountsResponse,
@ -122,7 +125,6 @@ export class PortfolioService {
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly rulesService: RulesService, private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
@ -173,17 +175,28 @@ export class PortfolioService {
this.accountService.accounts({ this.accountService.accounts({
where, where,
include: { include: {
activities: { include: { SymbolProfile: true } }, activities: {
include: {
SymbolProfile: true,
tags: {
select: {
id: true
},
where: {
id: TAG_ID_DRAFT
}
}
}
},
platform: true, platform: true,
tags: true tags: true
}, },
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getDetails({ this.getDetails({
userId,
withExcludedAccounts, withExcludedAccounts,
filters: filtersWithoutSearchQueryFilter, filters: filtersWithoutSearchQueryFilter
impersonationId: userId,
userId: this.request.user.id
}), }),
this.userService.user({ id: userId }) this.userService.user({ id: userId })
]); ]);
@ -199,12 +212,18 @@ export class PortfolioService {
for (const { for (const {
currency, currency,
date, date,
isDraft,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags,
type, type,
unitPrice unitPrice
} of account.activities) { } of account.activities) {
activitiesCount += 1;
if (isDraftActivity({ tags })) {
continue;
}
switch (type) { switch (type) {
case ActivityType.DIVIDEND: case ActivityType.DIVIDEND:
dividendInBaseCurrency += dividendInBaseCurrency +=
@ -225,12 +244,12 @@ export class PortfolioService {
)) ?? 0; )) ?? 0;
break; break;
} }
if (!isDraft) {
activitiesCount += 1;
}
} }
const quantityOfHolding = filterBySymbol
? (details.accounts[account.id]?.quantity ?? 0)
: undefined;
const valueInBaseCurrency = const valueInBaseCurrency =
details.accounts[account.id]?.valueInBaseCurrency ?? 0; details.accounts[account.id]?.valueInBaseCurrency ?? 0;
@ -246,6 +265,7 @@ export class PortfolioService {
account.currency, account.currency,
userCurrency userCurrency
), ),
quantity: quantityOfHolding,
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
valueInBaseCurrency, valueInBaseCurrency,
userCurrency, userCurrency,
@ -343,14 +363,10 @@ export class PortfolioService {
activities: Activity[]; activities: Activity[];
groupBy?: GroupBy; groupBy?: GroupBy;
}): InvestmentItem[] { }): InvestmentItem[] {
let dividends = activities.map(({ currency, date, value }) => { let dividends = activities.map(({ date, valueInBaseCurrency }) => {
return { return {
date: format(date, DATE_FORMAT), date: format(date, DATE_FORMAT),
investment: this.exchangeRateDataService.toCurrency( investment: valueInBaseCurrency
value,
currency,
this.getUserCurrency()
)
}; };
}); });
@ -364,16 +380,12 @@ export class PortfolioService {
public async getHoldings({ public async getHoldings({
dateRange, dateRange,
filters, filters,
impersonationId,
userId userId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
userId: string; userId: string;
}) { }) {
userId = await this.getUserId(impersonationId, userId);
const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy( const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy(
filters, filters,
({ type }) => { ({ type }) => {
@ -387,7 +399,6 @@ export class PortfolioService {
const { holdings: holdingsMap } = await this.getDetails({ const { holdings: holdingsMap } = await this.getDetails({
dateRange, dateRange,
impersonationId,
userId, userId,
filters: filtersWithoutSearchQueryFilter filters: filtersWithoutSearchQueryFilter
}); });
@ -412,16 +423,13 @@ export class PortfolioService {
dateRange, dateRange,
filters, filters,
groupBy, groupBy,
impersonationId,
userId userId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
groupBy?: GroupBy; groupBy?: GroupBy;
impersonationId: string;
userId: string; userId: string;
}): Promise<PortfolioInvestmentsResponse> { }): Promise<PortfolioInvestmentsResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate; const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate;
@ -492,7 +500,7 @@ export class PortfolioService {
public async getDetails({ public async getDetails({
dateRange = DEFAULT_DATE_RANGE, dateRange = DEFAULT_DATE_RANGE,
filters, filters,
impersonationId, user: userFromCaller,
userId, userId,
withExcludedAccounts = false, withExcludedAccounts = false,
withMarkets = false, withMarkets = false,
@ -500,14 +508,14 @@ export class PortfolioService {
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string; user?: UserWithSettings;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
withMarkets?: boolean; withMarkets?: boolean;
withSummary?: boolean; withSummary?: boolean;
}): Promise<PortfolioDetails & { hasErrors: boolean }> { }): Promise<PortfolioDetails & { hasErrors: boolean }> {
userId = await this.getUserId(impersonationId, userId); const user =
const user = await this.userService.user({ id: userId }); userFromCaller ?? (await this.userService.user({ id: userId }));
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const emergencyFund = new Big( const emergencyFund = new Big(
@ -741,7 +749,6 @@ export class PortfolioService {
if (withSummary) { if (withSummary) {
summary = await this.getSummary({ summary = await this.getSummary({
filteredValueInBaseCurrency, filteredValueInBaseCurrency,
impersonationId,
portfolioCalculator, portfolioCalculator,
userCurrency, userCurrency,
userId, userId,
@ -767,14 +774,11 @@ export class PortfolioService {
public async getHolding({ public async getHolding({
dataSource, dataSource,
impersonationId,
symbol, symbol,
userId userId
}: { }: {
impersonationId?: string;
userId: string; userId: string;
} & AssetProfileIdentifier): Promise<PortfolioHoldingResponse> { } & AssetProfileIdentifier): Promise<PortfolioHoldingResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
@ -1016,16 +1020,13 @@ export class PortfolioService {
public async getPerformance({ public async getPerformance({
dateRange = DEFAULT_DATE_RANGE, dateRange = DEFAULT_DATE_RANGE,
filters, filters,
impersonationId,
userId userId
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
@ -1118,23 +1119,44 @@ export class PortfolioService {
} }
public async getReport({ public async getReport({
impersonationId,
userId userId
}: { }: {
impersonationId: string;
userId: string; userId: string;
}): Promise<PortfolioReportResponse> { }): Promise<PortfolioReportResponse> {
userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId });
const userSettings = this.request.user.settings.settings as UserSettings;
// 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 } = const { accounts, holdings, markets, marketsAdvanced, summary } =
await this.getDetails({ await this.getDetails({
impersonationId, user,
userId, userId,
withMarkets: true, withMarkets: true,
withSummary: 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 hasOpenHoldings = Object.keys(holdings).length > 0;
const marketsAdvancedTotalInBaseCurrency = getSum( const marketsAdvancedTotalInBaseCurrency = getSum(
@ -1180,12 +1202,22 @@ export class PortfolioService {
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService, this.i18nService,
userSettings.language, userSettings.language,
this.getTotalEmergencyFund({ totalEmergencyFundInBaseCurrency
userSettings, ),
emergencyFundHoldingsValueInBaseCurrency: // The coverage is only meaningful once an emergency fund has been
this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings }) // set up, either by an amount or by the tagged holdings
}).toNumber() ...(totalEmergencyFundInBaseCurrency > 0
? [
new EmergencyFundCoverage(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
emergencyFundInBaseCurrency,
emergencyFundHoldingsValueInBaseCurrency,
cashBalanceInBaseCurrency
) )
]
: [])
], ],
userSettings userSettings
) )
@ -1865,7 +1897,6 @@ export class PortfolioService {
balanceInBaseCurrency, balanceInBaseCurrency,
emergencyFundHoldingsValueInBaseCurrency, emergencyFundHoldingsValueInBaseCurrency,
filteredValueInBaseCurrency, filteredValueInBaseCurrency,
impersonationId,
portfolioCalculator, portfolioCalculator,
userCurrency, userCurrency,
userId userId
@ -1873,12 +1904,10 @@ export class PortfolioService {
balanceInBaseCurrency: number; balanceInBaseCurrency: number;
emergencyFundHoldingsValueInBaseCurrency: number; emergencyFundHoldingsValueInBaseCurrency: number;
filteredValueInBaseCurrency: Big; filteredValueInBaseCurrency: Big;
impersonationId: string;
portfolioCalculator: PortfolioCalculator; portfolioCalculator: PortfolioCalculator;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<PortfolioSummary> { }): Promise<PortfolioSummary> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const { activities } = await this.activitiesService.getActivities({ const { activities } = await this.activitiesService.getActivities({
@ -1911,7 +1940,6 @@ export class PortfolioService {
} = await portfolioCalculator.getSnapshot(); } = await portfolioCalculator.getSnapshot();
const { performance } = await this.getPerformance({ const { performance } = await this.getPerformance({
impersonationId,
userId userId
}); });
@ -2074,8 +2102,8 @@ export class PortfolioService {
}) { }) {
return getSum( return getSum(
activities activities
.filter(({ isDraft, type }) => { .filter((activity) => {
return isDraft === false && type === activityType; return !isDraftActivity(activity) && activity.type === activityType;
}) })
.map(({ assetProfile, currency, quantity, unitPrice }) => { .map(({ assetProfile, currency, quantity, unitPrice }) => {
return new Big( return new Big(
@ -2105,18 +2133,7 @@ export class PortfolioService {
} }
private getUserCurrency(aUser?: UserWithSettings) { private getUserCurrency(aUser?: UserWithSettings) {
return ( return aUser?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
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;
} }
private getUserPerformanceCalculationType( private getUserPerformanceCalculationType(
@ -2143,6 +2160,10 @@ export class PortfolioService {
const accounts: PortfolioDetails['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
const platforms: PortfolioDetails['platforms'] = {}; const platforms: PortfolioDetails['platforms'] = {};
const { SYMBOL: [filterBySymbol] = [] } = groupBy(filters, ({ type }) => {
return type;
});
let currentAccounts: (AccountWithBalance & { let currentAccounts: (AccountWithBalance & {
Order?: Order[]; Order?: Order[];
platform?: Platform; platform?: Platform;
@ -2154,7 +2175,7 @@ export class PortfolioService {
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') { } else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
currentAccounts = await this.accountService.accounts({ currentAccounts = await this.accountService.accounts({
include: { platform: true, tags: true }, include: { platform: true, tags: true },
where: { id: filters[0].id } where: { userId, id: filters[0].id }
}); });
} else { } else {
const accountIds = Array.from( const accountIds = Array.from(
@ -2182,77 +2203,105 @@ export class PortfolioService {
// Iterate over the accounts plus a null entry to group activities without // Iterate over the accounts plus a null entry to group activities without
// an account into the unknown bucket // an account into the unknown bucket
for (const account of [...currentAccounts, null]) { for (const account of [...currentAccounts, null]) {
const currentAccountId = account?.id || UNKNOWN_KEY;
const currentPlatformId = account?.platformId || UNKNOWN_KEY;
const ordersByAccount = activities.filter(({ accountId }) => { const ordersByAccount = activities.filter(({ accountId }) => {
return account ? accountId === account.id : !accountId; return account ? accountId === account.id : !accountId;
}); });
if (account) { if (account) {
accounts[account.id] = { // The cash balance is not part of a holding and would distort the value
balance: account.balance, // and thus the allocation per account and platform
currency: account.currency, const balanceInBaseCurrency = filterBySymbol
name: account.name, ? 0
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( : this.exchangeRateDataService.toCurrency(
account.balance, account.balance,
account.currency, account.currency,
userCurrency userCurrency
) );
accounts[currentAccountId] = {
balance: account.balance,
currency: account.currency,
name: account.name,
valueInBaseCurrency: balanceInBaseCurrency
}; };
if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) { if (platforms[currentPlatformId]) {
platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency += platforms[currentPlatformId].valueInBaseCurrency = new Big(
this.exchangeRateDataService.toCurrency( platforms[currentPlatformId].valueInBaseCurrency
account.balance, )
account.currency, .plus(balanceInBaseCurrency)
userCurrency .toNumber();
);
} else { } else {
platforms[account.platformId || UNKNOWN_KEY] = { platforms[currentPlatformId] = {
balance: account.balance, balance: account.balance,
currency: account.currency, currency: account.currency,
name: account.platform?.name, name: account.platform?.name,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: balanceInBaseCurrency
account.balance,
account.currency,
userCurrency
)
}; };
} }
} }
for (const { account, assetProfile, quantity, type } of ordersByAccount) { if (ordersByAccount.length === 0) {
const currentValueOfSymbolInBaseCurrency = continue;
getFactor(type) * }
quantity *
(portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ??
0);
if (accounts[account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { let quantityOfAccount = new Big(0);
accounts[account?.id || UNKNOWN_KEY].valueInBaseCurrency += let valueOfAccountInBaseCurrency = new Big(0);
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 { } else {
accounts[account?.id || UNKNOWN_KEY] = { accounts[currentAccountId] = {
balance: 0, balance: 0,
currency: account?.currency, currency: account?.currency,
name: account?.name, name: account?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency quantity: quantityOfHolding,
valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber()
}; };
} }
if ( if (platforms[currentPlatformId]) {
platforms[account?.platformId || UNKNOWN_KEY]?.valueInBaseCurrency platforms[currentPlatformId].valueInBaseCurrency = new Big(
) { platforms[currentPlatformId].valueInBaseCurrency
platforms[account?.platformId || UNKNOWN_KEY].valueInBaseCurrency += )
currentValueOfSymbolInBaseCurrency; .plus(valueOfAccountInBaseCurrency)
.toNumber();
} else { } else {
platforms[account?.platformId || UNKNOWN_KEY] = { platforms[currentPlatformId] = {
balance: 0, balance: 0,
currency: account?.currency, currency: account?.currency,
name: account?.platform?.name, name: account?.platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber()
}; };
} }
} }
}
return { accounts, platforms }; return { accounts, platforms };
} }

2
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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.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'; import { SubscriptionService } from './subscription.service';
@AllowDuringImpersonation()
@Controller('subscription') @Controller('subscription')
export class SubscriptionController { export class SubscriptionController {
private readonly logger = new Logger(SubscriptionController.name); private readonly logger = new Logger(SubscriptionController.name);

2
apps/api/src/app/symbol/symbol.service.ts

@ -198,7 +198,7 @@ export class SymbolService {
results.items = items; results.items = items;
return results; return results;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }

45
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 { 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 { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.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 { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; 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 { 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 { 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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
HEADER_KEY_IMPERSONATION,
THROTTLE_SIGNUP_LIMIT, THROTTLE_SIGNUP_LIMIT,
THROTTLE_SIGNUP_TTL THROTTLE_SIGNUP_TTL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -18,6 +19,7 @@ import {
UpdateOwnAccessTokenDto, UpdateOwnAccessTokenDto,
UpdateUserSettingDto UpdateUserSettingDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { isUserSettingOfAuthenticatedUser } from '@ghostfolio/common/helper';
import { import {
AccessTokenResponse, AccessTokenResponse,
User, User,
@ -25,7 +27,10 @@ import {
UserSettings UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type {
ImpersonationContext,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -51,11 +56,11 @@ import { merge, size } from 'lodash';
import { UserService } from './user.service'; import { UserService } from './user.service';
@AllowDuringImpersonation()
@Controller('user') @Controller('user')
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -119,18 +124,16 @@ export class UserController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string, @Headers('accept-language') acceptLanguage: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Impersonation() { isActive, scopes, userId }: ImpersonationContext
): Promise<User> { ): Promise<User> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
return this.userService.getUser({ return this.userService.getUser({
impersonationUserId, scopes,
impersonationUserId: isActive ? userId : undefined,
locale: acceptLanguage?.split(',')?.[0], locale: acceptLanguage?.split(',')?.[0],
user: this.request.user user: this.request.user
}); });
@ -167,9 +170,27 @@ export class UserController {
} }
@Put('setting') @Put('setting')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @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 ( if (
size(data) === 1 && size(data) === 1 &&
(data.benchmark || data.dateRange) && (data.benchmark || data.dateRange) &&

60
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 { 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 { 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 { 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 { 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 { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume';
import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power'; import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power';
@ -34,11 +35,13 @@ import {
PROPERTY_MAX_DAILY_REQUESTS, PROPERTY_MAX_DAILY_REQUESTS,
PROPERTY_REFERRAL_PARTNERS, PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
THROTTLE_DAILY_KEY, THROTTLE_DAILY_KEY,
THROTTLE_DAILY_TTL THROTTLE_DAILY_TTL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { resolveUserSettings } from '@ghostfolio/common/helper';
import { import {
User as IUser, User as IUser,
ReferralPartner, ReferralPartner,
@ -50,13 +53,14 @@ import {
hasRole, hasRole,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { getScopesOfAccess } from '@ghostfolio/common/scopes';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler'; 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 { differenceInDays, subDays } from 'date-fns';
import { isNil, without } from 'lodash'; import { isNil, without } from 'lodash';
import { createHmac } from 'node:crypto'; import { createHmac } from 'node:crypto';
@ -112,10 +116,12 @@ export class UserService {
public async getUser({ public async getUser({
impersonationUserId, impersonationUserId,
locale = DEFAULT_LOCALE, locale = DEFAULT_LOCALE,
scopes,
user user
}: { }: {
impersonationUserId: string; impersonationUserId: string;
locale?: string; locale?: string;
scopes: string[];
user: UserWithSettings; user: UserWithSettings;
}): Promise<IUser> { }): Promise<IUser> {
const { id, permissions, settings, subscription } = user; const { id, permissions, settings, subscription } = user;
@ -125,7 +131,7 @@ export class UserService {
accounts, accounts,
activitiesCount, activitiesCount,
firstActivity, firstActivity,
impersonationUserSettings, impersonationUser,
tagsForUser tagsForUser
] = await Promise.all([ ] = await Promise.all([
this.prismaService.access.findMany({ this.prismaService.access.findMany({
@ -154,36 +160,39 @@ export class UserService {
where: { userId: impersonationUserId || user.id } where: { userId: impersonationUserId || user.id }
}), }),
impersonationUserId impersonationUserId
? this.prismaService.settings.findUnique({ ? this.user({ id: impersonationUserId })
where: { userId: impersonationUserId } : Promise.resolve<UserWithSettings>(null),
})
: Promise.resolve<Settings>(null),
this.tagService.getTagsForUser(impersonationUserId || user.id) this.tagService.getTagsForUser(impersonationUserId || user.id)
]); ]);
const baseCurrency = const resolvedUserSettings = resolveUserSettings({
(impersonationUserSettings?.settings as UserSettings)?.baseCurrency ?? impersonationUserSettings: impersonationUser?.settings
(settings.settings as UserSettings)?.baseCurrency; ?.settings as UserSettings,
userSettings: settings.settings as UserSettings
});
let referralPartners: ReferralPartner[]; let referralPartners: ReferralPartner[] = [];
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === SubscriptionType.Basic subscription?.type === SubscriptionType.Basic
) { ) {
referralPartners = await this.propertyService.getByKey<ReferralPartner[]>( referralPartners = await this.propertyService.getByKey<ReferralPartner[]>(
PROPERTY_REFERRAL_PARTNERS PROPERTY_REFERRAL_PARTNERS
); );
} }
let systemMessage: SystemMessage; let systemMessage: SystemMessage | undefined;
const systemMessageProperty = const systemMessageProperty =
await this.propertyService.getByKey<SystemMessage>( await this.propertyService.getByKey<SystemMessage>(
PROPERTY_SYSTEM_MESSAGE PROPERTY_SYSTEM_MESSAGE
); );
if (systemMessageProperty?.targetGroups?.includes(subscription?.type)) { if (
subscription?.type &&
systemMessageProperty?.targetGroups?.includes(subscription.type)
) {
systemMessage = systemMessageProperty; systemMessage = systemMessageProperty;
} }
@ -191,10 +200,10 @@ export class UserService {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === SubscriptionType.Basic subscription?.type === SubscriptionType.Basic
) { ) {
tags = tags.filter(({ id }) => { 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, id,
permissions, permissions,
referralPartners, referralPartners,
scopes,
subscription, subscription,
systemMessage, systemMessage,
tags, tags,
@ -210,7 +220,8 @@ export class UserService {
return { return {
alias: accessItem.alias, alias: accessItem.alias,
id: accessItem.id, id: accessItem.id,
permissions: accessItem.permissions permissions: accessItem.permissions,
scopes: getScopesOfAccess(accessItem)
}; };
}), }),
accounts: accounts.sort((a, b) => { accounts: accounts.sort((a, b) => {
@ -218,9 +229,9 @@ export class UserService {
}), }),
dateOfFirstActivity: firstActivity?.date ?? new Date(), dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: { settings: {
...(settings.settings as UserSettings), ...resolvedUserSettings,
baseCurrency, baseCurrency: resolvedUserSettings.baseCurrency ?? DEFAULT_CURRENCY,
locale: (settings.settings as UserSettings)?.locale ?? locale locale: resolvedUserSettings.locale ?? locale
} }
}; };
} }
@ -279,7 +290,6 @@ export class UserService {
activities: true activities: true
} }
}, },
accessesGet: true,
accounts: { accounts: {
include: { platform: true } include: { platform: true }
}, },
@ -296,7 +306,6 @@ export class UserService {
const { const {
_count, _count,
accessesGet,
accessToken, accessToken,
accounts, accounts,
analytics, analytics,
@ -314,7 +323,6 @@ export class UserService {
const activitiesCount = _count?.activities ?? 0; const activitiesCount = _count?.activities ?? 0;
const user: UserWithSettings = { const user: UserWithSettings = {
accessesGet,
accessToken, accessToken,
accounts, accounts,
authChallenge, authChallenge,
@ -448,6 +456,14 @@ export class UserService {
undefined, undefined,
undefined undefined
).getSettings(user.settings.settings), ).getSettings(user.settings.settings),
EmergencyFundCoverage: new EmergencyFundCoverage(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
).getSettings(user.settings.settings),
EmergencyFundSetup: new EmergencyFundSetup( EmergencyFundSetup: new EmergencyFundSetup(
undefined, undefined,
undefined, undefined,

127
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json

@ -113,7 +113,7 @@
"2OMB": "2omb Finance", "2OMB": "2omb Finance",
"2SHARES": "2SHARE", "2SHARES": "2SHARE",
"2TF": "2TF", "2TF": "2TF",
"2Z": "DoubleZero USD Price", "2Z": "DoubleZero",
"300F": "300FIT", "300F": "300FIT",
"314DAO": "Tonken 314 DAO", "314DAO": "Tonken 314 DAO",
"32BIT": "32Bitcoin", "32BIT": "32Bitcoin",
@ -212,7 +212,7 @@
"AAVEGOTCHIFOMO": "Aavegotchi FOMO", "AAVEGOTCHIFOMO": "Aavegotchi FOMO",
"AAX": "Academic Labs", "AAX": "Academic Labs",
"AAZ": "ATLAZ", "AAZ": "ATLAZ",
"AB": "Arma The Battle Ground USD Price", "AB": "Arma The Battle Ground",
"AB1INCH": "1inch (Avalanche Bride)", "AB1INCH": "1inch (Avalanche Bride)",
"ABA": "EcoBall", "ABA": "EcoBall",
"ABAT": "Aave BAT", "ABAT": "Aave BAT",
@ -806,7 +806,7 @@
"AMY": "Amygws", "AMY": "Amygws",
"AMZE": "The Amaze World", "AMZE": "The Amaze World",
"AMZNON": "Amazon (Ondo Tokenized)", "AMZNON": "Amazon (Ondo Tokenized)",
"AMZNX": "Amazon tokenized stock (xStock) USD Price", "AMZNX": "Amazon tokenized stock (xStock)",
"ANA": "Nirvana ANA", "ANA": "Nirvana ANA",
"ANAL": "AnalCoin", "ANAL": "AnalCoin",
"ANALOS": "analoS", "ANALOS": "analoS",
@ -845,7 +845,7 @@
"ANIM": "Animalia", "ANIM": "Animalia",
"ANIMA": "Realm Anima", "ANIMA": "Realm Anima",
"ANIME": "Animecoin", "ANIME": "Animecoin",
"ANIME35319": "Animecoin USD Price", "ANIME35319": "Animecoin",
"ANIMECOIN": "Animecoin", "ANIMECOIN": "Animecoin",
"ANIMEONBASE": "Anime", "ANIMEONBASE": "Anime",
"ANIMETOKEN": "Anime Token (animetoken.in)", "ANIMETOKEN": "Anime Token (animetoken.in)",
@ -1016,7 +1016,7 @@
"ARBUZ": "ARBUZ", "ARBUZ": "ARBUZ",
"ARBYS": "Arbys Token", "ARBYS": "Arbys Token",
"ARC": "AI Rig Complex", "ARC": "AI Rig Complex",
"ARC23486": "Arcadeum USD Price", "ARC23486": "Arcadeum",
"ARCA": "Legend of Arcadia", "ARCA": "Legend of Arcadia",
"ARCAD": "Arcadeum", "ARCAD": "Arcadeum",
"ARCADE": "ARCADE", "ARCADE": "ARCADE",
@ -1367,7 +1367,7 @@
"AVGOX": "Broadcom tokenized stock (xStock)", "AVGOX": "Broadcom tokenized stock (xStock)",
"AVH": "Animation Vision Cash", "AVH": "Animation Vision Cash",
"AVI": "Aviator", "AVI": "Aviator",
"AVICI": "Avici USD Price", "AVICI": "Avici",
"AVINOC": "AVINOC", "AVINOC": "AVINOC",
"AVIVE": "Avive World", "AVIVE": "Avive World",
"AVL": "Aston Villa Fan Token", "AVL": "Aston Villa Fan Token",
@ -1390,7 +1390,7 @@
"AWAX": "AWAX", "AWAX": "AWAX",
"AWBTC": "Aave interest bearing WBTC", "AWBTC": "Aave interest bearing WBTC",
"AWC": "Atomic Wallet Coin", "AWC": "Atomic Wallet Coin",
"AWE": "AWE Network USD Price", "AWE": "AWE Network",
"AWK": "Awkward Monkey Base", "AWK": "Awkward Monkey Base",
"AWM": "Another World", "AWM": "Another World",
"AWNEX": "AWNEX token", "AWNEX": "AWNEX token",
@ -1452,7 +1452,7 @@
"AZUR": "Azuro Protocol", "AZUR": "Azuro Protocol",
"AZURE": "Azure Wallet", "AZURE": "Azure Wallet",
"AZY": "Amazy", "AZY": "Amazy",
"B": "BUILDon USD Price", "B": "BUILDon",
"B01": "b0rder1ess", "B01": "b0rder1ess",
"B1P": "B ONE PAYMENT", "B1P": "B ONE PAYMENT",
"B2": "B² Network", "B2": "B² Network",
@ -1974,7 +1974,7 @@
"BEPE": "Blast Pepe", "BEPE": "Blast Pepe",
"BEPR": "Blockchain Euro Project", "BEPR": "Blockchain Euro Project",
"BEPRO": "BEPRO Network", "BEPRO": "BEPRO Network",
"BERA": "Berachain USD Price", "BERA": "Berachain",
"BERAETH": "Berachain Staked ETH", "BERAETH": "Berachain Staked ETH",
"BERASTONE": "StakeStone Berachain Vault Token", "BERASTONE": "StakeStone Berachain Vault Token",
"BERC": "Fair BERC20", "BERC": "Fair BERC20",
@ -2129,6 +2129,7 @@
"BIKE": "White Bike", "BIKE": "White Bike",
"BIKI": "BIKI", "BIKI": "BIKI",
"BILL": "TillBilly", "BILL": "TillBilly",
"BILL39545": "Billions Network",
"BILLI": "Billi", "BILLI": "Billi",
"BILLICAT": "BilliCat", "BILLICAT": "BilliCat",
"BILLY": "Billy", "BILLY": "Billy",
@ -2614,7 +2615,7 @@
"BONDLYV1": "Bondly Finance", "BONDLYV1": "Bondly Finance",
"BONDX": "BondX", "BONDX": "BondX",
"BONE": "Shibarium Wrapped BONE", "BONE": "Shibarium Wrapped BONE",
"BONE11865": "Bone ShibaSwap USD Price", "BONE11865": "Bone ShibaSwap",
"BONEBONE": "Bone", "BONEBONE": "Bone",
"BONES": "Moonshots Farm", "BONES": "Moonshots Farm",
"BONESCOIN": "BonesCoin", "BONESCOIN": "BonesCoin",
@ -3715,7 +3716,7 @@
"CHILL": "ChillPill", "CHILL": "ChillPill",
"CHILLAX": "Chillax", "CHILLAX": "Chillax",
"CHILLCAT": "Chillchat", "CHILLCAT": "Chillchat",
"CHILLGUY": "Just a chill guy USD Price", "CHILLGUY": "Just a chill guy",
"CHILLHOUSE": "Chill House", "CHILLHOUSE": "Chill House",
"CHIM": "Chimera", "CHIM": "Chimera",
"CHINA": "China Coin", "CHINA": "China Coin",
@ -3965,7 +3966,7 @@
"CO2": "CO2 Token", "CO2": "CO2 Token",
"COA": "Alliance Games", "COA": "Alliance Games",
"COAI": "ChainOpera AI", "COAI": "ChainOpera AI",
"COAI38489": "ChainOpera AI USD Price", "COAI38489": "ChainOpera AI",
"COAL": "BitCoal", "COAL": "BitCoal",
"COB": "Cobinhood", "COB": "Cobinhood",
"COBE": "Castle of Blackwater", "COBE": "Castle of Blackwater",
@ -5260,7 +5261,7 @@
"DOFI": "Doge Floki Coin", "DOFI": "Doge Floki Coin",
"DOG": "Dog (Runes)", "DOG": "Dog (Runes)",
"DOG11557": "The Doge NFT", "DOG11557": "The Doge NFT",
"DOG30933": "Dog (Runes) USD Price", "DOG30933": "Dog (Runes)",
"DOGA": "DOGAMÍ", "DOGA": "DOGAMÍ",
"DOGACOIN": "DogaCoin", "DOGACOIN": "DogaCoin",
"DOGAI": "Dogai", "DOGAI": "Dogai",
@ -5527,7 +5528,7 @@
"DRT": "DomRaider", "DRT": "DomRaider",
"DRUGS": "Big Pharmai", "DRUGS": "Big Pharmai",
"DRV": "Derive", "DRV": "Derive",
"DRV35014": "Derive USD Price", "DRV35014": "Derive",
"DRX": "DRX Token", "DRX": "DRX Token",
"DRXNE": "Droxne", "DRXNE": "Droxne",
"DRZ": "Droidz", "DRZ": "Droidz",
@ -6077,7 +6078,7 @@
"ENRON": "Enron", "ENRON": "Enron",
"ENRX": "Enrex", "ENRX": "Enrex",
"ENS": "Ethereum Name Service", "ENS": "Ethereum Name Service",
"ENSO": "Enso USD Price", "ENSO": "Enso",
"ENT": "Eternity", "ENT": "Eternity",
"ENTC": "ENTERBUTTON", "ENTC": "ENTERBUTTON",
"ENTER": "EnterCoin", "ENTER": "EnterCoin",
@ -6629,7 +6630,7 @@
"FEY": "Feyorra", "FEY": "Feyorra",
"FF": "Forefront", "FF": "Forefront",
"FF1": "Two Prime FF1 Token", "FF1": "Two Prime FF1 Token",
"FF38482": "Falcon Finance USD Price", "FF38482": "Falcon Finance",
"FFA": "Cryptofifa", "FFA": "Cryptofifa",
"FFC": "FireflyCoin", "FFC": "FireflyCoin",
"FFCT": "FortFC", "FFCT": "FortFC",
@ -6678,7 +6679,7 @@
"FIGHTPEPE": "FIGHT PEPE", "FIGHTPEPE": "FIGHT PEPE",
"FIGHTRUMP": "FIGHT TRUMP", "FIGHTRUMP": "FIGHT TRUMP",
"FIGMA": "Figments Club", "FIGMA": "Figments Club",
"FIGRHELOC": "Figure HELOC USD Price", "FIGRHELOC": "Figure HELOC",
"FIH": "Fidelity House", "FIH": "Fidelity House",
"FIII": "Fiii", "FIII": "Fiii",
"FIL": "Filecoin", "FIL": "Filecoin",
@ -6955,7 +6956,7 @@
"FORGE": "Forge Finance", "FORGE": "Forge Finance",
"FORK": "Gastro Advisor Token", "FORK": "Gastro Advisor Token",
"FORM": "Formation Fi", "FORM": "Formation Fi",
"FORM23635": "Four USD Price", "FORM23635": "Four",
"FORMATION": "Formation FI", "FORMATION": "Formation FI",
"FORMNET": "Form", "FORMNET": "Form",
"FORS": "Foresight", "FORS": "Foresight",
@ -7555,7 +7556,7 @@
"GIFT": "GiftNet", "GIFT": "GiftNet",
"GIG": "GigaCoin", "GIG": "GigaCoin",
"GIGA": "Gigachad", "GIGA": "Gigachad",
"GIGA30063": "Gigachad USD Price", "GIGA30063": "Gigachad",
"GIGABRAIN": "Gigabrain by virtuals", "GIGABRAIN": "Gigabrain by virtuals",
"GIGACAT": "GIGACAT", "GIGACAT": "GIGACAT",
"GIGACHAD": "GigaChad", "GIGACHAD": "GigaChad",
@ -7911,7 +7912,7 @@
"GRID": "Grid+", "GRID": "Grid+",
"GRIDCOIN": "GridCoin", "GRIDCOIN": "GridCoin",
"GRIDZ": "GridZone.io", "GRIDZ": "GridZone.io",
"GRIFFAIN": "GRIFFAIN USD Price", "GRIFFAIN": "GRIFFAIN",
"GRIFT": "ORBIT", "GRIFT": "ORBIT",
"GRIM": "GrimToken", "GRIM": "GrimToken",
"GRIMACE": "GrimaceCoin", "GRIMACE": "GrimaceCoin",
@ -8108,7 +8109,7 @@
"GZT": "Golden Zen Token", "GZT": "Golden Zen Token",
"GZX": "GreenZoneX", "GZX": "GreenZoneX",
"Glo Dollar": "USDGLO", "Glo Dollar": "USDGLO",
"H": "Humanity Protocol USD Price", "H": "Humanity Protocol",
"H1": "Haven1", "H1": "Haven1",
"H1DR4": "H1DR4 by Virtuals", "H1DR4": "H1DR4 by Virtuals",
"H2O": "H2O DAO", "H2O": "H2O DAO",
@ -8545,7 +8546,7 @@
"HOSTAI": "Host AI", "HOSTAI": "Host AI",
"HOT": "Holo", "HOT": "Holo",
"HOT1": "Holo", "HOT1": "Holo",
"HOT2682": "Holo USD Price", "HOT2682": "Holo",
"HOTCROSS": "Hot Cross", "HOTCROSS": "Hot Cross",
"HOTDOGE": "Hot Doge", "HOTDOGE": "Hot Doge",
"HOTKEY": "HotKeySwap", "HOTKEY": "HotKeySwap",
@ -8952,7 +8953,7 @@
"INFX": "Influxcoin", "INFX": "Influxcoin",
"ING": "Infinity Games", "ING": "Infinity Games",
"INI": "InitVerse", "INI": "InitVerse",
"INIT": "Initia USD Price", "INIT": "Initia",
"INJ": "Injective", "INJ": "Injective",
"INK": "Ink", "INK": "Ink",
"INN": "Innova", "INN": "Innova",
@ -9293,7 +9294,7 @@
"JINDO": "JINDOGE", "JINDO": "JINDOGE",
"JINDOGE": "Jindoge", "JINDOGE": "Jindoge",
"JIO": "JIO Token", "JIO": "JIO Token",
"JITOSOL": "Jito Staked SOL USD Price", "JITOSOL": "Jito Staked SOL",
"JIZZ": "JizzRocket", "JIZZ": "JizzRocket",
"JIZZLORD": "JizzLord", "JIZZLORD": "JizzLord",
"JIZZUS": "JIZZUS CHRIST", "JIZZUS": "JIZZUS CHRIST",
@ -9301,7 +9302,7 @@
"JK": "JK Coin", "JK": "JK Coin",
"JKC": "JunkCoin", "JKC": "JunkCoin",
"JKL": "Jackal Protocol", "JKL": "Jackal Protocol",
"JLP": "Jupiter Perps LP USD Price", "JLP": "Jupiter Perps LP",
"JLY": "Jellyverse", "JLY": "Jellyverse",
"JM": "JustMoney", "JM": "JustMoney",
"JMC": "Junson Ming Chan Coin", "JMC": "Junson Ming Chan Coin",
@ -9457,7 +9458,7 @@
"KAIM": "Kai Meme", "KAIM": "Kai Meme",
"KAINET": "KAINET", "KAINET": "KAINET",
"KAIRO": "Kairo", "KAIRO": "Kairo",
"KAITO": "KAITO USD Price", "KAITO": "KAITO",
"KAKA": "KAKA NFT World", "KAKA": "KAKA NFT World",
"KAKAXA": "KAKAXA", "KAKAXA": "KAKAXA",
"KAKI": "Doge KaKi", "KAKI": "Doge KaKi",
@ -9694,7 +9695,7 @@
"KISHU": "Kishu Inu", "KISHU": "Kishu Inu",
"KIT": "DexKit", "KIT": "DexKit",
"KITA": "KITA INU", "KITA": "KITA INU",
"KITE": "Kite USD Price", "KITE": "Kite",
"KITEAI": "KITEAI", "KITEAI": "KITEAI",
"KITEHAI": "Kite", "KITEHAI": "Kite",
"KITKAT": "Remember KitKat", "KITKAT": "Remember KitKat",
@ -10274,7 +10275,7 @@
"LISUSD": "lisUSD", "LISUSD": "lisUSD",
"LIT": "Lition", "LIT": "Lition",
"LIT1": "Litentry", "LIT1": "Litentry",
"LIT39125": "Lighter USD Price", "LIT39125": "Lighter",
"LITCOIN": "Litcoin", "LITCOIN": "Litcoin",
"LITE": "Lite USD", "LITE": "Lite USD",
"LITEBTC": "LiteBitcoin", "LITEBTC": "LiteBitcoin",
@ -10362,7 +10363,7 @@
"LOCG": "LOCGame", "LOCG": "LOCGame",
"LOCI": "LociCoin", "LOCI": "LociCoin",
"LOCK": "Meridian Network", "LOCK": "Meridian Network",
"LOCKIN": "LOCK IN USD Price", "LOCKIN": "LOCK IN",
"LOCO": "Loco", "LOCO": "Loco",
"LOCOM": "Locomotir", "LOCOM": "Locomotir",
"LOCUS": "Locus Chain", "LOCUS": "Locus Chain",
@ -10370,7 +10371,7 @@
"LOE": "Legends of Elysium", "LOE": "Legends of Elysium",
"LOF": "Land of Fantasy", "LOF": "Land of Fantasy",
"LOFI": "LOFI", "LOFI": "LOFI",
"LOFI34187": "LOFI USD Price", "LOFI34187": "LOFI",
"LOFIBUZZ": "LOFI", "LOFIBUZZ": "LOFI",
"LOG": "Wood Coin", "LOG": "Wood Coin",
"LOGO": "LOGOS", "LOGO": "LOGOS",
@ -10640,7 +10641,7 @@
"M0": "M by M^0", "M0": "M by M^0",
"M1": "SupplyShock", "M1": "SupplyShock",
"M2O": "M2O Token", "M2O": "M2O Token",
"M35491": "MemeCore USD Price", "M35491": "MemeCore",
"M3H": "MehVerseCoin", "M3H": "MehVerseCoin",
"M3M3": "M3M3", "M3M3": "M3M3",
"M87": "MESSIER", "M87": "MESSIER",
@ -11011,7 +11012,7 @@
"MEFAI": "META FINANCIAL AI", "MEFAI": "META FINANCIAL AI",
"MEFI": "Meo Finance", "MEFI": "Meo Finance",
"MEGA": "MegaCryptoPolis", "MEGA": "MegaCryptoPolis",
"MEGA38770": "MegaETH USD Price", "MEGA38770": "MegaETH",
"MEGABOT": "Megabot", "MEGABOT": "Megabot",
"MEGAD": "Mega Dice Casino", "MEGAD": "Mega Dice Casino",
"MEGAHERO": "MEGAHERO", "MEGAHERO": "MEGAHERO",
@ -11026,7 +11027,7 @@
"MEL": "MELX", "MEL": "MELX",
"MELAN": "Melania Meme", "MELAN": "Melania Meme",
"MELANIA": "Melania Meme", "MELANIA": "Melania Meme",
"MELANIA35347": "Official Melania Meme USD Price", "MELANIA35347": "Official Melania Meme",
"MELANIATRUMP": "Melania Trump", "MELANIATRUMP": "Melania Trump",
"MELB": "Minelab", "MELB": "Minelab",
"MELD": "MELD", "MELD": "MELD",
@ -11106,7 +11107,7 @@
"MESSI": "MESSI COIN", "MESSI": "MESSI COIN",
"MESSU": "Loinel Messu", "MESSU": "Loinel Messu",
"MET": "Metronome", "MET": "Metronome",
"MET38353": "Meteora USD Price", "MET38353": "Meteora",
"META": "Metadium", "META": "Metadium",
"METAA": "META ARENA", "METAA": "META ARENA",
"METABOT": "Robot Warriors", "METABOT": "Robot Warriors",
@ -11513,7 +11514,7 @@
"MOBYONBASEV1": "Moby v1", "MOBYONBASEV1": "Moby v1",
"MOC": "Moss Coin", "MOC": "Moss Coin",
"MOCA": "Moca Network", "MOCA": "Moca Network",
"MOCA31526": "Moca Network USD Price", "MOCA31526": "Moca Network",
"MOCHI": "Mochiswap", "MOCHI": "Mochiswap",
"MOCHI14315": "Mochi", "MOCHI14315": "Mochi",
"MOCHICAT": "MochiCat", "MOCHICAT": "MochiCat",
@ -11576,7 +11577,7 @@
"MOMO2": "MOMO 2.0", "MOMO2": "MOMO 2.0",
"MOMO2025": "momo", "MOMO2025": "momo",
"MON": "PocMon", "MON": "PocMon",
"MON30495": "Monad USD Price", "MON30495": "Monad",
"MON30950": "MON", "MON30950": "MON",
"MONA": "MonaCoin", "MONA": "MonaCoin",
"MONAI": "MONAI", "MONAI": "MONAI",
@ -11631,7 +11632,7 @@
"MOOBIFI": "Staked BIFI", "MOOBIFI": "Staked BIFI",
"MOOCAT": "MooCat", "MOOCAT": "MooCat",
"MOODENG": "Moo Deng (moodengsol.com)", "MOODENG": "Moo Deng (moodengsol.com)",
"MOODENG33093": "Moo Deng USD Price", "MOODENG33093": "Moo Deng",
"MOODENGBNB": "MOODENG (moodengbnb.com)", "MOODENGBNB": "MOODENG (moodengbnb.com)",
"MOODENGSBS": "Moo Deng (moodeng.sbs)", "MOODENGSBS": "Moo Deng (moodeng.sbs)",
"MOODENGSPACE": "MOO DENG", "MOODENGSPACE": "MOO DENG",
@ -12352,7 +12353,7 @@
"NIK": "NIKPLACE", "NIK": "NIKPLACE",
"NIKO": "NikolAI", "NIKO": "NikolAI",
"NIL": "Nillion", "NIL": "Nillion",
"NIL35702": "Nillion USD Price", "NIL35702": "Nillion",
"NILA": "MindWave", "NILA": "MindWave",
"NILE": "Nile", "NILE": "Nile",
"NIM": "Nimiq", "NIM": "Nimiq",
@ -13672,7 +13673,7 @@
"PIPL": "PiplCoin", "PIPL": "PiplCoin",
"PIPO": "Pipo", "PIPO": "Pipo",
"PIPONHL": "PiP", "PIPONHL": "PiP",
"PIPPIN": "pippin USD Price", "PIPPIN": "pippin",
"PIPPKIN": "Pippkin The Horse", "PIPPKIN": "Pippkin The Horse",
"PIPT": "Power Index Pool Token", "PIPT": "Power Index Pool Token",
"PIRATE": "PirateCash", "PIRATE": "PirateCash",
@ -14094,7 +14095,7 @@
"PROLIFIC": "Prolific Game Studio", "PROLIFIC": "Prolific Game Studio",
"PROM": "Prom", "PROM": "Prom",
"PROME": "Prometheus Trading", "PROME": "Prometheus Trading",
"PROMPT": "Wayfinder USD Price", "PROMPT": "Wayfinder",
"PRON": "Rise Of Nebula", "PRON": "Rise Of Nebula",
"PROOF": "PROVER", "PROOF": "PROVER",
"PROP": "Propeller", "PROP": "Propeller",
@ -14250,7 +14251,7 @@
"PURPE": "Purple Pepe", "PURPE": "Purple Pepe",
"PURPLEBTC": "Purple Bitcoin", "PURPLEBTC": "Purple Bitcoin",
"PURR": "Purr", "PURR": "Purr",
"PURR34332": "Purr USD Price", "PURR34332": "Purr",
"PURRC": "Purrcoin", "PURRC": "Purrcoin",
"PURSE": "Pundi X PURSE", "PURSE": "Pundi X PURSE",
"PUS": "Pussy Cat", "PUS": "Pussy Cat",
@ -14357,6 +14358,7 @@
"QGOLD": "Quorium", "QGOLD": "Quorium",
"QGOV": "Q Protocol", "QGOV": "Q Protocol",
"QI": "QiSwap", "QI": "QiSwap",
"QI9288": "BENQI",
"QIE": "QI Blockchain", "QIE": "QI Blockchain",
"QINGWA": "ShangXin QingWa", "QINGWA": "ShangXin QingWa",
"QISWAP": "QiSwap", "QISWAP": "QiSwap",
@ -14546,7 +14548,7 @@
"RATWIF": "RatWifHat", "RATWIF": "RatWifHat",
"RAV": "Ravelin Finance", "RAV": "Ravelin Finance",
"RAVE": "Ravendex", "RAVE": "Ravendex",
"RAVE38967": "RaveDAO USD Price", "RAVE38967": "RaveDAO",
"RAVELOUS": "Ravelous", "RAVELOUS": "Ravelous",
"RAVEN": "Raven Protocol", "RAVEN": "Raven Protocol",
"RAVENCOINC": "Ravencoin Classic", "RAVENCOINC": "Ravencoin Classic",
@ -14620,6 +14622,7 @@
"RDT": "Ridotto", "RDT": "Ridotto",
"RDWON": "Redwire (Ondo Tokenized)", "RDWON": "Redwire (Ondo Tokenized)",
"RDX": "Redux Protocol", "RDX": "Redux Protocol",
"RE": "Re",
"REA": "Realisto", "REA": "Realisto",
"REACH": "/Reach", "REACH": "/Reach",
"REACT": "Reactive Network", "REACT": "Reactive Network",
@ -14706,7 +14709,7 @@
"REINDEER": "Reindeer", "REINDEER": "Reindeer",
"REK": "Rekt", "REK": "Rekt",
"REKT": "Rekt", "REKT": "Rekt",
"REKT34434": "Rekt USD Price", "REKT34434": "Rekt",
"REKTV1": "REKT", "REKTV1": "REKT",
"REKTV2": "REKT 2.0", "REKTV2": "REKT 2.0",
"REKTV3": "REKT v3 (rekt.game)", "REKTV3": "REKT v3 (rekt.game)",
@ -15053,7 +15056,7 @@
"RRT": "Recovery Right Tokens", "RRT": "Recovery Right Tokens",
"RS": "ReadySwap", "RS": "ReadySwap",
"RSC": "ResearchCoin", "RSC": "ResearchCoin",
"RSC27054": "ResearchCoin USD Price", "RSC27054": "ResearchCoin",
"RSETH": "Kelp DAO Restaked ETH", "RSETH": "Kelp DAO Restaked ETH",
"RSF": "Royal Sting", "RSF": "Royal Sting",
"RSG": "RSG TOKEN", "RSG": "RSG TOKEN",
@ -15202,7 +15205,7 @@
"SAD": "SadCat", "SAD": "SadCat",
"SAF": "Safcoin", "SAF": "Safcoin",
"SAFE": "SafeCoin", "SAFE": "SafeCoin",
"SAFE21585": "Safe USD Price", "SAFE21585": "Safe",
"SAFEBTC": "SafeBTC", "SAFEBTC": "SafeBTC",
"SAFEBULL": "SafeBull", "SAFEBULL": "SafeBull",
"SAFECOIN": "SafeCoin", "SAFECOIN": "SafeCoin",
@ -15289,7 +15292,7 @@
"SAO": "Sator", "SAO": "Sator",
"SAP": "SwapAll", "SAP": "SwapAll",
"SAPE": "SolanaApe", "SAPE": "SolanaApe",
"SAPIEN": "Sapien USD Price", "SAPIEN": "Sapien",
"SAPP": "Sapphire", "SAPP": "Sapphire",
"SAPPC": "SappChat", "SAPPC": "SappChat",
"SAR": "Saren", "SAR": "Saren",
@ -15315,7 +15318,7 @@
"SATOX": "Satoxcoin", "SATOX": "Satoxcoin",
"SATOZ": "Satozhi", "SATOZ": "Satozhi",
"SATS": "SATS (Ordinals)", "SATS": "SATS (Ordinals)",
"SATS28194": "SATS (Ordinals) USD Price", "SATS28194": "SATS (Ordinals)",
"SATSALL": "ALL BEST ICO SATOSHI", "SATSALL": "ALL BEST ICO SATOSHI",
"SATT": "SaTT", "SATT": "SaTT",
"SATX": "SATX", "SATX": "SATX",
@ -15514,7 +15517,7 @@
"SENATE": "SENATE", "SENATE": "SENATE",
"SENC": "Sentinel Chain", "SENC": "Sentinel Chain",
"SEND": "Social Send", "SEND": "Social Send",
"SEND34611": "Suilend USD Price", "SEND34611": "Suilend",
"SENDCOIN": "Sendcoin", "SENDCOIN": "Sendcoin",
"SENDOR": "Sendor", "SENDOR": "Sendor",
"SENK": "Senk", "SENK": "Senk",
@ -16125,7 +16128,7 @@
"SNRG": "Synergy", "SNRG": "Synergy",
"SNRK": "Snark Launch", "SNRK": "Snark Launch",
"SNS": "Synesis One", "SNS": "Synesis One",
"SNS36468": "Solana Name Service USD Price", "SNS36468": "Solana Name Service",
"SNST": "Smooth Network Solutions Token", "SNST": "Smooth Network Solutions Token",
"SNSY": "Sensay", "SNSY": "Sensay",
"SNT": "Status", "SNT": "Status",
@ -16283,7 +16286,7 @@
"SONOF": "Son of Solana", "SONOF": "Son of Solana",
"SONOR": "SonorusToken", "SONOR": "SonorusToken",
"SOON": "SoonVerse", "SOON": "SoonVerse",
"SOON36542": "SOON USD Price", "SOON36542": "SOON",
"SOONAVERSE": "Soonaverse", "SOONAVERSE": "Soonaverse",
"SOONCOIN": "SoonCoin", "SOONCOIN": "SoonCoin",
"SOONTOKEN": "SOON", "SOONTOKEN": "SOON",
@ -17340,7 +17343,7 @@
"THOR": "THORSwap", "THOR": "THORSwap",
"THOREUM": "Thoreum V3", "THOREUM": "Thoreum V3",
"THP": "TurboHigh Performance", "THP": "TurboHigh Performance",
"THQ": "Theoriq Token", "THQ": "Theoriq",
"THR": "Thorecoin", "THR": "Thorecoin",
"THREE": "Three Protocol Token", "THREE": "Three Protocol Token",
"THRT": "ThriveToken", "THRT": "ThriveToken",
@ -17751,7 +17754,7 @@
"TRUMP2024": "Donald Trump", "TRUMP2024": "Donald Trump",
"TRUMP27872": "MAGA", "TRUMP27872": "MAGA",
"TRUMP3": "Trump MP3", "TRUMP3": "Trump MP3",
"TRUMP35336": "OFFICIAL TRUMP USD Price", "TRUMP35336": "OFFICIAL TRUMP",
"TRUMP47": "47th President of the United States", "TRUMP47": "47th President of the United States",
"TRUMPA": "TRUMP AI", "TRUMPA": "TRUMP AI",
"TRUMPAI": "Trump Maga AI", "TRUMPAI": "Trump Maga AI",
@ -17890,7 +17893,7 @@
"TURT": "TurtSat", "TURT": "TurtSat",
"TURT28826": "TurtSat", "TURT28826": "TurtSat",
"TURTLE": "Turtle", "TURTLE": "Turtle",
"TURTLE38671": "Turtle USD Price", "TURTLE38671": "Turtle",
"TUS": "Treasure Under Sea", "TUS": "Treasure Under Sea",
"TUSD": "TrueUSD", "TUSD": "TrueUSD",
"TUSDV1": "True USD v1", "TUSDV1": "True USD v1",
@ -18065,7 +18068,7 @@
"UMAMI": "Umami Finance", "UMAMI": "Umami Finance",
"UMB": "Umbrella Network", "UMB": "Umbrella Network",
"UMBR": "Umbria Network", "UMBR": "Umbria Network",
"UMBRA": "Umbra USD Price", "UMBRA": "Umbra",
"UMC": "Umbrella Coin", "UMC": "Umbrella Coin",
"UMI": "UMI", "UMI": "UMI",
"UMID": "Umi Digital", "UMID": "Umi Digital",
@ -18216,7 +18219,7 @@
"USCR": "United States Crypto Reserve", "USCR": "United States Crypto Reserve",
"USD0": "Usual USD", "USD0": "Usual USD",
"USD1": "World Liberty Financial USD", "USD1": "World Liberty Financial USD",
"USD136148": "World Liberty Financial USD USD Price", "USD136148": "World Liberty Financial USD",
"USD3": "Web 3 Dollar", "USD3": "Web 3 Dollar",
"USDA": "USDA", "USDA": "USDA",
"USDACC": "USDA", "USDACC": "USDA",
@ -18296,7 +18299,7 @@
"USE": "Usechain Token", "USE": "Usechain Token",
"USEDCAR": "A Gently Used 2001 Honda", "USEDCAR": "A Gently Used 2001 Honda",
"USELESS": "USELESS COIN", "USELESS": "USELESS COIN",
"USELESS36828": "Useless Coin USD Price", "USELESS36828": "Useless Coin",
"USETH": "USETH", "USETH": "USETH",
"USF": "Unslashed Finance", "USF": "Unslashed Finance",
"USG": "USGold", "USG": "USGold",
@ -18363,7 +18366,7 @@
"UWULEND": "UwU Lend", "UWULEND": "UwU Lend",
"UX": "UX Chain", "UX": "UX Chain",
"UXD": "UXD Stablecoin", "UXD": "UXD Stablecoin",
"UXLINK": "UXLINK USD Price", "UXLINK": "UXLINK",
"UXLINKV1": "UXLINK v1", "UXLINKV1": "UXLINK v1",
"UXOS": "UXOS", "UXOS": "UXOS",
"UXP": "UXD Protocol", "UXP": "UXD Protocol",
@ -19177,7 +19180,7 @@
"WLSC": "WESTLAND SMART CITY", "WLSC": "WESTLAND SMART CITY",
"WLTH": "Common Wealth", "WLTH": "Common Wealth",
"WLUNA": "Wrapped LUNA Token", "WLUNA": "Wrapped LUNA Token",
"WLUNC": "Wrapped LUNA Classic USD Price", "WLUNC": "Wrapped LUNA Classic",
"WLXT": "Wallex Token", "WLXT": "Wallex Token",
"WM": "WrappedM by M^0", "WM": "WrappedM by M^0",
"WMATIC": "Wrapped Matic", "WMATIC": "Wrapped Matic",
@ -19509,7 +19512,7 @@
"XCXT": "CoinonatX", "XCXT": "CoinonatX",
"XD": "Data Transaction Token", "XD": "Data Transaction Token",
"XDAG": "XDAG", "XDAG": "XDAG",
"XDAI": "xDAI", "XDAI": "XDAI",
"XDAO": "XDAO", "XDAO": "XDAO",
"XDATA": "Streamr XDATA", "XDATA": "Streamr XDATA",
"XDB": "DigitalBits", "XDB": "DigitalBits",
@ -19587,7 +19590,7 @@
"XIDR": "XIDR", "XIDR": "XIDR",
"XIL": "Project X", "XIL": "Project X",
"XIN": "Mixin", "XIN": "Mixin",
"XIN2349": "Mixin USD Price", "XIN2349": "Mixin",
"XING": "Xing Xing", "XING": "Xing Xing",
"XINGXING": "星星", "XINGXING": "星星",
"XINU": "XINU", "XINU": "XINU",
@ -19848,7 +19851,7 @@
"YAXIS": "yAxis", "YAXIS": "yAxis",
"YAY": "YAY Games", "YAY": "YAY Games",
"YAYCOIN": "YAYcoin", "YAYCOIN": "YAYcoin",
"YB": "YieldBasis USD Price", "YB": "YieldBasis",
"YBC": "YbCoin", "YBC": "YbCoin",
"YBDBD": "YBDBD", "YBDBD": "YBDBD",
"YBNB": "Yellow BNB 4", "YBNB": "Yellow BNB 4",
@ -20034,7 +20037,7 @@
"ZBCN": "Zebec Network", "ZBCN": "Zebec Network",
"ZBIT": "zbit", "ZBIT": "zbit",
"ZBT": "ZB Token", "ZBT": "ZB Token",
"ZBT38427": "ZEROBASE USD Price", "ZBT38427": "ZEROBASE",
"ZBU": "Zeebu", "ZBU": "Zeebu",
"ZBUV1": "ZEEBU v1", "ZBUV1": "ZEEBU v1",
"ZCC": "ZCC Coin", "ZCC": "ZCC Coin",

12
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);
}

28
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<RequestWithUser>();
return (
impersonation ?? {
isActive: false,
scopes: getScopesOfOwnAccess(),
userId: user?.id,
userSettings: user?.settings?.settings ?? {}
}
);
}
);

41
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
]);
});
});

26
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
)
);
}

53
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<boolean>(
ALLOW_DURING_IMPERSONATION_KEY,
[context.getHandler(), context.getClass()]
);
if (isAllowedDuringImpersonation) {
return true;
}
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}

25
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<RequestWithUser>();
request.impersonation = await this.impersonationService.resolve({
impersonationId: request.headers?.[
HEADER_KEY_IMPERSONATION.toLowerCase()
] as string,
user: request.user
});
return true;
}
}

21
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<IAuthGuard> {
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);
}

50
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<Scope[]>(
REQUIRES_SCOPE_KEY,
[context.getHandler(), context.getClass()]
);
if (!requiredScopes?.length) {
return true;
}
const { impersonation } = context
.switchToHttp()
.getRequest<RequestWithUser>();
const hasRequiredScopes = requiredScopes.every((scope) => {
return hasScope(impersonation?.scopes, scope);
});
if (!hasRequiredScopes) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return true;
}
}

74
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<T extends { id: string }>({
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;
}

7
apps/api/src/helper/country.helper.ts

@ -1,3 +1,4 @@
import { Logger } from '@nestjs/common';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
export function getCountryCodeByName({ 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; return undefined;
} }

29
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 { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { import { DEFAULT_REDACTED_PATHS } from '@ghostfolio/common/config';
DEFAULT_REDACTED_PATHS, import { isRestrictedView } from '@ghostfolio/common/permissions';
HEADER_KEY_IMPERSONATION import { hasScope, scopes } from '@ghostfolio/common/scopes';
} from '@ghostfolio/common/config'; import type { RequestWithUser } from '@ghostfolio/common/types';
import {
hasReadRestrictedAccessPermission,
isRestrictedView
} from '@ghostfolio/common/permissions';
import { UserWithSettings } from '@ghostfolio/common/types';
import { import {
CallHandler, CallHandler,
@ -29,17 +24,15 @@ export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
): Observable<any> { ): Observable<any> {
return next.handle().pipe( return next.handle().pipe(
map((data: any) => { map((data: any) => {
const { headers, user }: { headers: Headers; user: UserWithSettings } = const { impersonation, user } = context
context.switchToHttp().getRequest(); .switchToHttp()
.getRequest<RequestWithUser>();
const impersonationId =
headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
// 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 ( if (
hasReadRestrictedAccessPermission({ !hasScope(impersonation?.scopes, scopes.portfolioReadValues) ||
impersonationId,
accesses: user?.accessesGet
}) ||
isRestrictedView(user) isRestrictedView(user)
) { ) {
data = redactPaths({ data = redactPaths({

110
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<Settings> {
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;
}

114
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
};
}

58
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()
};
}

161
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
};
}

27
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<AssetProfileSplit[]> {
return this.prismaService.assetProfileSplit.findMany({
orderBy: [
{
date: 'asc'
}
],
where: {
symbolProfile: {
activities: {
some: {
userId
}
}
}
}
});
}
public async upsert({ public async upsert({
date, date,
denominator, denominator,

10
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 baseUrl = 'https://www.trackinsight.com';
private static countriesMapping = { private static countriesMapping = {
'Czech Republic': 'CZ',
'Republic of Korea': 'KR', 'Republic of Korea': 'KR',
'Russian Federation': 'RU', 'Russian Federation': 'RU',
Turkey: 'TR', Turkey: 'TR',
@ -125,13 +126,14 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
for (const [name, value] of Object.entries<any>( for (const [name, value] of Object.entries<any>(
holdings?.countries ?? {} holdings?.countries ?? {}
)) { )) {
response.countries.push({ const code = getCountryCodeByName({
code: getCountryCodeByName({
name, name,
aliases: TrackinsightDataEnhancerService.countriesMapping aliases: TrackinsightDataEnhancerService.countriesMapping
}),
weight: value.weight
}); });
if (code) {
response.countries.push({ code, weight: value.weight });
}
} }
} }

19
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 { getSectorName } from '@ghostfolio/api/helper/sector.helper';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error';
@ -18,12 +19,17 @@ import {
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { isISIN } from 'class-validator'; import { isISIN } from 'class-validator';
import { countries } from 'countries-list';
import YahooFinance from 'yahoo-finance2'; import YahooFinance from 'yahoo-finance2';
import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface'; import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface';
@Injectable() @Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
private static countriesMapping = {
'Czech Republic': 'CZ',
Macau: 'MO',
Turkey: 'TR'
};
private static sectorsMapping: Record<string, SectorName> = { private static sectorsMapping: Record<string, SectorName> = {
basic_materials: 'Basic Materials', basic_materials: 'Basic Materials',
communication_services: 'Communication Services', communication_services: 'Communication Services',
@ -130,7 +136,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
response.url = url; response.url = url;
} }
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
} }
return response; return response;
@ -246,15 +252,14 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
) { ) {
// Add country if asset is stock and country available // Add country if asset is stock and country available
try { const code = getCountryCodeByName({
const [code] = Object.entries(countries).find(([, country]) => { aliases: YahooFinanceDataEnhancerService.countriesMapping,
return country.name === assetProfile.summaryProfile?.country; name: assetProfile.summaryProfile.country
}); });
if (code) { if (code) {
response.countries = [{ code, weight: 1 }]; response.countries = [{ code, weight: 1 }];
} }
} catch {}
if (assetProfile.summaryProfile?.sector) { if (assetProfile.summaryProfile?.sector) {
response.sectors = [ response.sectors = [
@ -276,7 +281,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
`No data found, ${aSymbol} (${this.getName()}) may be delisted` `No data found, ${aSymbol} (${this.getName()}) may be delisted`
); );
} else { } else {
this.logger.error(error); this.logger.error(error.message);
} }
} }

14
apps/api/src/services/data-provider/data-provider.service.ts

@ -141,7 +141,7 @@ export class DataProviderService implements OnModuleInit {
); );
} }
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }
@ -340,7 +340,13 @@ export class DataProviderService implements OnModuleInit {
from: Date; from: Date;
granularity: Granularity; granularity: Granularity;
to: Date; to: Date;
} & AssetProfileIdentifier) { } & AssetProfileIdentifier): Promise<{
[date: string]: DataProviderHistoricalResponse;
}> {
if (!isValid(from) || !isValid(to)) {
return {};
}
return this.getDataProvider(DataSource[dataSource]).getDividends({ return this.getDataProvider(DataSource[dataSource]).getDividends({
from, from,
granularity, granularity,
@ -421,7 +427,7 @@ export class DataProviderService implements OnModuleInit {
return r; return r;
}, {}); }, {});
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
} finally { } finally {
return response; return response;
} }
@ -542,7 +548,7 @@ export class DataProviderService implements OnModuleInit {
result[getAssetProfileIdentifier({ dataSource, symbol })] = data; result[getAssetProfileIdentifier({ dataSource, symbol })] = data;
} }
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }

10
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -54,9 +54,11 @@ export class FinancialModelingPrepService
implements DataProviderInterface, OnModuleInit implements DataProviderInterface, OnModuleInit
{ {
private static countriesMapping = { private static countriesMapping = {
'Czech Republic': 'CZ',
'Korea (the Republic of)': 'KR', 'Korea (the Republic of)': 'KR',
'Russian Federation': 'RU', 'Russian Federation': 'RU',
'Taiwan (Province of China)': 'TW' 'Taiwan (Province of China)': 'TW',
Turkey: 'TR'
}; };
private readonly logger = new Logger(FinancialModelingPrepService.name); private readonly logger = new Logger(FinancialModelingPrepService.name);
@ -169,9 +171,6 @@ export class FinancialModelingPrepService
.then((res) => res.json()); .then((res) => res.json());
response.countries = etfCountryWeightings response.countries = etfCountryWeightings
.filter(({ country: countryName }) => {
return countryName.toLowerCase() !== 'other';
})
.map(({ country: countryName, weightPercentage }) => { .map(({ country: countryName, weightPercentage }) => {
return { return {
code: getCountryCodeByName({ code: getCountryCodeByName({
@ -180,6 +179,9 @@ export class FinancialModelingPrepService
}), }),
weight: parseFloat(`${weightPercentage}`) / 100 weight: parseFloat(`${weightPercentage}`) / 100
}; };
})
.filter(({ code }) => {
return !!code;
}); });
const etfHoldings = await this.fetchService const etfHoldings = await this.fetchService

2
apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts

@ -144,7 +144,7 @@ export class GoogleSheetsService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
} }
return {}; return {};

2
apps/api/src/services/data-provider/manual/manual.service.ts

@ -215,7 +215,7 @@ export class ManualService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
} }
return {}; return {};

2
apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts

@ -113,7 +113,7 @@ export class RapidApiService implements DataProviderInterface {
} }
} }
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
} }
return {}; return {};

61
apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts

@ -22,6 +22,7 @@ import {
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { addDays, format, isSameDay } from 'date-fns'; import { addDays, format, isSameDay } from 'date-fns';
import { ReasonPhrases, StatusCodes } from 'http-status-codes';
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import YahooFinance from 'yahoo-finance2'; import YahooFinance from 'yahoo-finance2';
import { ChartResultArray } from 'yahoo-finance2/esm/src/modules/chart'; import { ChartResultArray } from 'yahoo-finance2/esm/src/modules/chart';
@ -41,6 +42,12 @@ import { SearchQuoteNonYahoo } from 'yahoo-finance2/esm/src/modules/search';
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { 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 logger = new Logger(YahooFinanceService.name);
private readonly yahooFinance = new YahooFinance({ private readonly yahooFinance = new YahooFinance({
@ -77,10 +84,6 @@ export class YahooFinanceService implements DataProviderInterface {
symbol, symbol,
to to
}: GetDividendsParams) { }: GetDividendsParams) {
if (isSameDay(from, to)) {
to = addDays(to, 1);
}
try { try {
const historicalResult = this.convertToDividendResult( const historicalResult = this.convertToDividendResult(
await this.yahooFinance.chart( await this.yahooFinance.chart(
@ -91,7 +94,10 @@ export class YahooFinanceService implements DataProviderInterface {
events: 'dividends', events: 'dividends',
interval: granularity === 'month' ? '1mo' : '1d', interval: granularity === 'month' ? '1mo' : '1d',
period1: format(from, DATE_FORMAT), 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; return response;
} catch (error) { } catch (error) {
this.logger.error( const message = `Could not get dividends for ${symbol} (${this.getName()}) from ${format(
`Could not get dividends for ${symbol} (${this.getName()}) from ${format(
from, from,
DATE_FORMAT DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` )} 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 {}; return {};
} }
@ -125,10 +145,6 @@ export class YahooFinanceService implements DataProviderInterface {
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[date: string]: DataProviderHistoricalResponse; [date: string]: DataProviderHistoricalResponse;
}> { }> {
if (isSameDay(from, to)) {
to = addDays(to, 1);
}
try { try {
const historicalResult = this.convertToHistoricalResult( const historicalResult = this.convertToHistoricalResult(
await this.yahooFinance.chart( await this.yahooFinance.chart(
@ -138,7 +154,10 @@ export class YahooFinanceService implements DataProviderInterface {
{ {
interval: '1d', interval: '1d',
period1: format(from, DATE_FORMAT), 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; return response;
} catch (error) { } catch (error) {
if (error.message === 'No data found, symbol may be delisted') { if (error?.message === YahooFinanceService.DELISTED_ERROR_MESSAGE) {
throw new AssetProfileDelistedError( throw new AssetProfileDelistedError(
`No data found, ${symbol} (${this.getName()}) may be delisted` `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( `Could not get historical market data for ${symbol} (${this.getName()}) from ${format(
from, from,
DATE_FORMAT 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 { try {
quotes = await this.yahooFinance.quote(yahooFinanceSymbols); quotes = await this.yahooFinance.quote(yahooFinanceSymbols);
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
this.logger.warn('Fallback to yahooFinance.quoteSummary()'); this.logger.warn('Fallback to yahooFinance.quoteSummary()');
@ -225,7 +244,7 @@ export class YahooFinanceService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
return {}; return {};
} }
@ -333,7 +352,7 @@ export class YahooFinanceService implements DataProviderInterface {
if (error?.name === 'BadRequestError') { if (error?.name === 'BadRequestError') {
this.logger.warn(`Could not search for "${query}": ${error.message}`); this.logger.warn(`Could not search for "${query}": ${error.message}`);
} else { } else {
this.logger.error(error); this.logger.error(error.message);
} }
} }
@ -343,9 +362,11 @@ export class YahooFinanceService implements DataProviderInterface {
private convertToDividendResult( private convertToDividendResult(
result: ChartResultArray result: ChartResultArray
): HistoricalDividendsResult { ): HistoricalDividendsResult {
return result.events.dividends.map(({ amount: dividends, date }) => { return (result.events?.dividends ?? []).map(
({ amount: dividends, date }) => {
return { date, dividends }; return { date, dividends };
}); }
);
} }
private convertToHistoricalResult( private convertToHistoricalResult(

2
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}`]; const factor2 = this.exchangeRates[`${DEFAULT_CURRENCY}${aToCurrency}`];
factor = factor1 * factor2; factor = factor1 * factor2;
this.exchangeRates[`${aFromCurrency}${aToCurrency}`] = factor;
} }
} }

5
apps/api/src/services/i18n/i18n.service.ts

@ -45,7 +45,10 @@ export class I18nService implements OnModuleInit {
if (placeholders) { if (placeholders) {
for (const [key, value] of Object.entries(placeholders)) { for (const [key, value] of Object.entries(placeholders)) {
translatedText = translatedText.replace(`\${${key}}`, String(value)); translatedText = translatedText.replace(
new RegExp(`\\$\\{${key}\\}`, 'g'),
String(value)
);
} }
} }

100
apps/api/src/services/impersonation/impersonation.service.ts

@ -1,50 +1,110 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; 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 { 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 { Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { Access } from '@prisma/client';
@Injectable() @Injectable()
export class ImpersonationService { export class ImpersonationService {
public constructor( public constructor(private readonly prismaService: PrismaService) {}
private readonly prismaService: PrismaService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
public async validateImpersonationId(aId = '') { public async resolve({
if (this.request.user) { impersonationId,
user
}: {
impersonationId?: string;
user?: UserWithSettings;
}): Promise<ImpersonationContext> {
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({ const accessObject = await this.prismaService.access.findFirst({
where: { where: {
granteeUserId: this.request.user.id, granteeUserId: user.id,
id: aId id: impersonationId
} }
}); });
if (accessObject?.userId) { if (accessObject?.userId) {
return accessObject.userId; return { access: accessObject, userId: accessObject.userId };
} else if ( } else if (
hasPermission( hasPermission(user.permissions, permissions.impersonateAllUsers)
this.request.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 { } else {
// Public access // Public access
const accessObject = await this.prismaService.access.findFirst({ const accessObject = await this.prismaService.access.findFirst({
where: { where: {
granteeUserId: null, granteeUserId: null,
user: { id: aId } user: { id: impersonationId }
} }
}); });
if (accessObject?.userId) { if (accessObject?.userId) {
return accessObject.userId; return { access: accessObject, userId: accessObject.userId };
} }
} }
return null; return { userId: null };
} }
} }

4
apps/api/src/services/queues/data-gathering/data-gathering.processor.ts

@ -85,7 +85,7 @@ export class DataGatheringProcessor {
return job.discard(); return job.discard();
} }
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }
@ -198,7 +198,7 @@ export class DataGatheringProcessor {
return job.discard(); return job.discard();
} }
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }

10
apps/api/src/services/queues/data-gathering/data-gathering.service.ts

@ -29,7 +29,7 @@ import {
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; 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 { format, min, subDays, subMilliseconds, subYears } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import ms, { StringValue } from 'ms'; import ms, { StringValue } from 'ms';
@ -285,7 +285,7 @@ export class DataGatheringService {
date: date ?? item.date date: date ?? item.date
})); }));
await this.gatherSymbols({ return this.gatherSymbols({
dataGatheringItems, dataGatheringItems,
force: true, force: true,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
@ -322,7 +322,7 @@ export class DataGatheringService {
}); });
} }
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
} finally { } finally {
return undefined; return undefined;
} }
@ -336,8 +336,8 @@ export class DataGatheringService {
dataGatheringItems: DataGatheringItem[]; dataGatheringItems: DataGatheringItem[];
force?: boolean; force?: boolean;
priority: number; priority: number;
}) { }): Promise<Job[]> {
await this.addJobsToQueue( return this.addJobsToQueue(
dataGatheringItems.map(({ dataSource, date, symbol }) => { dataGatheringItems.map(({ dataSource, date, symbol }) => {
return { return {
data: { data: {

2
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

@ -101,7 +101,7 @@ export class PortfolioSnapshotProcessor {
return snapshot; return snapshot;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
throw new Error(error); throw new Error(error);
} }

8
apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts

@ -143,7 +143,7 @@ export class StatisticsGatheringProcessor {
return pull_count; return pull_count;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }
@ -173,7 +173,7 @@ export class StatisticsGatheringProcessor {
value value
}); });
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }
@ -192,7 +192,7 @@ export class StatisticsGatheringProcessor {
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }
@ -221,7 +221,7 @@ export class StatisticsGatheringProcessor {
return data.attributes.availability / 100; return data.attributes.availability / 100;
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error.message);
throw error; throw error;
} }

46
apps/api/src/services/tag/tag.service.ts

@ -1,4 +1,5 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { TAG_ID_DRAFT } from '@ghostfolio/common/config';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client'; import { Prisma, Tag } from '@prisma/client';
@ -9,9 +10,13 @@ export class TagService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async createTag(data: Prisma.TagCreateInput) { public async createTag(data: Prisma.TagCreateInput) {
return this.prismaService.tag.create({ try {
return await this.prismaService.tag.create({
data data
}); });
} catch (error) {
throw this.getExceptionForError(error);
}
} }
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> { public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
@ -120,10 +125,14 @@ export class TagService {
data: Prisma.TagUpdateInput; data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput; where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> { }): Promise<Tag> {
return this.prismaService.tag.update({ try {
return await this.prismaService.tag.update({
data, data,
where where
}); });
} catch (error) {
throw this.getExceptionForError(error);
}
} }
public async validateTagIds({ 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;
}
} }

4
apps/client/src/app/components/access-table/access-table.component.html

@ -18,10 +18,10 @@
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<div class="align-items-center d-flex"> <div class="align-items-center d-flex">
@if (element.permissions.includes('READ')) { @if (hasScopeToReadValues(element)) {
<ion-icon class="mr-1" name="lock-open-outline" /> <ion-icon class="mr-1" name="lock-open-outline" />
<ng-container i18n>View</ng-container> <ng-container i18n>View</ng-container>
} @else if (element.permissions.includes('READ_RESTRICTED')) { } @else {
<ion-icon class="mr-1" name="lock-closed-outline" /> <ion-icon class="mr-1" name="lock-closed-outline" />
<ng-container i18n>Restricted view</ng-container> <ng-container i18n>Restricted view</ng-container>
} }

5
apps/client/src/app/components/access-table/access-table.component.ts

@ -1,6 +1,7 @@
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
@ -100,6 +101,10 @@ export class GfAccessTableComponent {
return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`; return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`;
} }
protected hasScopeToReadValues({ scopes: scopesOfAccess }: Access) {
return hasScope(scopesOfAccess, scopes.portfolioReadValues);
}
protected onCopyUrlToClipboard(aId: string) { protected onCopyUrlToClipboard(aId: string) {
this.clipboard.copy(this.getPublicUrl(aId)); this.clipboard.copy(this.getPublicUrl(aId));

24
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -14,11 +14,8 @@ import {
PortfolioPosition, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
hasPermission, import { hasScope, scopes } from '@ghostfolio/common/scopes';
hasReadRestrictedAccessPermission,
permissions
} from '@ghostfolio/common/permissions';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
@ -176,6 +173,8 @@ export class GfAccountDetailDialogComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.initialize(); this.initialize();
this.refreshUser();
}); });
} }
@ -195,6 +194,8 @@ export class GfAccountDetailDialogComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.initialize(); this.initialize();
this.refreshUser();
}); });
} }
@ -229,10 +230,8 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected showValuesInPercentage() { protected showValuesInPercentage() {
return ( return (
hasReadRestrictedAccessPermission({ !hasScope(this.user?.scopes, scopes.portfolioReadValues) ||
accesses: this.user?.access, this.user?.settings?.isRestrictedView
impersonationId: this.data.impersonationId
}) || this.user?.settings?.isRestrictedView
); );
} }
@ -413,4 +412,11 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.fetchChart(); this.fetchChart();
this.fetchPortfolioHoldings(); this.fetchPortfolioHoldings();
} }
private refreshUser() {
this.userService
.get(true)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
} }

1
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -160,7 +160,6 @@
[showActions]=" [showActions]="
data.hasPermissionToCreateActivity && data.hasPermissionToCreateActivity &&
!data.impersonationId && !data.impersonationId &&
user?.settings?.isExperimentalFeatures &&
!user?.settings?.isRestrictedView !user?.settings?.isRestrictedView
" "
[sortColumn]="sortColumn" [sortColumn]="sortColumn"

11
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) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(({ price }) => { .subscribe(({ price }) => {
const currency = this.assetProfileForm.controls.currency.value;
this.notificationService.alert({ this.notificationService.alert({
title: title: `${$localize`The current market price is`} ${price}${
$localize`The current market price is` + currency ? ` ${currency}` : ''
' ' + }`
price +
' ' +
this.assetProfileForm.controls.currency.value
}); });
}); });
} }

4
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -511,9 +511,7 @@
</div> </div>
</div> </div>
</mat-tab> </mat-tab>
<!-- Splits are not applied to the portfolio calculation yet, hence the @if (user?.settings?.isExperimentalFeatures) {
tab to manage them is disabled -->
@if (false && user?.settings?.isExperimentalFeatures) {
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<ion-icon name="git-compare-outline" /> <ion-icon name="git-compare-outline" />

9
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss

@ -1,6 +1,15 @@
@use '@angular/material' as mat;
:host { :host {
display: block; display: block;
@include mat.form-field-overrides(
(
container-height: 2rem,
container-vertical-padding: 0.33rem
)
);
.chart-container { .chart-container {
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;

2
apps/client/src/app/components/header/header.component.html

@ -217,7 +217,7 @@
<span i18n>Me</span> <span i18n>Me</span>
</span> </span>
</button> </button>
@for (accessItem of user()?.access; track accessItem) { @for (accessItem of user()?.access; track accessItem.id) {
<button mat-menu-item (click)="impersonateAccount(accessItem.id)"> <button mat-menu-item (click)="impersonateAccount(accessItem.id)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon <ion-icon

11
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -3,7 +3,8 @@ import {
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
E_MAIL_LINE_BREAK, E_MAIL_LINE_BREAK,
NUMERICAL_PRECISION_THRESHOLD_3_FIGURES, NUMERICAL_PRECISION_THRESHOLD_3_FIGURES,
NUMERICAL_PRECISION_THRESHOLD_4_FIGURES NUMERICAL_PRECISION_THRESHOLD_4_FIGURES,
TAG_ID_DRAFT
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto } from '@ghostfolio/common/dtos';
import { import {
@ -591,7 +592,13 @@ export class GfHoldingDetailDialogComponent implements OnInit {
); );
this.tagsAvailable = this.tagsAvailable =
this.user?.tags?.map((tag) => { this.user?.tags
?.filter(({ id }) => {
// The "DRAFT" tag qualifies an individual activity and cannot
// be assigned to all activities of a holding
return id !== TAG_ID_DRAFT;
})
.map((tag) => {
return { return {
...tag, ...tag,
name: translate(tag.name) name: translate(tag.name)

2
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -392,7 +392,6 @@
[showActions]=" [showActions]="
data.hasPermissionToCreateActivity && data.hasPermissionToCreateActivity &&
!data.impersonationId && !data.impersonationId &&
user?.settings?.isExperimentalFeatures &&
!user?.settings?.isRestrictedView !user?.settings?.isRestrictedView
" "
[showNameColumn]="false" [showNameColumn]="false"
@ -418,6 +417,7 @@
[showAllocationInPercentage]="user?.settings?.isExperimentalFeatures" [showAllocationInPercentage]="user?.settings?.isExperimentalFeatures"
[showBalance]="false" [showBalance]="false"
[showFooter]="false" [showFooter]="false"
[showQuantity]="user?.settings?.isExperimentalFeatures"
[showValue]="false" [showValue]="false"
[showValueInBaseCurrency]="false" [showValueInBaseCurrency]="false"
/> />

62
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -3,12 +3,15 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
PortfolioPosition, PortfolioPosition,
ToggleOption,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { HoldingType, HoldingsViewMode } from '@ghostfolio/common/types'; import {
HoldingType,
HoldingsViewMode,
ToggleOption
} from '@ghostfolio/common/types';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfToggleComponent } from '@ghostfolio/ui/toggle'; import { GfToggleComponent } from '@ghostfolio/ui/toggle';
@ -24,11 +27,8 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { Router, RouterModule } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { gridOutline, reorderFourOutline } from 'ionicons/icons'; import { gridOutline, reorderFourOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -36,14 +36,10 @@ import { DeviceDetectorService } from 'ngx-device-detector';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
FormsModule,
GfHoldingsTableComponent, GfHoldingsTableComponent,
GfToggleComponent, GfToggleComponent,
GfTreemapChartComponent, GfTreemapChartComponent,
IonIcon,
MatButtonModule, MatButtonModule,
MatButtonToggleModule,
ReactiveFormsModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
@ -59,17 +55,29 @@ export class GfHomeHoldingsComponent implements OnInit {
protected hasPermissionToAccessHoldingsChart: boolean; protected hasPermissionToAccessHoldingsChart: boolean;
protected hasPermissionToCreateActivity: boolean; protected hasPermissionToCreateActivity: boolean;
protected holdings: PortfolioPosition[] | undefined; protected holdings: PortfolioPosition[] | undefined;
protected holdingsViewMode: HoldingsViewMode =
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE;
protected readonly holdingsViewModeOptions: ToggleOption[] = [
{
iconName: 'reorder-four-outline',
title: $localize`Table`,
value: 'TABLE'
},
{
iconName: 'grid-outline',
title: $localize`Chart`,
value: 'CHART'
}
];
protected holdingType: HoldingType = 'ACTIVE'; protected holdingType: HoldingType = 'ACTIVE';
protected readonly holdingTypeOptions: ToggleOption[] = [ protected readonly holdingTypeOptions: ToggleOption[] = [
{ label: $localize`Active`, value: 'ACTIVE' }, { label: $localize`Active`, value: 'ACTIVE' },
{ label: $localize`Closed`, value: 'CLOSED' } { label: $localize`Closed`, value: 'CLOSED' }
]; ];
protected isHoldingsViewModeToggleDisabled = true;
protected readonly routerLinkPortfolioActivities = protected readonly routerLinkPortfolioActivities =
internalRoutes.portfolio.subRoutes.activities.routerLink; internalRoutes.portfolio.subRoutes.activities.routerLink;
protected user: User; protected user: User;
protected readonly viewModeFormControl = new FormControl<HoldingsViewMode>(
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
);
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
@ -118,16 +126,13 @@ export class GfHomeHoldingsComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.viewModeFormControl.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((holdingsViewMode) => {
if (!holdingsViewMode) {
return;
} }
protected onChangeHoldingsViewMode(aHoldingsViewMode: HoldingsViewMode) {
this.holdingsViewMode = aHoldingsViewMode;
this.dataService this.dataService
.putUserSetting({ holdingsViewMode }) .putUserSetting({ holdingsViewMode: aHoldingsViewMode })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
@ -139,7 +144,6 @@ export class GfHomeHoldingsComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
}); });
});
} }
protected onChangeHoldingType(aHoldingType: HoldingType) { protected onChangeHoldingType(aHoldingType: HoldingType) {
@ -170,26 +174,22 @@ export class GfHomeHoldingsComponent implements OnInit {
} }
private initialize() { private initialize() {
this.viewModeFormControl.disable({ emitEvent: false }); this.isHoldingsViewModeToggleDisabled = true;
if ( if (
this.hasPermissionToAccessHoldingsChart && this.hasPermissionToAccessHoldingsChart &&
this.holdingType === 'ACTIVE' this.holdingType === 'ACTIVE'
) { ) {
this.viewModeFormControl.enable({ emitEvent: false }); this.isHoldingsViewModeToggleDisabled = false;
this.viewModeFormControl.setValue( this.holdingsViewMode =
this.deviceType === 'mobile' this.deviceType === 'mobile'
? GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE ? GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
: (this.user?.settings?.holdingsViewMode ?? : (this.user?.settings?.holdingsViewMode ??
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE), GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE);
{ emitEvent: false } } else {
); this.holdingsViewMode =
} else if (this.holdingType === 'CLOSED') { GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE;
this.viewModeFormControl.setValue(
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
{ emitEvent: false }
);
} }
this.holdings = undefined; this.holdings = undefined;

27
apps/client/src/app/components/home-holdings/home-holdings.html

@ -7,32 +7,23 @@
<div class="row"> <div class="row">
<div class="col-lg"> <div class="col-lg">
<div class="d-flex"> <div class="d-flex">
<div class="d-flex"> <gf-toggle
<div class="d-none d-lg-block"> class="d-none d-lg-block"
<mat-button-toggle-group [defaultValue]="holdingsViewMode"
[formControl]="viewModeFormControl" [isDisabled]="isHoldingsViewModeToggleDisabled"
[hideSingleSelectionIndicator]="true" [options]="holdingsViewModeOptions"
> (valueChange)="onChangeHoldingsViewMode($event.value)"
<mat-button-toggle i18n-title title="Table" value="TABLE"> />
<ion-icon name="reorder-four-outline" />
</mat-button-toggle>
<mat-button-toggle i18n-title title="Chart" value="CHART">
<ion-icon name="grid-outline" />
</mat-button-toggle>
</mat-button-toggle-group>
</div>
</div>
<div class="align-items-center d-flex flex-grow-1 justify-content-end"> <div class="align-items-center d-flex flex-grow-1 justify-content-end">
<gf-toggle <gf-toggle
class="d-none d-lg-block" class="d-none d-lg-block"
[defaultValue]="holdingType" [defaultValue]="holdingType"
[isLoading]="false"
[options]="holdingTypeOptions" [options]="holdingTypeOptions"
(valueChange)="onChangeHoldingType($event.value)" (valueChange)="onChangeHoldingType($event.value)"
/> />
</div> </div>
</div> </div>
@if (viewModeFormControl.value === 'CHART') { @if (holdingsViewMode === 'CHART') {
<gf-treemap-chart <gf-treemap-chart
class="mt-3" class="mt-3"
cursor="pointer" cursor="pointer"
@ -44,7 +35,7 @@
(treemapChartClicked)="onHoldingClicked($event)" (treemapChartClicked)="onHoldingClicked($event)"
/> />
} }
<div [class.d-none]="viewModeFormControl.value !== 'TABLE'"> <div [class.d-none]="holdingsViewMode !== 'TABLE'">
<gf-holdings-table <gf-holdings-table
[holdings]="holdings" [holdings]="holdings"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"

6
apps/client/src/app/components/home-holdings/home-holdings.scss

@ -1,9 +1,3 @@
:host { :host {
display: block; display: block;
.mat-button-toggle-group {
.mat-button-toggle-appearance-standard {
--mat-button-toggle-height: 1.5rem;
}
}
} }

11
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

@ -128,10 +128,17 @@ export class GfHomeWatchlistComponent implements OnInit {
this.dataService this.dataService
.fetchWatchlist() .fetchWatchlist()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ watchlist }) => { .subscribe({
this.watchlist = watchlist; error: () => {
this.watchlist = [];
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
},
next: ({ watchlist }) => {
this.watchlist = watchlist ?? [];
this.changeDetectorRef.markForCheck();
}
}); });
} }

14
apps/client/src/app/components/markets/markets.component.ts

@ -5,11 +5,10 @@ import {
HistoricalDataItem, HistoricalDataItem,
InfoItem, InfoItem,
MarketDataOfMarketsResponse, MarketDataOfMarketsResponse,
ToggleOption,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { FearAndGreedIndexMode } from '@ghostfolio/common/types'; import { FearAndGreedIndexMode, ToggleOption } from '@ghostfolio/common/types';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfFearAndGreedIndexComponent } from '@ghostfolio/ui/fear-and-greed-index'; import { GfFearAndGreedIndexComponent } from '@ghostfolio/ui/fear-and-greed-index';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
@ -44,7 +43,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
templateUrl: './markets.html' templateUrl: './markets.html'
}) })
export class GfMarketsComponent implements OnInit { export class GfMarketsComponent implements OnInit {
protected readonly benchmarks = signal<Benchmark[]>([]); protected readonly benchmarks = signal<Benchmark[] | undefined>(undefined);
protected readonly deviceType = computed( protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
@ -118,8 +117,13 @@ export class GfMarketsComponent implements OnInit {
this.dataService this.dataService
.fetchBenchmarks() .fetchBenchmarks()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ benchmarks }) => { .subscribe({
this.benchmarks.set(benchmarks); error: () => {
this.benchmarks.set([]);
},
next: ({ benchmarks }) => {
this.benchmarks.set(benchmarks ?? []);
}
}); });
} }

2
apps/client/src/app/components/markets/markets.html

@ -56,7 +56,7 @@
[showSymbol]="false" [showSymbol]="false"
[user]="user" [user]="user"
/> />
@if (benchmarks()?.length > 0) { @if (benchmarks()?.length) {
<div <div
class="gf-text-wrap-balance line-height-1 mt-3 text-center text-muted" class="gf-text-wrap-balance line-height-1 mt-3 text-center text-muted"
> >

3
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts

@ -1,5 +1,6 @@
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
export interface CreateOrUpdateAccessDialogParams { export interface CreateOrUpdateAccessDialogParams {
access?: Access; // TODO: Include the scopes once the dialog allows to configure them
access?: Omit<Access, 'scopes'>;
} }

7
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -247,15 +247,18 @@ export class GfUserAccountAccessComponent implements OnInit {
} }
private update() { private update() {
this.accessesGet = this.user.access.map(({ alias, id, permissions }) => { this.accessesGet = this.user.access.map(
({ alias, id, permissions, scopes }) => {
return { return {
id, id,
permissions, permissions,
scopes,
alias: alias ?? '', alias: alias ?? '',
grantee: $localize`Me`, grantee: $localize`Me`,
type: 'PRIVATE' type: 'PRIVATE'
}; };
}); }
);
this.dataService this.dataService
.fetchAccesses() .fetchAccesses()

58
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -1,3 +1,4 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { import {
KEY_STAY_SIGNED_IN, KEY_STAY_SIGNED_IN,
KEY_TOKEN, KEY_TOKEN,
@ -14,6 +15,7 @@ import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -30,8 +32,9 @@ import {
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
FormControl,
FormGroup,
NonNullableFormBuilder, NonNullableFormBuilder,
FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
@ -58,7 +61,7 @@ import { catchError } from 'rxjs/operators';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
FormsModule, GfCurrencySelectorComponent,
GfValueComponent, GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
@ -77,12 +80,18 @@ import { catchError } from 'rxjs/operators';
}) })
export class GfUserAccountSettingsComponent implements OnInit { export class GfUserAccountSettingsComponent implements OnInit {
protected readonly appearancePlaceholder = $localize`Auto`; protected readonly appearancePlaceholder = $localize`Auto`;
protected readonly baseCurrency: string; protected readonly baseCurrencyForm = new FormGroup({
baseCurrency: new FormControl<string | null>({
disabled: true,
value: null
})
});
protected closeUserAccountMailHref: string; protected closeUserAccountMailHref: string;
protected readonly currencies: string[] = []; protected readonly currencies: string[] = [];
protected readonly deleteOwnUserForm = inject(NonNullableFormBuilder).group({ protected readonly deleteOwnUserForm = inject(NonNullableFormBuilder).group({
accessToken: ['', Validators.required] accessToken: ['', Validators.required]
}); });
protected hasImpersonationId: boolean;
protected hasPermissionToDeleteOwnUser: boolean; protected hasPermissionToDeleteOwnUser: boolean;
protected hasPermissionToRequestOwnUserDeletion: boolean; protected hasPermissionToRequestOwnUserDeletion: boolean;
protected hasPermissionToUpdateViewMode: boolean; protected hasPermissionToUpdateViewMode: boolean;
@ -123,6 +132,9 @@ export class GfUserAccountSettingsComponent implements OnInit {
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
private readonly settingsStorageService = inject(SettingsStorageService); private readonly settingsStorageService = inject(SettingsStorageService);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
@ -130,11 +142,21 @@ export class GfUserAccountSettingsComponent implements OnInit {
private readonly webAuthnService = inject(WebAuthnService); private readonly webAuthnService = inject(WebAuthnService);
public constructor() { public constructor() {
const { baseCurrency, currencies } = this.dataService.fetchInfo(); const { currencies } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.currencies = currencies; this.currencies = currencies;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.updateBaseCurrencyFormState();
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
@ -183,6 +205,12 @@ export class GfUserAccountSettingsComponent implements OnInit {
permissions.updateViewMode permissions.updateViewMode
); );
this.baseCurrencyForm.setValue({
baseCurrency: this.user.settings.baseCurrency ?? null
});
this.updateBaseCurrencyFormState();
if (this.user.settings.locale) { if (this.user.settings.locale) {
this.locales.push(this.user.settings.locale); this.locales.push(this.user.settings.locale);
} }
@ -195,6 +223,16 @@ export class GfUserAccountSettingsComponent implements OnInit {
} }
}); });
this.baseCurrencyForm.controls.baseCurrency.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
// The currency selector emits null while the user is typing and only
// emits a currency once an option has been selected
if (value && value !== this.user?.settings.baseCurrency) {
this.onChangeUserSetting('baseCurrency', value);
}
});
addIcons({ eyeOffOutline, eyeOutline }); addIcons({ eyeOffOutline, eyeOutline });
} }
@ -406,4 +444,14 @@ export class GfUserAccountSettingsComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
private updateBaseCurrencyFormState() {
// The base currency belongs to the impersonated user while a change would be
// applied to the authenticated user
if (!this.hasImpersonationId && this.hasPermissionToUpdateUserSettings) {
this.baseCurrencyForm.enable({ emitEvent: false });
} else {
this.baseCurrencyForm.disable({ emitEvent: false });
}
}
} }

37
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -3,25 +3,30 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="d-flex py-1"> <div class="d-flex py-1">
<form #changeUserSettingsForm="ngForm" class="w-100"> <div class="w-100">
<div class="d-flex mb-2"> <div
class="d-flex"
[class.mb-2]="!hasImpersonationId"
[class.mb-4]="hasImpersonationId"
>
<div class="align-items-center d-flex pt-1 pt-1 w-50"> <div class="align-items-center d-flex pt-1 pt-1 w-50">
<ng-container i18n>Base Currency</ng-container> <ng-container i18n>Base Currency</ng-container>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50" [formGroup]="baseCurrencyForm">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field
<mat-select appearance="outline"
name="baseCurrency" class="w-100"
[disabled]="!hasPermissionToUpdateUserSettings" [class.without-hint]="!hasImpersonationId"
[value]="user.settings.baseCurrency" >
(selectionChange)=" <gf-currency-selector
onChangeUserSetting('baseCurrency', $event.value) formControlName="baseCurrency"
" [currencies]="currencies"
/>
@if (hasImpersonationId) {
<mat-hint class="mt-1 text-muted text-truncate" i18n
>Derived from the portfolio you are viewing</mat-hint
> >
@for (currency of currencies; track currency) {
<mat-option [value]="currency">{{ currency }}</mat-option>
} }
</mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
@ -179,7 +184,7 @@
<mat-option [value]="locale">{{ locale }}</mat-option> <mat-option [value]="locale">{{ locale }}</mat-option>
} }
</mat-select> </mat-select>
<mat-hint class="d-flex mt-2 text-muted"> <mat-hint class="d-flex mt-2 text-muted text-truncate">
<gf-value <gf-value
[deviceType]="deviceType()" [deviceType]="deviceType()"
[isDate]="true" [isDate]="true"
@ -222,7 +227,7 @@
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
</form> </div>
</div> </div>
<div class="align-items-center d-flex mt-4 py-1"> <div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50"> <div class="pr-1 w-50">

18
apps/client/src/app/components/user-account-settings/user-account-settings.scss

@ -11,6 +11,15 @@
} }
} }
mat-form-field:has(gf-currency-selector) {
--mat-form-field-outlined-disabled-input-text-color: rgba(
var(--dark-secondary-text)
);
--mat-form-field-outlined-disabled-outline-color: rgba(
var(--dark-disabled-text)
);
}
.hint-text { .hint-text {
font-size: 90%; font-size: 90%;
line-height: 1.2; line-height: 1.2;
@ -19,4 +28,13 @@
:host-context(.theme-dark) { :host-context(.theme-dark) {
color: rgb(var(--light-primary-text)); color: rgb(var(--light-primary-text));
mat-form-field:has(gf-currency-selector) {
--mat-form-field-outlined-disabled-input-text-color: rgba(
var(--light-secondary-text)
);
--mat-form-field-outlined-disabled-outline-color: rgba(
var(--light-disabled-text)
);
}
} }

296
apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts

@ -0,0 +1,296 @@
import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import {
AccountDetailDialogParams,
AccountDetailDialogResult
} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos';
import { AccountResponse, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
OnDestroy,
OnInit,
inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Observable, of, Subject } from 'rxjs';
import {
distinctUntilChanged,
map,
switchMap,
takeUntil,
tap
} from 'rxjs/operators';
import { GfCreateOrUpdateAccountDialogComponent } from '../create-or-update-account-dialog/create-or-update-account-dialog.component';
import { CreateOrUpdateAccountDialogParams } from '../create-or-update-account-dialog/interfaces/interfaces';
import { AccountDialogMode } from './types/account-dialog-mode.type';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-account-dialog-host',
template: ''
})
export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
private dialogRef: MatDialogRef<
GfAccountDetailDialogComponent | GfCreateOrUpdateAccountDialogComponent
>;
private readonly deviceType = computed(() => {
return this.deviceDetectorService.deviceInfo().deviceType;
});
private readonly dialogClosed = new Subject<void>();
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public ngOnInit() {
const mode = this.route.snapshot.data.mode as AccountDialogMode;
// The router reuses this component when only the account id changes, so
// the parameters are observed instead of read from the snapshot once
this.route.paramMap
.pipe(
map((paramMap) => {
return paramMap.get('accountId');
}),
distinctUntilChanged(),
tap(() => {
this.closeDialog();
}),
switchMap((accountId) => {
const account$: Observable<AccountResponse | undefined> =
mode === 'update' && accountId
? this.dataService.fetchAccount(accountId)
: of(undefined);
return this.userService.get().pipe(
switchMap((user) => {
return account$.pipe(
map((account) => {
return { account, accountId, user };
})
);
})
);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
error: () => {
this.navigateBack();
},
next: ({ account, accountId, user }) => {
if (mode === 'detail') {
this.openAccountDetailDialog({ accountId, user });
return;
}
if (mode === 'update') {
if (
!account ||
!hasPermission(user?.permissions, permissions.updateAccount) ||
this.isReadOnlyMode(user)
) {
this.navigateBack();
return;
}
const { balance, comment, currency, id, name, platformId, tags } =
account;
this.openCreateOrUpdateAccountDialog({
user,
account: {
balance,
comment,
currency,
id,
name,
platformId,
tags
},
isUpdate: true
});
return;
}
if (
!hasPermission(user?.permissions, permissions.createAccount) ||
this.isReadOnlyMode(user)
) {
this.navigateBack();
return;
}
this.openCreateOrUpdateAccountDialog({
user,
account: {
balance: 0,
comment: null,
currency: user?.settings?.baseCurrency ?? null,
id: null,
name: null,
platformId: null,
tags: []
},
isUpdate: false
});
}
});
}
public ngOnDestroy() {
// The dialog lives in an overlay outside of this component, so it needs to
// be closed explicitly when leaving the route (for example via the browser
// navigation)
this.dialogRef?.close();
this.dialogClosed.complete();
}
private closeDialog() {
// Tear down the subscription of the dialog which is about to be replaced,
// so that its result is not mistaken for the user closing it
this.dialogClosed.next();
this.dialogRef?.close();
}
private isReadOnlyMode(user: User) {
return (
!!this.impersonationStorageService.getId() ||
!!user?.settings?.isRestrictedView
);
}
private navigateBack() {
void this.router.navigate(internalRoutes.accounts.routerLink);
}
private openAccountDetailDialog({
accountId,
user
}: {
accountId: string | null;
user: User;
}) {
if (!accountId) {
this.navigateBack();
return;
}
const impersonationId = this.impersonationStorageService.getId();
const dialogRef = this.dialog.open<
GfAccountDetailDialogComponent,
AccountDetailDialogParams,
AccountDetailDialogResult
>(GfAccountDetailDialogComponent, {
autoFocus: false,
data: {
accountId,
impersonationId,
deviceType: this.deviceType(),
hasPermissionToCreateActivity:
!impersonationId &&
hasPermission(user?.permissions, permissions.createActivity) &&
!user?.settings?.isRestrictedView
},
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
this.dialogRef = dialogRef;
dialogRef
.afterClosed()
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (result?.isNavigating) {
return;
}
this.navigateBack();
});
}
private openCreateOrUpdateAccountDialog({
account,
isUpdate,
user
}: {
account: CreateOrUpdateAccountDialogParams['account'];
isUpdate: boolean;
user: User;
}) {
const dialogRef = this.dialog.open<
GfCreateOrUpdateAccountDialogComponent,
CreateOrUpdateAccountDialogParams,
CreateAccountDto | UpdateAccountDto | null
>(GfCreateOrUpdateAccountDialogComponent, {
data: {
account,
user
},
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
this.dialogRef = dialogRef;
dialogRef
.afterClosed()
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (!result) {
this.navigateBack();
return;
}
const request$: Observable<unknown> = isUpdate
? this.dataService.putAccount(result as UpdateAccountDto)
: this.dataService.postAccount(result as CreateAccountDto);
request$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
error: () => {
this.navigateBack();
},
next: () => {
// Deliberately not bound to the destroy reference: navigating back
// destroys this component and the refreshed user is what makes the
// accounts page reload its data
this.userService.get(true).subscribe();
this.navigateBack();
}
});
});
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save