Browse Source

Merge branch 'main' into pr/6284

pull/6284/head
Thomas Kaul 2 months ago
parent
commit
5e841b655d
  1. 2
      .github/workflows/build-code.yml
  2. 239
      CHANGELOG.md
  3. 6
      README.md
  4. 95
      apps/api/src/app/activities/activities.controller.ts
  5. 12
      apps/api/src/app/activities/activities.module.ts
  6. 75
      apps/api/src/app/activities/activities.service.ts
  7. 9
      apps/api/src/app/admin/admin.controller.ts
  8. 4
      apps/api/src/app/admin/admin.module.ts
  9. 18
      apps/api/src/app/admin/admin.service.ts
  10. 38
      apps/api/src/app/app.module.ts
  11. 29
      apps/api/src/app/auth-device/auth-device.controller.ts
  12. 4
      apps/api/src/app/endpoints/ai/ai.module.ts
  13. 7
      apps/api/src/app/endpoints/assets/assets.controller.ts
  14. 6
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  15. 4
      apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
  16. 7
      apps/api/src/app/endpoints/public/public.controller.ts
  17. 4
      apps/api/src/app/endpoints/public/public.module.ts
  18. 5
      apps/api/src/app/export/export.controller.ts
  19. 4
      apps/api/src/app/export/export.module.ts
  20. 16
      apps/api/src/app/export/export.service.ts
  21. 2
      apps/api/src/app/import/import.controller.ts
  22. 4
      apps/api/src/app/import/import.module.ts
  23. 154
      apps/api/src/app/import/import.service.ts
  24. 17
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  25. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  26. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  27. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  28. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  29. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  30. 17
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  31. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  32. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  33. 190
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts
  34. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  35. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  36. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  37. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  38. 9
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  39. 11
      apps/api/src/app/portfolio/current-rate.service.mock.ts
  40. 2
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  41. 7
      apps/api/src/app/portfolio/current-rate.service.ts
  42. 12
      apps/api/src/app/portfolio/portfolio.controller.ts
  43. 4
      apps/api/src/app/portfolio/portfolio.module.ts
  44. 81
      apps/api/src/app/portfolio/portfolio.service.ts
  45. 11
      apps/api/src/app/redis-cache/redis-cache.service.ts
  46. 2
      apps/api/src/app/subscription/subscription.service.ts
  47. 4
      apps/api/src/app/user/user.module.ts
  48. 18
      apps/api/src/app/user/user.service.ts
  49. 295
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  50. 1
      apps/api/src/assets/cryptocurrencies/custom.json
  51. 12
      apps/api/src/events/asset-profile-changed.event.ts
  52. 65
      apps/api/src/events/asset-profile-changed.listener.ts
  53. 4
      apps/api/src/events/events.module.ts
  54. 30
      apps/api/src/events/portfolio-changed.listener.ts
  55. 4
      apps/api/src/helper/object.helper.spec.ts
  56. 3
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  57. 6
      apps/api/src/main.ts
  58. 28
      apps/api/src/middlewares/bull-board-auth.middleware.ts
  59. 4
      apps/api/src/models/rule.ts
  60. 7
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  61. 7
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  62. 7
      apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
  63. 7
      apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts
  64. 7
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  65. 7
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  66. 7
      apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts
  67. 7
      apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts
  68. 7
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  69. 28
      apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts
  70. 7
      apps/api/src/models/rules/liquidity/buying-power.ts
  71. 4
      apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts
  72. 4
      apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts
  73. 4
      apps/api/src/models/rules/regional-market-cluster-risk/europe.ts
  74. 4
      apps/api/src/models/rules/regional-market-cluster-risk/japan.ts
  75. 4
      apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts
  76. 2
      apps/api/src/services/configuration/configuration.service.ts
  77. 7
      apps/api/src/services/cryptocurrency/cryptocurrency.module.ts
  78. 34
      apps/api/src/services/cryptocurrency/cryptocurrency.service.ts
  79. 10
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  80. 12
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  81. 2
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts
  82. 10
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  83. 149
      apps/api/src/services/data-provider/data-provider.service.ts
  84. 10
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  85. 10
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  86. 36
      apps/api/src/services/data-provider/manual/manual.service.ts
  87. 6
      apps/api/src/services/i18n/i18n.service.ts
  88. 2
      apps/api/src/services/interfaces/environment.interface.ts
  89. 14
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  90. 18
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  91. 6
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  92. 8
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  93. 4
      apps/client/proxy.conf.json
  94. 136
      apps/client/src/app/app.component.ts
  95. 11
      apps/client/src/app/components/access-table/access-table.component.html
  96. 76
      apps/client/src/app/components/access-table/access-table.component.ts
  97. 34
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  98. 4
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  99. 55
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  100. 9
      apps/client/src/app/components/admin-jobs/admin-jobs.html

2
.github/workflows/build-code.yml

@ -13,7 +13,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node_version: node_version:
- 22 - 22.22.1
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4

239
CHANGELOG.md

@ -5,12 +5,249 @@ 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).
## Unreleased ## 2.253.0 - 2026-03-06
### Added
- Added support for filtering by activity type on the activities page (experimental)
- Extended the admin control panel by adding a copy-to-clipboard button for the application version
### Changed
- Extended the terms of service for the _Ghostfolio_ SaaS (cloud) to include _Paid Plans_ and _Refund Policy_
- Upgraded `prisma` from version `6.19.0` to `6.19.3`
### Fixed
- Fixed the allocations by account chart on the allocations page in the _Presenter View_
- Fixed the allocations by asset class chart on the allocations page in the _Presenter View_
- Fixed the allocations by currency chart on the allocations page in the _Presenter View_
- Fixed the allocations by ETF provider chart on the allocations page in the _Presenter View_
- Fixed the allocations by platform chart on the allocations page in the _Presenter View_
## 2.252.0 - 2026-03-02
### Added
- Added support for a copy-to-clipboard functionality in the value component
- Extended the holding detail dialog by adding a copy-to-clipboard button for the ISIN number (experimental)
- Extended the holding detail dialog by adding a copy-to-clipboard button for the symbol (experimental)
- Extended the user detail dialog of the admin control panel’s users section by adding a copy-to-clipboard button for the user id
### Changed
- Refreshed the cryptocurrencies list
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
- Upgraded `countries-list` from version `3.2.2` to `3.3.0`
- Upgraded `ng-extract-i18n-merge` from `3.2.1` to `3.3.0`
- Upgraded `stripe` from version `20.3.0` to `20.4.1`
## 2.251.0 - 2026-03-24
### Added
- Added the quantity column to the holdings table of the portfolio holdings page
### Changed
- Hardened the endpoint `DELETE /api/v1/auth-device/:id` by improving the user validation
- Improved the allocations by ETF holding on the allocations page by refining the grouping of the same assets with diverging names (experimental)
- Improved the language localization for Polish (`pl`)
- Upgraded `@trivago/prettier-plugin-sort-imports` from version `5.2.2` to `6.0.2`
### Fixed
- Fixed an issue by adding a missing guard in the public access for portfolio sharing
## 2.250.0 - 2026-03-17
### Added
- Added support for specific calendar year date ranges (`2025`, `2024`, `2023`, etc.) on the portfolio activities page
### Changed
- Consolidated the sign-out logic within the user service to unify cookie, state and token clearance
- Improved the language localization for Polish (`pl`)
- Upgraded `@ionic/angular` from version `8.7.3` to `8.8.1`
- Upgraded `replace-in-file` from version `8.3.0` to `8.4.0`
- Upgraded `svgmap` from version `2.14.0` to `2.19.2`
- Pinned the _Node.js_ version in the _Build code_ _GitHub Action_ to ensure environment consistency for tests
### Fixed
- Fixed an issue with the detection of the thousand separator for the `de-CH` locale
- Fixed an issue in the _Storybook_ stories of the symbol autocomplete component caused by a circular dependency
## 2.249.0 - 2026-03-10
### Added
- Integrated _Bull Dashboard_ for a detailed jobs queue view in the admin control panel (experimental)
- Added a debounce to the `PortfolioChangedListener` and `AssetProfileChangedListener` to minimize redundant _Redis_ and database operations
### Changed ### Changed
- Improved the _Storybook_ stories of the value component
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for German (`de`)
- Upgraded `class-validator` from version `0.14.3` to `0.15.1`
### Fixed
- Fixed false _Redis_ health check failures by using unique keys and increasing the timeout to 5s
## 2.248.0 - 2026-03-07
### Added
- Added support for column sorting to the data providers management of the admin control panel
### Changed
- Included asset profile data in the endpoint `GET api/v1/portfolio/holdings`
- Included asset profile data in the holdings of the public page
- Reused the value component in the platform management of the admin control panel
- Reused the value component in the tag management of the admin control panel
- Deprecated the `api/v1/order` endpoints in favor of the `api/v1/activities` endpoints
- Upgraded `jsonpath` from version `1.1.1` to `1.2.1`
### Fixed
- Fixed an issue in the _FIRE_ calculator to correctly calculate the projected total amount
## 2.247.0 - 2026-03-04
### Changed
- Upgraded `yahoo-finance2` from version `3.13.0` to `3.13.2`
## 2.246.0 - 2026-03-03
### Changed
- Removed the deprecated `committedFunds` from the summary of the portfolio details endpoint
- Upgraded `Nx` from version `22.4.5` to `22.5.3`
### Fixed
- Fixed an issue where the apply and reset filter buttons remained disabled in the assistant
## 2.245.0 - 2026-03-01
### Changed
- Excluded the scraper configuration from the import and export functionality
- Excluded the symbol mapping from the import and export functionality
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for Italian (`it`)
- Improved the language localization for Spanish (`es`)
### Fixed
- Resolved the data source transformation in the errors of the performance endpoint
- Resolved the data source transformation in the export functionality
## 2.244.0 - 2026-02-28
### Changed
- Improved the usability of the asset profile details dialog in the admin control panel for currencies
- Removed the deprecated static portfolio analysis rule: _Fees_ (Fee Ratio)
- Refactored queries in the data provider service to use Prisma’s safe query methods
### Fixed
- Fixed an exception by adding a fallback for missing market price values on the _X-ray_ page
## 2.243.0 - 2026-02-23
### Changed
- Improved the language localization for Chinese (`zh`)
- Upgraded `nestjs` from version `11.1.8` to `11.1.14`
### Fixed
- Fixed an issue when creating activities of type `FEE`, `INTEREST` or `LIABILITY`
## 2.242.0 - 2026-02-22
### Changed
- Changed the account field to optional in the create or update activity dialog
### Fixed
- Fixed a validation issue for valuables used in the create and import activity logic
- Fixed the page size for presets in the historical market data table of the admin control panel
## 2.241.0 - 2026-02-21
### Changed
- Improved the usability of the portfolio summary tab on the home page in the _Presenter View_
- Refreshed the cryptocurrencies list
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
### Fixed
- Fixed an issue with `balanceInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `comment` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `dividendInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `interestInBaseCurrency` of the accounts in the value redaction interceptor for the impersonation mode
- Fixed an issue with `value` of the accounts in the value redaction interceptor for the impersonation mode
## 2.240.0 - 2026-02-18
### Added
- Added a _No Activities_ preset to the historical market data table of the admin control panel
- Added support for custom cryptocurrencies defined in the database
- Added support for the cryptocurrency _Sky_
### Changed
- Harmonized the validation for the create activity endpoint with the existing import activity logic
- Upgraded `marked` from version `17.0.1` to `17.0.2`
- Upgraded `ngx-markdown` from version `21.0.1` to `21.1.0`
## 2.239.0 - 2026-02-15
### Added
- Added a new static portfolio analysis rule based on the total investment volume: _Fees_ (Fee Ratio)
- Extended the content of the _Self-Hosting_ section on the Frequently Asked Questions (FAQ) page with information on derived currencies
### Changed
- Deprecated the existing static portfolio analysis rule: _Fees_ (Fee Ratio)
- Ignored nested ETFs when fetching top holdings for ETF and mutual fund assets from _Yahoo Finance_
- Improved the scraper configuration with more detailed error messages
- Improved the language localization for German (`de`)
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.1.0` to `13.2.2`
- Upgraded `cheerio` from version `1.0.0` to `1.2.0`
### Fixed
- Fixed the investment value by including currency effects in the portfolio summary tab on the home page
- Added the missing `valueInBaseCurrency` to the response of the import activities endpoint
## 2.238.0 - 2026-02-12
### Changed
- Upgraded `ngx-skeleton-loader` from version `11.3.0` to `12.0.0`
- Upgraded `twitter-api-v2` from version `1.27.0` to `1.29.0` - Upgraded `twitter-api-v2` from version `1.27.0` to `1.29.0`
### Fixed
- Fixed a performance calculation issue by resetting tracking variables when a holding is fully closed
- Fixed an issue in the annualized performance calculation
- Fixed an issue with the exchange rate calculation by expanding the date range to cover the full day (start to end of day)
## 2.237.0 - 2026-02-08 ## 2.237.0 - 2026-02-08
### Changed ### Changed

6
README.md

@ -313,10 +313,12 @@ Ghostfolio is **100% free** and **open source**. We encourage and support an act
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you. Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.
If you like to support this project, become a [**Sponsor**](https://github.com/sponsors/ghostfolio), get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
## Sponsors ## Sponsors
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing), become a [**Sponsor**](https://github.com/sponsors/ghostfolio) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
<br />
<div align="center"> <div align="center">
<a href="https://www.testmuai.com?utm_medium=sponsor&utm_source=ghostfolio" target="_blank" title="TestMu AI - AI Powered Testing Tool"> <a href="https://www.testmuai.com?utm_medium=sponsor&utm_source=ghostfolio" target="_blank" title="TestMu AI - AI Powered Testing Tool">
<img alt="TestMu AI Logo" height="45" src="https://assets.testmuai.com/resources/images/logos/logo.svg" /> <img alt="TestMu AI Logo" height="45" src="https://assets.testmuai.com/resources/images/logos/logo.svg" />

95
apps/api/src/app/order/order.controller.ts → apps/api/src/app/activities/activities.controller.ts

@ -4,6 +4,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.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';
@ -36,27 +37,32 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Order as OrderModel, Prisma } from '@prisma/client'; import { Order, Prisma, Type as ActivityType } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { OrderService } from './order.service'; import { ActivitiesService } from './activities.service';
@Controller('order') @Controller([
export class OrderController { 'activities',
/** @deprecated */
'order'
])
export class ActivitiesController {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataProviderService: DataProviderService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Delete() @Delete()
@HasPermission(permissions.deleteOrder) @HasPermission(permissions.deleteActivity)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteOrders( public async deleteActivities(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@ -71,29 +77,29 @@ export class OrderController {
filterByTags filterByTags
}); });
return this.orderService.deleteOrders({ return this.activitiesService.deleteActivities({
filters, filters,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteOrder) @HasPermission(permissions.deleteActivity)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteActivity(@Param('id') id: string): Promise<Order> {
const order = await this.orderService.order({ const activity = await this.activitiesService.order({
id, id,
userId: this.request.user.id userId: this.request.user.id
}); });
if (!order) { if (!activity) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
); );
} }
return this.orderService.deleteOrder({ return this.activitiesService.deleteActivity({
id id
}); });
} }
@ -103,9 +109,10 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders( public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange, @Query('range') dateRange?: DateRange,
@ -120,7 +127,7 @@ export class OrderController {
let startDate: Date; let startDate: Date;
if (dateRange) { if (dateRange) {
({ endDate, startDate } = getIntervalFromDateRange(dateRange)); ({ endDate, startDate } = getIntervalFromDateRange({ dateRange }));
} }
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
@ -133,14 +140,18 @@ export class OrderController {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.orderService.getOrders({ const { activities, count } = await this.activitiesService.getActivities({
endDate, endDate,
filters, filters,
sortColumn, sortColumn,
sortDirection, sortDirection,
startDate, startDate,
types,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
skip: isNaN(skip) ? undefined : skip, skip: isNaN(skip) ? undefined : skip,
@ -156,7 +167,7 @@ export class OrderController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getOrderById( public async getActivityById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string @Param('id') id: string
): Promise<ActivityResponse> { ): Promise<ActivityResponse> {
@ -164,7 +175,7 @@ export class OrderController {
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities } = await this.orderService.getOrders({ const { activities } = await this.activitiesService.getActivities({
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
@ -185,11 +196,34 @@ export class OrderController {
return activity; return activity;
} }
@HasPermission(permissions.createOrder) @HasPermission(permissions.createActivity)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { public async createActivity(@Body() data: CreateOrderDto): Promise<Order> {
try {
await this.dataProviderService.validateActivities({
activitiesDto: [
{
currency: data.currency,
dataSource: data.dataSource,
symbol: data.symbol,
type: data.type
}
],
maxActivitiesToImport: 1,
user: this.request.user
});
} catch (error) {
throw new HttpException(
{
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
message: [error.message]
},
StatusCodes.BAD_REQUEST
);
}
const currency = data.currency; const currency = data.currency;
const customCurrency = data.customCurrency; const customCurrency = data.customCurrency;
const dataSource = data.dataSource; const dataSource = data.dataSource;
@ -202,7 +236,7 @@ export class OrderController {
delete data.dataSource; delete data.dataSource;
const order = await this.orderService.createOrder({ const activity = await this.activitiesService.createActivity({
...data, ...data,
date: parseISO(data.date), date: parseISO(data.date),
SymbolProfile: { SymbolProfile: {
@ -227,14 +261,14 @@ export class OrderController {
userId: this.request.user.id userId: this.request.user.id
}); });
if (dataSource && !order.isDraft) { if (dataSource && !activity.isDraft) {
// 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 not draft
this.dataGatheringService.gatherSymbols({ this.dataGatheringService.gatherSymbols({
dataGatheringItems: [ dataGatheringItems: [
{ {
dataSource, dataSource,
date: order.date, date: activity.date,
symbol: data.symbol symbol: data.symbol
} }
], ],
@ -242,19 +276,22 @@ export class OrderController {
}); });
} }
return order; return activity;
} }
@HasPermission(permissions.updateOrder) @HasPermission(permissions.updateActivity)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { public async updateActivity(
const originalOrder = await this.orderService.order({ @Param('id') id: string,
@Body() data: UpdateOrderDto
) {
const originalActivity = await this.activitiesService.order({
id id
}); });
if (!originalOrder || originalOrder.userId !== this.request.user.id) { if (!originalActivity || originalActivity.userId !== this.request.user.id) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -277,7 +314,7 @@ export class OrderController {
delete data.dataSource; delete data.dataSource;
return this.orderService.updateOrder({ return this.activitiesService.updateActivity({
data: { data: {
...data, ...data,
date, date,

12
apps/api/src/app/order/order.module.ts → apps/api/src/app/activities/activities.module.ts

@ -15,12 +15,12 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/sym
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { OrderController } from './order.controller'; import { ActivitiesController } from './activities.controller';
import { OrderService } from './order.service'; import { ActivitiesService } from './activities.service';
@Module({ @Module({
controllers: [OrderController], controllers: [ActivitiesController],
exports: [OrderService], exports: [ActivitiesService],
imports: [ imports: [
ApiModule, ApiModule,
CacheModule, CacheModule,
@ -35,6 +35,6 @@ import { OrderService } from './order.service';
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule TransformDataSourceInResponseModule
], ],
providers: [AccountBalanceService, AccountService, OrderService] providers: [AccountBalanceService, AccountService, ActivitiesService]
}) })
export class OrderModule {} export class ActivitiesModule {}

75
apps/api/src/app/order/order.service.ts → apps/api/src/app/activities/activities.service.ts

@ -44,7 +44,7 @@ import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class OrderService { export class ActivitiesService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
@ -62,7 +62,7 @@ export class OrderService {
tags, tags,
userId userId
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
const orders = await this.prismaService.order.findMany({ const activities = await this.prismaService.order.findMany({
where: { where: {
userId, userId,
SymbolProfile: { SymbolProfile: {
@ -73,7 +73,7 @@ export class OrderService {
}); });
await Promise.all( await Promise.all(
orders.map(({ id }) => activities.map(({ id }) =>
this.prismaService.order.update({ this.prismaService.order.update({
data: { data: {
tags: { tags: {
@ -96,7 +96,7 @@ export class OrderService {
); );
} }
public async createOrder( public async createActivity(
data: Prisma.OrderCreateInput & { data: Prisma.OrderCreateInput & {
accountId?: string; accountId?: string;
assetClass?: AssetClass; assetClass?: AssetClass;
@ -201,7 +201,7 @@ export class OrderService {
? false ? false
: isAfter(data.date as Date, endOfToday()); : isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({ const activity = await this.prismaService.order.create({
data: { data: {
...orderData, ...orderData,
account, account,
@ -235,56 +235,56 @@ export class OrderService {
this.eventEmitter.emit( this.eventEmitter.emit(
AssetProfileChangedEvent.getName(), AssetProfileChangedEvent.getName(),
new AssetProfileChangedEvent({ new AssetProfileChangedEvent({
currency: order.SymbolProfile.currency, currency: activity.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource, dataSource: activity.SymbolProfile.dataSource,
symbol: order.SymbolProfile.symbol symbol: activity.SymbolProfile.symbol
}) })
); );
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({
userId: order.userId userId: activity.userId
}) })
); );
return order; return activity;
} }
public async deleteOrder( public async deleteActivity(
where: Prisma.OrderWhereUniqueInput where: Prisma.OrderWhereUniqueInput
): Promise<Order> { ): Promise<Order> {
const order = await this.prismaService.order.delete({ const activity = await this.prismaService.order.delete({
where where
}); });
const [symbolProfile] = const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesByIds([ await this.symbolProfileService.getSymbolProfilesByIds([
order.symbolProfileId activity.symbolProfileId
]); ]);
if (symbolProfile.activitiesCount === 0) { if (symbolProfile.activitiesCount === 0) {
await this.symbolProfileService.deleteById(order.symbolProfileId); await this.symbolProfileService.deleteById(activity.symbolProfileId);
} }
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({
userId: order.userId userId: activity.userId
}) })
); );
return order; return activity;
} }
public async deleteOrders({ public async deleteActivities({
filters, filters,
userId userId
}: { }: {
filters?: Filter[]; filters?: Filter[];
userId: string; userId: string;
}): Promise<number> { }): Promise<number> {
const { activities } = await this.getOrders({ const { activities } = await this.getActivities({
filters, filters,
userId, userId,
includeDrafts: true, includeDrafts: true,
@ -324,7 +324,7 @@ export class OrderService {
} }
/** /**
* Generates synthetic orders for cash holdings based on account balance history. * Generates synthetic activities for cash holdings based on account balance history.
* Treat currencies as assets with a fixed unit price of 1.0 (in their own currency) to allow * Treat currencies as assets with a fixed unit price of 1.0 (in their own currency) to allow
* performance tracking based on exchange rate fluctuations. * performance tracking based on exchange rate fluctuations.
* *
@ -334,7 +334,7 @@ export class OrderService {
* @param userId - The ID of the user. * @param userId - The ID of the user.
* @returns A response containing the list of synthetic cash activities. * @returns A response containing the list of synthetic cash activities.
*/ */
public async getCashOrders({ public async getCashActivities({
cashDetails, cashDetails,
filters = [], filters = [],
userCurrency, userCurrency,
@ -448,7 +448,10 @@ export class OrderService {
}; };
} }
public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) { public async getLatestActivity({
dataSource,
symbol
}: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({ return this.prismaService.order.findFirst({
orderBy: { orderBy: {
date: 'desc' date: 'desc'
@ -459,7 +462,7 @@ export class OrderService {
}); });
} }
public async getOrders({ public async getActivities({
endDate, endDate,
filters, filters,
includeDrafts = false, includeDrafts = false,
@ -626,7 +629,7 @@ export class OrderService {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
} }
if (types) { if (types?.length > 0) {
where.type = { in: types }; where.type = { in: types };
} }
@ -742,17 +745,17 @@ export class OrderService {
} }
/** /**
* Retrieves all orders required for the portfolio calculator, including both standard asset orders * Retrieves all activities required for the portfolio calculator, including both standard asset activities
* and optional synthetic orders representing cash activities. * and optional synthetic activities representing cash activities.
*/ */
@LogPerformance @LogPerformance
public async getOrdersForPortfolioCalculator({ public async getActivitiesForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId, userId,
withCash = false withCash = false
}: { }: {
/** Optional filters to apply to the orders. */ /** Optional filters to apply to the activities. */
filters?: Filter[]; filters?: Filter[];
/** The base currency of the user. */ /** The base currency of the user. */
userCurrency: string; userCurrency: string;
@ -761,7 +764,7 @@ export class OrderService {
/** Whether to include cash activities in the result. */ /** Whether to include cash activities in the result. */
withCash?: boolean; withCash?: boolean;
}) { }) {
const orders = await this.getOrders({ const activities = await this.getActivities({
filters, filters,
userCurrency, userCurrency,
userId, userId,
@ -775,18 +778,18 @@ export class OrderService {
currency: userCurrency currency: userCurrency
}); });
const cashOrders = await this.getCashOrders({ const cashActivities = await this.getCashActivities({
cashDetails, cashDetails,
filters, filters,
userCurrency, userCurrency,
userId userId
}); });
orders.activities.push(...cashOrders.activities); activities.activities.push(...cashActivities.activities);
orders.count += cashOrders.count; activities.count += cashActivities.count;
} }
return orders; return activities;
} }
public async getStatisticsByCurrency( public async getStatisticsByCurrency(
@ -817,7 +820,7 @@ export class OrderService {
}); });
} }
public async updateOrder({ public async updateActivity({
data, data,
where where
}: { }: {
@ -882,7 +885,7 @@ export class OrderService {
data: { tags: { set: [] } } data: { tags: { set: [] } }
}); });
const order = await this.prismaService.order.update({ const activity = await this.prismaService.order.update({
where, where,
data: { data: {
...data, ...data,
@ -896,11 +899,11 @@ export class OrderService {
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({
userId: order.userId userId: activity.userId
}) })
); );
return order; return activity;
} }
private async orders(params: { private async orders(params: {

9
apps/api/src/app/admin/admin.controller.ts

@ -172,7 +172,7 @@ export class AdminController {
let date: Date; let date: Date;
if (dateRange) { if (dateRange) {
const { startDate } = getIntervalFromDateRange(dateRange); const { startDate } = getIntervalFromDateRange({ dateRange });
date = startDate; date = startDate;
} }
@ -247,14 +247,17 @@ export class AdminController {
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<{ price: number }> { ): Promise<{ price: number }> {
try { try {
const price = await this.manualService.test(data.scraperConfiguration); const price = await this.manualService.test({
symbol,
scraperConfiguration: data.scraperConfiguration
});
if (price) { if (price) {
return { price }; return { price };
} }
throw new Error( throw new Error(
`Could not parse the current market price for ${symbol} (${dataSource})` `Could not parse the market price for ${symbol} (${dataSource})`
); );
} catch (error) { } catch (error) {
Logger.error(error, 'AdminController'); Logger.error(error, 'AdminController');

4
apps/api/src/app/admin/admin.module.ts

@ -1,4 +1,4 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
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 { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
@ -20,6 +20,7 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
ActivitiesModule,
ApiModule, ApiModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
@ -28,7 +29,6 @@ import { QueueModule } from './queue/queue.module';
DemoModule, DemoModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
QueueModule, QueueModule,

18
apps/api/src/app/admin/admin.service.ts

@ -1,4 +1,4 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -55,12 +55,12 @@ import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
@ -225,6 +225,10 @@ export class AdminService {
presetId === 'ETF_WITHOUT_SECTORS' presetId === 'ETF_WITHOUT_SECTORS'
) { ) {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
} else if (presetId === 'NO_ACTIVITIES') {
where.activities = {
none: {}
};
} }
const searchQuery = filters.find(({ type }) => { const searchQuery = filters.find(({ type }) => {
@ -466,10 +470,12 @@ export class AdminService {
let currency: EnhancedSymbolProfile['currency'] = '-'; let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) { const isCurrencyAssetProfile = isCurrency(getCurrencyFromSymbol(symbol));
if (isCurrencyAssetProfile) {
currency = getCurrencyFromSymbol(symbol); currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } = ({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency)); await this.activitiesService.getStatisticsByCurrency(currency));
} }
const [[assetProfile], marketData] = await Promise.all([ const [[assetProfile], marketData] = await Promise.all([
@ -504,6 +510,8 @@ export class AdminService {
dataSource, dataSource,
dateOfFirstActivity, dateOfFirstActivity,
symbol, symbol,
assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined,
assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined,
isActive: true isActive: true
} }
}; };
@ -790,7 +798,7 @@ export class AdminService {
if (isCurrency(getCurrencyFromSymbol(symbol))) { if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol); currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } = ({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency)); await this.activitiesService.getStatisticsByCurrency(currency));
} }
const lastMarketPrice = lastMarketPriceMap.get( const lastMarketPrice = lastMarketPriceMap.get(

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

@ -1,4 +1,5 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { EventsModule } from '@ghostfolio/api/events/events.module';
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';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronModule } from '@ghostfolio/api/services/cron/cron.module'; import { CronModule } from '@ghostfolio/api/services/cron/cron.module';
@ -10,10 +11,13 @@ import { PropertyModule } from '@ghostfolio/api/services/property/property.modul
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { import {
BULL_BOARD_ROUTE,
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { ExpressAdapter } from '@bull-board/express';
import { BullBoardModule } from '@bull-board/nestjs';
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';
@ -25,6 +29,7 @@ import { join } from 'node:path';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';
import { ActivitiesModule } from './activities/activities.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module'; import { AssetModule } from './asset/asset.module';
@ -48,7 +53,6 @@ import { HealthModule } from './health/health.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
import { LogoModule } from './logo/logo.module'; import { LogoModule } from './logo/logo.module';
import { OrderModule } from './order/order.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module';
@ -62,6 +66,7 @@ import { UserModule } from './user/user.module';
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
ActivitiesModule,
AiModule, AiModule,
ApiKeysModule, ApiKeysModule,
AssetModule, AssetModule,
@ -69,6 +74,29 @@ import { UserModule } from './user/user.module';
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarksModule, BenchmarksModule,
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forRoot({
adapter: ExpressAdapter,
boardOptions: {
uiConfig: {
boardLogo: {
height: 0,
path: '',
width: 0
},
boardTitle: 'Job Queues',
favIcon: {
alternative: '/assets/favicon-32x32.png',
default: '/assets/favicon-32x32.png'
}
}
},
middleware: BullBoardAuthMiddleware,
route: BULL_BOARD_ROUTE
})
]
: []),
BullModule.forRoot({ BullModule.forRoot({
redis: { redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10), db: parseInt(process.env.REDIS_DB ?? '0', 10),
@ -94,7 +122,6 @@ import { UserModule } from './user/user.module';
InfoModule, InfoModule,
LogoModule, LogoModule,
MarketDataModule, MarketDataModule,
OrderModule,
PlatformModule, PlatformModule,
PlatformsModule, PlatformsModule,
PortfolioModule, PortfolioModule,
@ -105,7 +132,12 @@ import { UserModule } from './user/user.module';
RedisCacheModule, RedisCacheModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'], exclude: [
`${BULL_BOARD_ROUTE}/*wildcard`,
'/.well-known/*wildcard',
'/api/*wildcard',
'/sitemap.xml'
],
rootPath: join(__dirname, '..', 'client'), rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: { serveStaticOptions: {
setHeaders: (res) => { setHeaders: (res) => {

29
apps/api/src/app/auth-device/auth-device.controller.ts

@ -2,18 +2,43 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
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';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Delete, Param, UseGuards } from '@nestjs/common'; import {
Controller,
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@Controller('auth-device') @Controller('auth-device')
export class AuthDeviceController { export class AuthDeviceController {
public constructor(private readonly authDeviceService: AuthDeviceService) {} public constructor(
private readonly authDeviceService: AuthDeviceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteAuthDevice) @HasPermission(permissions.deleteAuthDevice)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAuthDevice(@Param('id') id: string): Promise<void> { public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
const originalAuthDevice = await this.authDeviceService.authDevice({
id,
userId: this.request.user.id
});
if (!originalAuthDevice) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.authDeviceService.deleteAuthDevice({ id }); await this.authDeviceService.deleteAuthDevice({ id });
} }
} }

4
apps/api/src/app/endpoints/ai/ai.module.ts

@ -1,6 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -29,6 +29,7 @@ import { AiService } from './ai.service';
@Module({ @Module({
controllers: [AiController], controllers: [AiController],
imports: [ imports: [
ActivitiesModule,
ApiModule, ApiModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
@ -37,7 +38,6 @@ import { AiService } from './ai.service';
I18nModule, I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule, PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,

7
apps/api/src/app/endpoints/assets/assets.controller.ts

@ -4,6 +4,7 @@ import { interpolate } from '@ghostfolio/common/helper';
import { import {
Controller, Controller,
Get, Get,
OnModuleInit,
Param, Param,
Res, Res,
Version, Version,
@ -14,12 +15,14 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
@Controller('assets') @Controller('assets')
export class AssetsController { export class AssetsController implements OnModuleInit {
private webManifest = ''; private webManifest = '';
public constructor( public constructor(
public readonly configurationService: ConfigurationService public readonly configurationService: ConfigurationService
) { ) {}
public onModuleInit() {
try { try {
this.webManifest = readFileSync( this.webManifest = readFileSync(
join(__dirname, 'assets', 'site.webmanifest'), join(__dirname, 'assets', 'site.webmanifest'),

6
apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts

@ -126,10 +126,10 @@ export class BenchmarksController {
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false' @Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetailsResponse> { ): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange( const { endDate, startDate } = getIntervalFromDateRange({
dateRange, dateRange,
new Date(startDateString) startDate: new Date(startDateString)
); });
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,

4
apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts

@ -1,6 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -32,6 +32,7 @@ import { BenchmarksService } from './benchmarks.service';
@Module({ @Module({
controllers: [BenchmarksController], controllers: [BenchmarksController],
imports: [ imports: [
ActivitiesModule,
ApiModule, ApiModule,
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
@ -39,7 +40,6 @@ import { BenchmarksService } from './benchmarks.service';
I18nModule, I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule, PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,

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

@ -1,5 +1,5 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.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 { UserService } from '@ghostfolio/api/app/user/user.service';
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';
@ -28,9 +28,9 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
export class PublicController { export class PublicController {
public constructor( public constructor(
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
@ -81,7 +81,7 @@ export class PublicController {
}) })
]); ]);
const { activities } = await this.orderService.getOrders({ const { activities } = await this.activitiesService.getActivities({
sortColumn: 'date', sortColumn: 'date',
sortDirection: 'desc', sortDirection: 'desc',
take: 10, take: 10,
@ -167,6 +167,7 @@ export class PublicController {
allocationInPercentage: allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue, portfolioPosition.valueInBaseCurrency / totalValue,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined, assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
assetProfile: hasDetails ? portfolioPosition.assetProfile : undefined,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined, currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource, dataSource: portfolioPosition.dataSource,

4
apps/api/src/app/endpoints/public/public.module.ts

@ -1,7 +1,7 @@
import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
@ -27,13 +27,13 @@ import { PublicController } from './public.controller';
controllers: [PublicController], controllers: [PublicController],
imports: [ imports: [
AccessModule, AccessModule,
ActivitiesModule,
BenchmarkModule, BenchmarkModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule, I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule, PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,
RedisCacheModule, RedisCacheModule,

5
apps/api/src/app/export/export.controller.ts

@ -15,6 +15,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
@ -33,12 +34,15 @@ export class ExportController {
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string, @Query('activityIds') filterByActivityIds?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string, @Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<ExportResponse> { ): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? []; const activityIds = filterByActivityIds?.split(',') ?? [];
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -49,6 +53,7 @@ export class ExportController {
return this.exportService.export({ return this.exportService.export({
activityIds, activityIds,
activityTypes,
filters, filters,
userId: this.request.user.id, userId: this.request.user.id,
userSettings: this.request.user.settings.settings userSettings: this.request.user.settings.settings

4
apps/api/src/app/export/export.module.ts

@ -1,5 +1,5 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module'; import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
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 { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
@ -14,9 +14,9 @@ import { ExportService } from './export.service';
controllers: [ExportController], controllers: [ExportController],
imports: [ imports: [
AccountModule, AccountModule,
ActivitiesModule,
ApiModule, ApiModule,
MarketDataModule, MarketDataModule,
OrderModule,
TagModule, TagModule,
TransformDataSourceInRequestModule TransformDataSourceInRequestModule
], ],

16
apps/api/src/app/export/export.service.ts

@ -1,5 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
@ -10,25 +10,27 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Platform, Prisma } from '@prisma/client'; import { Platform, Prisma, Type as ActivityType } from '@prisma/client';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
@Injectable() @Injectable()
export class ExportService { export class ExportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly activitiesService: ActivitiesService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly tagService: TagService private readonly tagService: TagService
) {} ) {}
public async export({ public async export({
activityIds, activityIds,
activityTypes,
filters, filters,
userId, userId,
userSettings userSettings
}: { }: {
activityIds?: string[]; activityIds?: string[];
activityTypes?: ActivityType[];
filters?: Filter[]; filters?: Filter[];
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
@ -38,12 +40,13 @@ export class ExportService {
}); });
const platformsMap: { [platformId: string]: Platform } = {}; const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.orderService.getOrders({ let { activities } = await this.activitiesService.getActivities({
filters, filters,
userId, userId,
includeDrafts: true, includeDrafts: true,
sortColumn: 'date', sortColumn: 'date',
sortDirection: 'asc', sortDirection: 'asc',
types: activityTypes,
userCurrency: userSettings?.baseCurrency, userCurrency: userSettings?.baseCurrency,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
@ -182,10 +185,8 @@ export class ExportService {
isActive, isActive,
isin, isin,
name, name,
scraperConfiguration,
sectors, sectors,
symbol, symbol,
symbolMapping,
url url
}) => { }) => {
return { return {
@ -204,11 +205,8 @@ export class ExportService {
isin, isin,
marketData: marketDataByAssetProfile[id], marketData: marketDataByAssetProfile[id],
name, name,
scraperConfiguration:
scraperConfiguration as unknown as Prisma.JsonArray,
sectors: sectors as unknown as Prisma.JsonArray, sectors: sectors as unknown as Prisma.JsonArray,
symbol, symbol,
symbolMapping,
url url
}; };
} }

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

@ -38,7 +38,7 @@ export class ImportController {
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@HasPermission(permissions.createOrder) @HasPermission(permissions.createActivity)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async import( public async import(

4
apps/api/src/app/import/import.module.ts

@ -1,6 +1,6 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module'; import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
@ -25,6 +25,7 @@ import { ImportService } from './import.service';
controllers: [ImportController], controllers: [ImportController],
imports: [ imports: [
AccountModule, AccountModule,
ActivitiesModule,
ApiModule, ApiModule,
CacheModule, CacheModule,
ConfigurationModule, ConfigurationModule,
@ -32,7 +33,6 @@ import { ImportService } from './import.service';
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule,
PlatformModule, PlatformModule,
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,

154
apps/api/src/app/import/import.service.ts

@ -1,10 +1,10 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.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 { 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 { 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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
@ -32,7 +32,7 @@ import {
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns'; import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
import { omit, uniqBy } from 'lodash'; import { omit, uniqBy } from 'lodash';
@ -44,12 +44,12 @@ import { ImportDataDto } from './import-data.dto';
export class ImportService { export class ImportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly platformService: PlatformService, private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
@ -91,7 +91,7 @@ export class ImportService {
userId, userId,
withExcludedAccounts: true withExcludedAccounts: true
}), }),
this.orderService.getOrders({ this.activitiesService.getActivities({
filters, filters,
userCurrency, userCurrency,
userId, userId,
@ -393,7 +393,7 @@ export class ImportService {
} }
} }
const assetProfiles = await this.validateActivities({ const assetProfiles = await this.dataProviderService.validateActivities({
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto, assetProfilesWithMarketDataDto,
maxActivitiesToImport, maxActivitiesToImport,
@ -548,7 +548,7 @@ export class ImportService {
continue; continue;
} }
order = await this.orderService.createOrder({ order = await this.activitiesService.createActivity({
comment, comment,
currency, currency,
date, date,
@ -590,10 +590,18 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
const valueInBaseCurrency = this.exchangeRateDataService.toCurrencyAtDate(
value,
currency ?? assetProfile.currency,
userCurrency,
date
);
activities.push({ activities.push({
...order, ...order,
error, error,
value, value,
valueInBaseCurrency: await valueInBaseCurrency,
// @ts-ignore // @ts-ignore
SymbolProfile: assetProfile SymbolProfile: assetProfile
}); });
@ -637,7 +645,7 @@ export class ImportService {
userId: string; userId: string;
}): Promise<Partial<Activity>[]> { }): Promise<Partial<Activity>[]> {
const { activities: existingActivities } = const { activities: existingActivities } =
await this.orderService.getOrders({ await this.activitiesService.getActivities({
userCurrency, userCurrency,
userId, userId,
includeDrafts: true, includeDrafts: true,
@ -719,132 +727,4 @@ export class ImportService {
return uniqueAccountIds.size === 1; return uniqueAccountIds.size === 1;
} }
private async validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
user
}: {
activitiesDto: Partial<CreateOrderDto>[];
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
maxActivitiesToImport: number;
user: UserWithSettings;
}) {
if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
}
const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {};
const dataSources = await this.dataProviderService.getDataSources();
for (const [
index,
{ currency, dataSource, symbol, type }
] of activitiesDto.entries()) {
if (!dataSources.includes(dataSource)) {
throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid`
);
}
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user.subscription.type === 'Basic'
) {
const dataProvider = this.dataProviderService.getDataProvider(
DataSource[dataSource]
);
if (dataProvider.getDataProviderInfo().isPremium) {
throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid`
);
}
}
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
// Skip asset profile validation for FEE, INTEREST, and LIABILITY
// as these activity types don't require asset profiles
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = {
currency,
dataSource,
symbol,
name: assetProfileInImport?.name
};
continue;
}
let assetProfile: Partial<SymbolProfile> = { currency };
try {
assetProfile = (
await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
])
)?.[symbol];
} catch {}
if (!assetProfile?.name) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
if (assetProfileInImport) {
// Merge all fields of custom asset profiles into the validation object
Object.assign(assetProfile, {
assetClass: assetProfileInImport.assetClass,
assetSubClass: assetProfileInImport.assetSubClass,
comment: assetProfileInImport.comment,
countries: assetProfileInImport.countries,
currency: assetProfileInImport.currency,
cusip: assetProfileInImport.cusip,
dataSource: assetProfileInImport.dataSource,
figi: assetProfileInImport.figi,
figiComposite: assetProfileInImport.figiComposite,
figiShareClass: assetProfileInImport.figiShareClass,
holdings: assetProfileInImport.holdings,
isActive: assetProfileInImport.isActive,
isin: assetProfileInImport.isin,
name: assetProfileInImport.name,
scraperConfiguration: assetProfileInImport.scraperConfiguration,
sectors: assetProfileInImport.sectors,
symbol: assetProfileInImport.symbol,
symbolMapping: assetProfileInImport.symbolMapping,
url: assetProfileInImport.url
});
}
}
if (!['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
}
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
assetProfile;
}
}
return assetProfiles;
}
} }

17
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -53,6 +53,7 @@ import {
isBefore, isBefore,
isWithinInterval, isWithinInterval,
min, min,
startOfDay,
startOfYear, startOfYear,
subDays subDays
} from 'date-fns'; } from 'date-fns';
@ -157,13 +158,13 @@ export abstract class PortfolioCalculator {
this.redisCacheService = redisCacheService; this.redisCacheService = redisCacheService;
this.userId = userId; this.userId = userId;
const { endDate, startDate } = getIntervalFromDateRange( const { endDate, startDate } = getIntervalFromDateRange({
'max', dateRange: 'max',
subDays(dateOfFirstActivity, 1) startDate: subDays(dateOfFirstActivity, 1)
); });
this.endDate = endDate; this.endDate = endOfDay(endDate);
this.startDate = startDate; this.startDate = startOfDay(startDate);
this.computeTransactionPoints(); this.computeTransactionPoints();
@ -236,7 +237,7 @@ export abstract class PortfolioCalculator {
const exchangeRatesByCurrency = const exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({ await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: Array.from(new Set(Object.values(currencies))), currencies: Array.from(new Set(Object.values(currencies))),
endDate: endOfDay(this.endDate), endDate: this.endDate,
startDate: this.startDate, startDate: this.startDate,
targetCurrency: this.currency targetCurrency: this.currency
}); });
@ -884,7 +885,7 @@ export abstract class PortfolioCalculator {
// Make sure some key dates are present // Make sure some key dates are present
for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) { for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) {
const { endDate: dateRangeEnd, startDate: dateRangeStart } = const { endDate: dateRangeEnd, startDate: dateRangeStart } =
getIntervalFromDateRange(dateRange); getIntervalFromDateRange({ dateRange });
if ( if (
!isBefore(dateRangeStart, startDate) && !isBefore(dateRangeStart, startDate) &&

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts

@ -194,6 +194,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.07032490039195362, netPerformanceInPercentage: 0.07032490039195362,
netPerformanceInPercentageWithCurrencyEffect: 0.07032490039195362, netPerformanceInPercentageWithCurrencyEffect: 0.07032490039195362,
netPerformanceWithCurrencyEffect: 33.4, netPerformanceWithCurrencyEffect: 33.4,
totalInvestment: 559,
totalInvestmentValueWithCurrencyEffect: 559 totalInvestmentValueWithCurrencyEffect: 559
}) })
); );

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -208,6 +208,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: -0.05528341497550734703, netPerformanceInPercentage: -0.05528341497550734703,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703,
netPerformanceWithCurrencyEffect: -15.8, netPerformanceWithCurrencyEffect: -15.8,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0 totalInvestmentValueWithCurrencyEffect: 0
}) })
); );

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -192,6 +192,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: -0.05528341497550734703, netPerformanceInPercentage: -0.05528341497550734703,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703,
netPerformanceWithCurrencyEffect: -15.8, netPerformanceWithCurrencyEffect: -15.8,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0 totalInvestmentValueWithCurrencyEffect: 0
}) })
); );

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts

@ -192,6 +192,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.08437042459736457, netPerformanceInPercentage: 0.08437042459736457,
netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457, netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457,
netPerformanceWithCurrencyEffect: 23.05, netPerformanceWithCurrencyEffect: 23.05,
totalInvestment: 273.2,
totalInvestmentValueWithCurrencyEffect: 273.2 totalInvestmentValueWithCurrencyEffect: 273.2
}) })
); );

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -210,6 +210,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 42.41983590271396609433, netPerformanceInPercentage: 42.41983590271396609433,
netPerformanceInPercentageWithCurrencyEffect: 41.64017412624815597854, netPerformanceInPercentageWithCurrencyEffect: 41.64017412624815597854,
netPerformanceWithCurrencyEffect: 26516.208701400000064086, netPerformanceWithCurrencyEffect: 26516.208701400000064086,
totalInvestment: 318.542667299999967957,
totalInvestmentValueWithCurrencyEffect: 318.542667299999967957 totalInvestmentValueWithCurrencyEffect: 318.542667299999967957
}) })
); );

17
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -1,6 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
@ -62,11 +62,11 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let accountBalanceService: AccountBalanceService; let accountBalanceService: AccountBalanceService;
let accountService: AccountService; let accountService: AccountService;
let activitiesService: ActivitiesService;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService; let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let orderService: OrderService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService; let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
@ -106,13 +106,13 @@ describe('PortfolioCalculator', () => {
); );
currentRateService = new CurrentRateService( currentRateService = new CurrentRateService(
dataProviderService,
null, null,
dataProviderService,
null, null,
null null
); );
orderService = new OrderService( activitiesService = new ActivitiesService(
accountBalanceService, accountBalanceService,
accountService, accountService,
null, null,
@ -183,18 +183,17 @@ describe('PortfolioCalculator', () => {
.spyOn(dataProviderService, 'getDataSourceForExchangeRates') .spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO); .mockReturnValue(DataSource.YAHOO);
jest.spyOn(orderService, 'getOrders').mockResolvedValue({ jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({
activities: [], activities: [],
count: 0 count: 0
}); });
const { activities } = await orderService.getOrdersForPortfolioCalculator( const { activities } =
{ await activitiesService.getActivitiesForPortfolioCalculator({
userCurrency: 'CHF', userCurrency: 'CHF',
userId: userDummyData.id, userId: userDummyData.id,
withCash: true withCash: true
} });
);
jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [], dataProviderInfos: [],

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -128,6 +128,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0, netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0 totalInvestmentValueWithCurrencyEffect: 0
}) })
); );

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -188,6 +188,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.29544434470377019749, netPerformanceInPercentage: 0.29544434470377019749,
netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628, netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628,
netPerformanceWithCurrencyEffect: 19.851974, netPerformanceWithCurrencyEffect: 19.851974,
totalInvestment: new Big('89.12').mul(0.8854).toNumber(),
totalInvestmentValueWithCurrencyEffect: 82.329056 totalInvestmentValueWithCurrencyEffect: 82.329056
}) })
); );

190
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts

@ -0,0 +1,190 @@
import {
activityDummyData,
loadExportFile,
symbolProfileDummyData,
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 { 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, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'node:path';
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('PortfolioCalculator', () => {
let exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with JNUG buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 4,
averagePrice: new Big('0'),
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: '2025-12-11',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4'),
feeInBaseCurrency: new Big('4'),
grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
netPerformanceWithCurrencyEffectMap: {
max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
},
marketPrice: 237.8000030517578,
marketPriceInBaseCurrency: 237.8000030517578,
quantity: new Big('0'),
symbol: 'JNUG',
tags: [],
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('4'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2025-12-11', investment: new Big('1885.05') },
{ date: '2025-12-18', investment: new Big('2041.1') },
{ date: '2025-12-28', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2025-12-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2025-01-01', investment: 0 }
]);
});
});
});

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -174,6 +174,7 @@ describe('PortfolioCalculator', () => {
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
totalInvestment: 298.58,
totalInvestmentValueWithCurrencyEffect: 298.58 totalInvestmentValueWithCurrencyEffect: 298.58
}) })
); );

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -190,6 +190,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.12184460284330327256, netPerformanceInPercentage: 0.12184460284330327256,
netPerformanceInPercentageWithCurrencyEffect: 0.12184460284330327256, netPerformanceInPercentageWithCurrencyEffect: 0.12184460284330327256,
netPerformanceWithCurrencyEffect: 17.68, netPerformanceWithCurrencyEffect: 17.68,
totalInvestment: 75.8,
totalInvestmentValueWithCurrencyEffect: 75.8 totalInvestmentValueWithCurrencyEffect: 75.8
}) })
); );

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -241,6 +241,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.13100263852242744063, netPerformanceInPercentage: 0.13100263852242744063,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063,
netPerformanceWithCurrencyEffect: 19.86, netPerformanceWithCurrencyEffect: 19.86,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0 totalInvestmentValueWithCurrencyEffect: 0
}) })
); );

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -162,6 +162,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0, netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
totalInvestment: 500000,
totalInvestmentValueWithCurrencyEffect: 500000 totalInvestmentValueWithCurrencyEffect: 500000
}) })
); );

9
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -626,6 +626,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
totalQuantityFromBuyTransactions totalQuantityFromBuyTransactions
); );
if (totalUnits.eq(0)) {
// Reset tracking variables when position is fully closed
totalInvestmentFromBuyTransactions = new Big(0);
totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
totalQuantityFromBuyTransactions = new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) { if (PortfolioCalculator.ENABLE_LOGGING) {
console.log( console.log(
'grossPerformanceFromSells', 'grossPerformanceFromSells',
@ -853,7 +860,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
return format(date, 'yyyy'); return format(date, 'yyyy');
}) })
] as DateRange[]) { ] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange); const dateInterval = getIntervalFromDateRange({ dateRange });
const endDate = dateInterval.endDate; const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate; let startDate = dateInterval.startDate;

11
apps/api/src/app/portfolio/current-rate.service.mock.ts

@ -64,6 +64,17 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 }; return { marketPrice: 0 };
case 'JNUG':
if (isSameDay(parseDate('2025-12-10'), date)) {
return { marketPrice: 204.5599975585938 };
} else if (isSameDay(parseDate('2025-12-17'), date)) {
return { marketPrice: 203.9700012207031 };
} else if (isSameDay(parseDate('2025-12-28'), date)) {
return { marketPrice: 237.8000030517578 };
}
return { marketPrice: 0 };
case 'MSFT': case 'MSFT':
if (isSameDay(parseDate('2021-09-16'), date)) { if (isSameDay(parseDate('2021-09-16'), date)) {
return { marketPrice: 89.12 }; return { marketPrice: 89.12 };

2
apps/api/src/app/portfolio/current-rate.service.spec.ts

@ -114,9 +114,9 @@ describe('CurrentRateService', () => {
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);
currentRateService = new CurrentRateService( currentRateService = new CurrentRateService(
null,
dataProviderService, dataProviderService,
marketDataService, marketDataService,
null,
null null
); );
}); });

7
apps/api/src/app/portfolio/current-rate.service.ts

@ -1,4 +1,4 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
@ -24,9 +24,9 @@ export class CurrentRateService {
private static readonly MARKET_DATA_PAGE_SIZE = 50000; private static readonly MARKET_DATA_PAGE_SIZE = 50000;
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -129,7 +129,8 @@ export class CurrentRateService {
if (!value) { if (!value) {
// Fallback to unit price of latest activity // Fallback to unit price of latest activity
const latestActivity = await this.orderService.getLatestOrder({ const latestActivity =
await this.activitiesService.getLatestActivity({
dataSource, dataSource,
symbol symbol
}); });

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

@ -1,4 +1,4 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
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 { import {
@ -63,10 +63,10 @@ import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
public constructor( public constructor(
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 impersonationService: ImpersonationService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -187,7 +187,6 @@ export class PortfolioController {
portfolioSummary = nullifyValuesInObject(summary, [ portfolioSummary = nullifyValuesInObject(summary, [
'cash', 'cash',
'committedFunds',
'currentNetWorth', 'currentNetWorth',
'currentValueInBaseCurrency', 'currentValueInBaseCurrency',
'dividendInBaseCurrency', 'dividendInBaseCurrency',
@ -206,6 +205,7 @@ export class PortfolioController {
'netPerformanceWithCurrencyEffect', 'netPerformanceWithCurrencyEffect',
'totalBuy', 'totalBuy',
'totalInvestment', 'totalInvestment',
'totalInvestmentValueWithCurrencyEffect',
'totalSell', 'totalSell',
'totalValueInBaseCurrency' 'totalValueInBaseCurrency'
]); ]);
@ -320,9 +320,9 @@ export class PortfolioController {
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { endDate, startDate } = getIntervalFromDateRange(dateRange); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { activities } = await this.orderService.getOrders({ const { activities } = await this.activitiesService.getActivities({
endDate, endDate,
filters, filters,
startDate, startDate,
@ -639,7 +639,7 @@ export class PortfolioController {
return report; return report;
} }
@HasPermission(permissions.updateOrder) @HasPermission(permissions.updateActivity)
@Put('holding/:dataSource/:symbol/tags') @Put('holding/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

4
apps/api/src/app/portfolio/portfolio.module.ts

@ -1,7 +1,7 @@
import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { PerformanceLoggingModule } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.module'; import { PerformanceLoggingModule } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.module';
@ -34,6 +34,7 @@ import { RulesService } from './rules.service';
exports: [PortfolioService], exports: [PortfolioService],
imports: [ imports: [
AccessModule, AccessModule,
ActivitiesModule,
ApiModule, ApiModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
@ -43,7 +44,6 @@ import { RulesService } from './rules.service';
I18nModule, I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule,
PerformanceLoggingModule, PerformanceLoggingModule,
PortfolioSnapshotQueueModule, PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,

81
apps/api/src/app/portfolio/portfolio.service.ts

@ -1,7 +1,7 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
@ -13,7 +13,7 @@ import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rul
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 { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { 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';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific'; import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets'; import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
@ -105,13 +105,13 @@ export class PortfolioService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly calculatorFactory: PortfolioCalculatorFactory,
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, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@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,
@ -403,10 +403,10 @@ export class PortfolioService {
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 { endDate, startDate } = getIntervalFromDateRange(dateRange); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { activities } = const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({ await this.activitiesService.getActivitiesForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId userId
@ -490,7 +490,7 @@ export class PortfolioService {
); );
const { activities } = const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({ await this.activitiesService.getActivitiesForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId userId
@ -623,6 +623,28 @@ export class PortfolioService {
? 0 ? 0
: valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(),
assetClass: assetProfile.assetClass, assetClass: assetProfile.assetClass,
assetProfile: {
assetClass: assetProfile.assetClass,
assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries,
currency: assetProfile.currency,
dataSource: assetProfile.dataSource,
holdings: assetProfile.holdings.map(
({ allocationInPercentage, name }) => {
return {
allocationInPercentage,
name,
valueInBaseCurrency: valueInBaseCurrency
.mul(allocationInPercentage)
.toNumber()
};
}
),
name: assetProfile.name,
sectors: assetProfile.sectors,
symbol: assetProfile.symbol,
url: assetProfile.url
},
assetSubClass: assetProfile.assetSubClass, assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries, countries: assetProfile.countries,
dataSource: assetProfile.dataSource, dataSource: assetProfile.dataSource,
@ -758,7 +780,7 @@ export class PortfolioService {
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const { activities } = const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({ await this.activitiesService.getActivitiesForPortfolioCalculator({
userCurrency, userCurrency,
userId userId
}); });
@ -988,7 +1010,7 @@ export class PortfolioService {
userId, userId,
userCurrency userCurrency
}), }),
this.orderService.getOrdersForPortfolioCalculator({ this.activitiesService.getActivitiesForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId userId
@ -1007,7 +1029,8 @@ export class PortfolioService {
netPerformancePercentage: 0, netPerformancePercentage: 0,
netPerformancePercentageWithCurrencyEffect: 0, netPerformancePercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
totalInvestment: 0 totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0
} }
}; };
} }
@ -1024,7 +1047,7 @@ export class PortfolioService {
const { errors, hasErrors, historicalData } = const { errors, hasErrors, historicalData } =
await portfolioCalculator.getSnapshot(); await portfolioCalculator.getSnapshot();
const { endDate, startDate } = getIntervalFromDateRange(dateRange); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
const { chart } = await portfolioCalculator.getPerformance({ const { chart } = await portfolioCalculator.getPerformance({
end: endDate, end: endDate,
@ -1038,6 +1061,7 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
netWorth, netWorth,
totalInvestment, totalInvestment,
totalInvestmentValueWithCurrencyEffect,
valueWithCurrencyEffect valueWithCurrencyEffect
} = chart?.at(-1) ?? { } = chart?.at(-1) ?? {
netPerformance: 0, netPerformance: 0,
@ -1058,6 +1082,7 @@ export class PortfolioService {
netPerformance, netPerformance,
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
totalInvestment, totalInvestment,
totalInvestmentValueWithCurrencyEffect,
currentNetWorth: netWorth, currentNetWorth: netWorth,
currentValueInBaseCurrency: valueWithCurrencyEffect, currentValueInBaseCurrency: valueWithCurrencyEffect,
netPerformancePercentage: netPerformanceInPercentage, netPerformancePercentage: netPerformanceInPercentage,
@ -1306,11 +1331,11 @@ export class PortfolioService {
}), }),
rules: await this.rulesService.evaluate( rules: await this.rulesService.evaluate(
[ [
new FeeRatioInitialInvestment( new FeeRatioTotalInvestmentVolume(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService, this.i18nService,
userSettings.language, userSettings.language,
summary.committedFunds, summary.totalBuy + summary.totalSell,
summary.fees summary.fees
) )
], ],
@ -1346,7 +1371,12 @@ export class PortfolioService {
}) { }) {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
await this.orderService.assignTags({ dataSource, symbol, tags, userId }); await this.activitiesService.assignTags({
dataSource,
symbol,
tags,
userId
});
} }
private getAggregatedMarkets(holdings: Record<string, PortfolioPosition>): { private getAggregatedMarkets(holdings: Record<string, PortfolioPosition>): {
@ -1669,6 +1699,17 @@ export class PortfolioService {
allocationInPercentage: 0, allocationInPercentage: 0,
assetClass: AssetClass.LIQUIDITY, assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH, assetSubClass: AssetSubClass.CASH,
assetProfile: {
currency,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
dataSource: undefined,
holdings: [],
name: currency,
sectors: [],
symbol: currency
},
countries: [], countries: [],
dataSource: undefined, dataSource: undefined,
dateOfFirstActivity: undefined, dateOfFirstActivity: undefined,
@ -1838,7 +1879,7 @@ export class PortfolioService {
userId = await this.getUserId(impersonationId, userId); 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.orderService.getOrders({ const { activities } = await this.activitiesService.getActivities({
userCurrency, userCurrency,
userId, userId,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
@ -1860,8 +1901,11 @@ export class PortfolioService {
} }
} }
const { currentValueInBaseCurrency, totalInvestment } = const {
await portfolioCalculator.getSnapshot(); currentValueInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect
} = await portfolioCalculator.getSnapshot();
const { performance } = await this.getPerformance({ const { performance } = await this.getPerformance({
impersonationId, impersonationId,
@ -1908,8 +1952,6 @@ export class PortfolioService {
.plus(emergencyFundHoldingsValueInBaseCurrency) .plus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber(); .toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell);
const totalOfExcludedActivities = this.getSumOfActivityType({ const totalOfExcludedActivities = this.getSumOfActivityType({
userCurrency, userCurrency,
activities: excludedActivities, activities: excludedActivities,
@ -1973,7 +2015,6 @@ export class PortfolioService {
activityCount: activities.filter(({ type }) => { activityCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type); return ['BUY', 'SELL'].includes(type);
}).length, }).length,
committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: { emergencyFund: {
@ -2004,6 +2045,8 @@ export class PortfolioService {
interestInBaseCurrency: interest.toNumber(), interestInBaseCurrency: interest.toNumber(),
liabilitiesInBaseCurrency: liabilities.toNumber(), liabilitiesInBaseCurrency: liabilities.toNumber(),
totalInvestment: totalInvestment.toNumber(), totalInvestment: totalInvestment.toNumber(),
totalInvestmentValueWithCurrencyEffect:
totalInvestmentWithCurrencyEffect.toNumber(),
totalValueInBaseCurrency: netWorth totalValueInBaseCurrency: netWorth
}; };
} }

11
apps/api/src/app/redis-cache/redis-cache.service.ts

@ -6,7 +6,7 @@ import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import Keyv from 'keyv'; import Keyv from 'keyv';
import ms from 'ms'; import ms from 'ms';
import { createHash } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class RedisCacheService { export class RedisCacheService {
@ -75,13 +75,16 @@ export class RedisCacheService {
} }
public async isHealthy() { public async isHealthy() {
const testKey = '__health_check__'; const HEALTH_CHECK_TIMEOUT = ms('5 seconds');
const testKey = `__health_check__${randomUUID().replace(/-/g, '')}`;
const testValue = Date.now().toString(); const testValue = Date.now().toString();
try { try {
await Promise.race([ await Promise.race([
(async () => { (async () => {
await this.set(testKey, testValue, ms('1 second')); await this.set(testKey, testValue, HEALTH_CHECK_TIMEOUT);
const result = await this.get(testKey); const result = await this.get(testKey);
if (result !== testValue) { if (result !== testValue) {
@ -91,7 +94,7 @@ export class RedisCacheService {
new Promise((_, reject) => new Promise((_, reject) =>
setTimeout( setTimeout(
() => reject(new Error('Redis health check failed: timeout')), () => reject(new Error('Redis health check failed: timeout')),
ms('2 seconds') HEALTH_CHECK_TIMEOUT
) )
) )
]); ]);

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

@ -35,7 +35,7 @@ export class SubscriptionService {
this.stripe = new Stripe( this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'), this.configurationService.get('STRIPE_SECRET_KEY'),
{ {
apiVersion: '2026-01-28.clover' apiVersion: '2026-02-25.clover'
} }
); );
} }

4
apps/api/src/app/user/user.module.ts

@ -1,4 +1,4 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -18,6 +18,7 @@ import { UserService } from './user.service';
controllers: [UserController], controllers: [UserController],
exports: [UserService], exports: [UserService],
imports: [ imports: [
ActivitiesModule,
ConfigurationModule, ConfigurationModule,
I18nModule, I18nModule,
ImpersonationModule, ImpersonationModule,
@ -25,7 +26,6 @@ import { UserService } from './user.service';
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
OrderModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
RedactValuesInResponseModule, RedactValuesInResponseModule,

18
apps/api/src/app/user/user.service.ts

@ -1,4 +1,4 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
@ -12,7 +12,7 @@ import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rul
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 { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { 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';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific'; import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets'; import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
@ -55,10 +55,10 @@ import { createHmac } from 'node:crypto';
@Injectable() @Injectable()
export class UserService { export class UserService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
@ -376,7 +376,7 @@ export class UserService {
undefined, undefined,
undefined undefined
).getSettings(user.settings.settings), ).getSettings(user.settings.settings),
FeeRatioInitialInvestment: new FeeRatioInitialInvestment( FeeRatioTotalInvestmentVolume: new FeeRatioTotalInvestmentVolume(
undefined, undefined,
undefined, undefined,
undefined, undefined,
@ -530,9 +530,15 @@ export class UserService {
} }
} }
if (!environment.production && hasRole(user, Role.ADMIN)) { if (hasRole(user, Role.ADMIN)) {
if (this.configurationService.get('ENABLE_FEATURE_BULL_BOARD')) {
currentPermissions.push(permissions.accessAdminControlBullBoard);
}
if (!environment.production) {
currentPermissions.push(permissions.impersonateAllUsers); currentPermissions.push(permissions.impersonateAllUsers);
} }
}
user.accounts = user.accounts.sort((a, b) => { user.accounts = user.accounts.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
@ -643,7 +649,7 @@ export class UserService {
} catch {} } catch {}
try { try {
await this.orderService.deleteOrders({ await this.activitiesService.deleteActivities({
userId: where.id userId: where.id
}); });
} catch {} } catch {}

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

File diff suppressed because it is too large

1
apps/api/src/assets/cryptocurrencies/custom.json

@ -4,6 +4,7 @@
"LUNA1": "Terra", "LUNA1": "Terra",
"LUNA2": "Terra", "LUNA2": "Terra",
"SGB1": "Songbird", "SGB1": "Songbird",
"SKY33038": "Sky",
"SMURFCAT": "Real Smurf Cat", "SMURFCAT": "Real Smurf Cat",
"TON11419": "Toncoin", "TON11419": "Toncoin",
"UNI1": "Uniswap", "UNI1": "Uniswap",

12
apps/api/src/events/asset-profile-changed.event.ts

@ -8,4 +8,16 @@ export class AssetProfileChangedEvent {
public static getName(): string { public static getName(): string {
return 'assetProfile.changed'; return 'assetProfile.changed';
} }
public getCurrency() {
return this.data.currency;
}
public getDataSource() {
return this.data.dataSource;
}
public getSymbol() {
return this.data.symbol;
}
} }

65
apps/api/src/events/asset-profile-changed.listener.ts

@ -1,29 +1,74 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { DataSource } from '@prisma/client';
import ms from 'ms';
import { AssetProfileChangedEvent } from './asset-profile-changed.event'; import { AssetProfileChangedEvent } from './asset-profile-changed.event';
@Injectable() @Injectable()
export class AssetProfileChangedListener { export class AssetProfileChangedListener {
private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService
private readonly orderService: OrderService
) {} ) {}
@OnEvent(AssetProfileChangedEvent.getName()) @OnEvent(AssetProfileChangedEvent.getName())
public async handleAssetProfileChanged(event: AssetProfileChangedEvent) { public handleAssetProfileChanged(event: AssetProfileChangedEvent) {
const currency = event.getCurrency();
const dataSource = event.getDataSource();
const symbol = event.getSymbol();
const key = getAssetProfileIdentifier({
dataSource,
symbol
});
const existingTimer = this.debounceTimers.get(key);
if (existingTimer) {
clearTimeout(existingTimer);
}
this.debounceTimers.set(
key,
setTimeout(() => {
this.debounceTimers.delete(key);
void this.processAssetProfileChanged({
currency,
dataSource,
symbol
});
}, AssetProfileChangedListener.DEBOUNCE_DELAY)
);
}
private async processAssetProfileChanged({
currency,
dataSource,
symbol
}: {
currency: string;
dataSource: DataSource;
symbol: string;
}) {
Logger.log( Logger.log(
`Asset profile of ${event.data.symbol} (${event.data.dataSource}) has changed`, `Asset profile of ${symbol} (${dataSource}) has changed`,
'AssetProfileChangedListener' 'AssetProfileChangedListener'
); );
@ -31,16 +76,16 @@ export class AssetProfileChangedListener {
this.configurationService.get( this.configurationService.get(
'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES' 'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES'
) === false || ) === false ||
event.data.currency === DEFAULT_CURRENCY currency === DEFAULT_CURRENCY
) { ) {
return; return;
} }
const existingCurrencies = this.exchangeRateDataService.getCurrencies(); const existingCurrencies = this.exchangeRateDataService.getCurrencies();
if (!existingCurrencies.includes(event.data.currency)) { if (!existingCurrencies.includes(currency)) {
Logger.log( Logger.log(
`New currency ${event.data.currency} has been detected`, `New currency ${currency} has been detected`,
'AssetProfileChangedListener' 'AssetProfileChangedListener'
); );
@ -48,13 +93,13 @@ export class AssetProfileChangedListener {
} }
const { dateOfFirstActivity } = const { dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(event.data.currency); await this.activitiesService.getStatisticsByCurrency(currency);
if (dateOfFirstActivity) { if (dateOfFirstActivity) {
await this.dataGatheringService.gatherSymbol({ await this.dataGatheringService.gatherSymbol({
dataSource: this.dataProviderService.getDataSourceForExchangeRates(), dataSource: this.dataProviderService.getDataSourceForExchangeRates(),
date: dateOfFirstActivity, date: dateOfFirstActivity,
symbol: `${DEFAULT_CURRENCY}${event.data.currency}` symbol: `${DEFAULT_CURRENCY}${currency}`
}); });
} }
} }

4
apps/api/src/events/events.module.ts

@ -1,4 +1,4 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -12,11 +12,11 @@ import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({ @Module({
imports: [ imports: [
ActivitiesModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
OrderModule,
RedisCacheModule RedisCacheModule
], ],
providers: [AssetProfileChangedListener, PortfolioChangedListener] providers: [AssetProfileChangedListener, PortfolioChangedListener]

30
apps/api/src/events/portfolio-changed.listener.ts

@ -2,22 +2,44 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import ms from 'ms';
import { PortfolioChangedEvent } from './portfolio-changed.event'; import { PortfolioChangedEvent } from './portfolio-changed.event';
@Injectable() @Injectable()
export class PortfolioChangedListener { export class PortfolioChangedListener {
private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>();
public constructor(private readonly redisCacheService: RedisCacheService) {} public constructor(private readonly redisCacheService: RedisCacheService) {}
@OnEvent(PortfolioChangedEvent.getName()) @OnEvent(PortfolioChangedEvent.getName())
handlePortfolioChangedEvent(event: PortfolioChangedEvent) { handlePortfolioChangedEvent(event: PortfolioChangedEvent) {
const userId = event.getUserId();
const existingTimer = this.debounceTimers.get(userId);
if (existingTimer) {
clearTimeout(existingTimer);
}
this.debounceTimers.set(
userId,
setTimeout(() => {
this.debounceTimers.delete(userId);
void this.processPortfolioChanged({ userId });
}, PortfolioChangedListener.DEBOUNCE_DELAY)
);
}
private async processPortfolioChanged({ userId }: { userId: string }) {
Logger.log( Logger.log(
`Portfolio of user '${event.getUserId()}' has changed`, `Portfolio of user '${userId}' has changed`,
'PortfolioChangedListener' 'PortfolioChangedListener'
); );
this.redisCacheService.removePortfolioSnapshotsByUserId({ await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId });
userId: event.getUserId()
});
} }
} }

4
apps/api/src/helper/object.helper.spec.ts

@ -1540,7 +1540,6 @@ describe('redactAttributes', () => {
netPerformanceWithCurrencyEffect: null, netPerformanceWithCurrencyEffect: null,
totalBuy: null, totalBuy: null,
totalSell: null, totalSell: null,
committedFunds: null,
currentValueInBaseCurrency: null, currentValueInBaseCurrency: null,
dividendInBaseCurrency: null, dividendInBaseCurrency: null,
emergencyFund: null, emergencyFund: null,
@ -1554,6 +1553,7 @@ describe('redactAttributes', () => {
items: null, items: null,
liabilities: null, liabilities: null,
totalInvestment: null, totalInvestment: null,
totalInvestmentValueWithCurrencyEffect: null,
totalValueInBaseCurrency: null, totalValueInBaseCurrency: null,
currentNetWorth: null currentNetWorth: null
} }
@ -3016,7 +3016,6 @@ describe('redactAttributes', () => {
netPerformanceWithCurrencyEffect: null, netPerformanceWithCurrencyEffect: null,
totalBuy: null, totalBuy: null,
totalSell: null, totalSell: null,
committedFunds: null,
currentValueInBaseCurrency: null, currentValueInBaseCurrency: null,
dividendInBaseCurrency: null, dividendInBaseCurrency: null,
emergencyFund: null, emergencyFund: null,
@ -3030,6 +3029,7 @@ describe('redactAttributes', () => {
items: null, items: null,
liabilities: null, liabilities: null,
totalInvestment: null, totalInvestment: null,
totalInvestmentValueWithCurrencyEffect: null,
totalValueInBaseCurrency: null, totalValueInBaseCurrency: null,
currentNetWorth: null currentNetWorth: null
} }

3
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -62,10 +62,13 @@ export class TransformDataSourceInResponseInterceptor<
valueMap, valueMap,
object: data, object: data,
paths: [ paths: [
'activities[*].dataSource',
'activities[*].SymbolProfile.dataSource', 'activities[*].SymbolProfile.dataSource',
'benchmarks[*].dataSource', 'benchmarks[*].dataSource',
'errors[*].dataSource',
'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource', 'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource',
'fearAndGreedIndex.STOCKS.dataSource', 'fearAndGreedIndex.STOCKS.dataSource',
'holdings[*].assetProfile.dataSource',
'holdings[*].dataSource', 'holdings[*].dataSource',
'items[*].dataSource', 'items[*].dataSource',
'SymbolProfile.dataSource', 'SymbolProfile.dataSource',

6
apps/api/src/main.ts

@ -1,4 +1,5 @@
import { import {
BULL_BOARD_ROUTE,
DEFAULT_HOST, DEFAULT_HOST,
DEFAULT_PORT, DEFAULT_PORT,
STORYBOOK_PATH, STORYBOOK_PATH,
@ -14,6 +15,7 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express'; import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
@ -46,6 +48,7 @@ async function bootstrap() {
}); });
app.setGlobalPrefix('api', { app.setGlobalPrefix('api', {
exclude: [ exclude: [
`${BULL_BOARD_ROUTE.substring(1)}{/*wildcard}`,
'sitemap.xml', 'sitemap.xml',
...SUPPORTED_LANGUAGE_CODES.map((languageCode) => { ...SUPPORTED_LANGUAGE_CODES.map((languageCode) => {
// Exclude language-specific routes with an optional wildcard // Exclude language-specific routes with an optional wildcard
@ -53,6 +56,7 @@ async function bootstrap() {
}) })
] ]
}); });
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidNonWhitelisted: true, forbidNonWhitelisted: true,
@ -64,6 +68,8 @@ async function bootstrap() {
// Support 10mb csv/json files for importing activities // Support 10mb csv/json files for importing activities
app.useBodyParser('json', { limit: '10mb' }); app.useBodyParser('json', { limit: '10mb' });
app.use(cookieParser());
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') { if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
app.use((req: Request, res: Response, next: NextFunction) => { app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith(STORYBOOK_PATH)) { if (req.path.startsWith(STORYBOOK_PATH)) {

28
apps/api/src/middlewares/bull-board-auth.middleware.ts

@ -0,0 +1,28 @@
import { BULL_BOARD_COOKIE_NAME } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import passport from 'passport';
@Injectable()
export class BullBoardAuthMiddleware implements NestMiddleware {
public use(req: Request, res: Response, next: NextFunction) {
const token = req.cookies?.[BULL_BOARD_COOKIE_NAME];
if (token) {
req.headers.authorization = `Bearer ${token}`;
}
passport.authenticate('jwt', { session: false }, (error, user) => {
if (
error ||
!hasPermission(user?.permissions, permissions.accessAdminControl)
) {
next(new ForbiddenException());
} else {
next();
}
})(req, res, next);
}
}

4
apps/api/src/models/rule.ts

@ -57,7 +57,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
previousValue + previousValue +
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
new Big(currentValue.quantity) new Big(currentValue.quantity)
.mul(currentValue.marketPrice) .mul(currentValue.marketPrice ?? 0)
.toNumber(), .toNumber(),
currentValue.currency, currentValue.currency,
baseCurrency baseCurrency
@ -70,8 +70,6 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
public abstract evaluate(aRuleSettings: T): EvaluationResult; public abstract evaluate(aRuleSettings: T): EvaluationResult;
public abstract getCategoryName(): string;
public abstract getConfiguration(): Partial< public abstract getConfiguration(): Partial<
PortfolioReportRule['configuration'] PortfolioReportRule['configuration']
>; >;

7
apps/api/src/models/rules/account-cluster-risk/current-investment.ts

@ -98,13 +98,6 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.accountClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

7
apps/api/src/models/rules/account-cluster-risk/single-account.ts

@ -57,13 +57,6 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.accountClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return undefined; return undefined;
} }

7
apps/api/src/models/rules/asset-class-cluster-risk/equity.ts

@ -85,13 +85,6 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.assetClassClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

7
apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts

@ -85,13 +85,6 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.assetClassClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

7
apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts

@ -82,13 +82,6 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.currencyClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return undefined; return undefined;
} }

7
apps/api/src/models/rules/currency-cluster-risk/current-investment.ts

@ -75,13 +75,6 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.currencyClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

7
apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts

@ -76,13 +76,6 @@ export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.economicMarketClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

7
apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts

@ -76,13 +76,6 @@ export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.economicMarketClusterRisk.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

7
apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts

@ -40,13 +40,6 @@ export class EmergencyFundSetup extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.emergencyFund.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return undefined; return undefined;
} }

28
apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts → apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts

@ -3,35 +3,36 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces';
export class FeeRatioInitialInvestment extends Rule<Settings> { export class FeeRatioTotalInvestmentVolume extends Rule<Settings> {
private fees: number; private fees: number;
private totalInvestment: number; private totalInvestmentVolumeInBaseCurrency: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService, private i18nService: I18nService,
languageCode: string, languageCode: string,
totalInvestment: number, totalInvestmentVolumeInBaseCurrency: number,
fees: number fees: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
languageCode, languageCode,
key: FeeRatioInitialInvestment.name key: FeeRatioTotalInvestmentVolume.name
}); });
this.fees = fees; this.fees = fees;
this.totalInvestment = totalInvestment; this.totalInvestmentVolumeInBaseCurrency =
totalInvestmentVolumeInBaseCurrency;
} }
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {
const feeRatio = this.totalInvestment const feeRatio = this.totalInvestmentVolumeInBaseCurrency
? this.fees / this.totalInvestment ? this.fees / this.totalInvestmentVolumeInBaseCurrency
: 0; : 0;
if (feeRatio > ruleSettings.thresholdMax) { if (feeRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: this.i18nService.getTranslation({ evaluation: this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment.false', id: 'rule.feeRatioTotalInvestmentVolume.false',
languageCode: this.getLanguageCode(), languageCode: this.getLanguageCode(),
placeholders: { placeholders: {
feeRatio: (ruleSettings.thresholdMax * 100).toFixed(2), feeRatio: (ruleSettings.thresholdMax * 100).toFixed(2),
@ -44,7 +45,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
return { return {
evaluation: this.i18nService.getTranslation({ evaluation: this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment.true', id: 'rule.feeRatioTotalInvestmentVolume.true',
languageCode: this.getLanguageCode(), languageCode: this.getLanguageCode(),
placeholders: { placeholders: {
feeRatio: (feeRatio * 100).toPrecision(3), feeRatio: (feeRatio * 100).toPrecision(3),
@ -55,13 +56,6 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.fees.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {
@ -76,7 +70,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
public getName() { public getName() {
return this.i18nService.getTranslation({ return this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment', id: 'rule.feeRatioTotalInvestmentVolume',
languageCode: this.getLanguageCode() languageCode: this.getLanguageCode()
}); });
} }

7
apps/api/src/models/rules/liquidity/buying-power.ts

@ -63,13 +63,6 @@ export class BuyingPower extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.liquidity.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

4
apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts

@ -70,10 +70,6 @@ export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

4
apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts

@ -72,10 +72,6 @@ export class RegionalMarketClusterRiskEmergingMarkets extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

4
apps/api/src/models/rules/regional-market-cluster-risk/europe.ts

@ -70,10 +70,6 @@ export class RegionalMarketClusterRiskEurope extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

4
apps/api/src/models/rules/regional-market-cluster-risk/japan.ts

@ -70,10 +70,6 @@ export class RegionalMarketClusterRiskJapan extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

4
apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts

@ -70,10 +70,6 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule<Settings> {
}; };
} }
public getCategoryName() {
return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation
}
public getConfiguration() { public getConfiguration() {
return { return {
threshold: { threshold: {

2
apps/api/src/services/configuration/configuration.service.ts

@ -30,6 +30,7 @@ export class ConfigurationService {
API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }), API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }),
BULL_BOARD_IS_READ_ONLY: bool({ default: true }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),
CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }), CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
@ -43,6 +44,7 @@ export class ConfigurationService {
ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }), ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }),
ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }), ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }),
ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }), ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }),
ENABLE_FEATURE_BULL_BOARD: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),

7
apps/api/src/services/cryptocurrency/cryptocurrency.module.ts

@ -1,9 +1,12 @@
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CryptocurrencyService } from './cryptocurrency.service'; import { CryptocurrencyService } from './cryptocurrency.service';
@Module({ @Module({
providers: [CryptocurrencyService], exports: [CryptocurrencyService],
exports: [CryptocurrencyService] imports: [PropertyModule],
providers: [CryptocurrencyService]
}) })
export class CryptocurrencyModule {} export class CryptocurrencyModule {}

34
apps/api/src/services/cryptocurrency/cryptocurrency.service.ts

@ -1,31 +1,39 @@
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_CURRENCY,
PROPERTY_CUSTOM_CRYPTOCURRENCIES
} from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
const cryptocurrencies = require('../../assets/cryptocurrencies/cryptocurrencies.json'); const cryptocurrencies = require('../../assets/cryptocurrencies/cryptocurrencies.json');
const customCryptocurrencies = require('../../assets/cryptocurrencies/custom.json'); const customCryptocurrencies = require('../../assets/cryptocurrencies/custom.json');
@Injectable() @Injectable()
export class CryptocurrencyService { export class CryptocurrencyService implements OnModuleInit {
private combinedCryptocurrencies: string[]; private combinedCryptocurrencies: string[];
public isCryptocurrency(aSymbol = '') { public constructor(private readonly propertyService: PropertyService) {}
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
return ( public async onModuleInit() {
aSymbol.endsWith(DEFAULT_CURRENCY) && const customCryptocurrenciesFromDatabase =
this.getCryptocurrencies().includes(cryptocurrencySymbol) await this.propertyService.getByKey<Record<string, string>>(
PROPERTY_CUSTOM_CRYPTOCURRENCIES
); );
}
private getCryptocurrencies() {
if (!this.combinedCryptocurrencies) {
this.combinedCryptocurrencies = [ this.combinedCryptocurrencies = [
...Object.keys(cryptocurrencies), ...Object.keys(cryptocurrencies),
...Object.keys(customCryptocurrencies) ...Object.keys(customCryptocurrencies),
...Object.keys(customCryptocurrenciesFromDatabase ?? {})
]; ];
} }
return this.combinedCryptocurrencies; public isCryptocurrency(aSymbol = '') {
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
return (
aSymbol.endsWith(DEFAULT_CURRENCY) &&
this.combinedCryptocurrencies.includes(cryptocurrencySymbol)
);
} }
} }

10
apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts

@ -16,7 +16,7 @@ import {
LookupResponse LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import Alphavantage from 'alphavantage'; import Alphavantage from 'alphavantage';
import { format, isAfter, isBefore, parse } from 'date-fns'; import { format, isAfter, isBefore, parse } from 'date-fns';
@ -24,12 +24,16 @@ import { format, isAfter, isBefore, parse } from 'date-fns';
import { AlphaVantageHistoricalResponse } from './interfaces/interfaces'; import { AlphaVantageHistoricalResponse } from './interfaces/interfaces';
@Injectable() @Injectable()
export class AlphaVantageService implements DataProviderInterface { export class AlphaVantageService
implements DataProviderInterface, OnModuleInit
{
public alphaVantage; public alphaVantage;
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {}
public onModuleInit() {
this.alphaVantage = Alphavantage({ this.alphaVantage = Alphavantage({
key: this.configurationService.get('API_KEY_ALPHA_VANTAGE') key: this.configurationService.get('API_KEY_ALPHA_VANTAGE')
}); });

12
apps/api/src/services/data-provider/coingecko/coingecko.service.ts

@ -17,7 +17,7 @@ import {
LookupResponse LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -27,13 +27,15 @@ import {
import { format, fromUnixTime, getUnixTime } from 'date-fns'; import { format, fromUnixTime, getUnixTime } from 'date-fns';
@Injectable() @Injectable()
export class CoinGeckoService implements DataProviderInterface { export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
private readonly apiUrl: string; private apiUrl: string;
private readonly headers: HeadersInit = {}; private headers: HeadersInit = {};
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {}
public onModuleInit() {
const apiKeyDemo = this.configurationService.get('API_KEY_COINGECKO_DEMO'); const apiKeyDemo = this.configurationService.get('API_KEY_COINGECKO_DEMO');
const apiKeyPro = this.configurationService.get('API_KEY_COINGECKO_PRO'); const apiKeyPro = this.configurationService.get('API_KEY_COINGECKO_PRO');

2
apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts

@ -29,7 +29,7 @@ describe('YahooFinanceDataEnhancerService', () => {
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService; let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
beforeAll(async () => { beforeAll(async () => {
cryptocurrencyService = new CryptocurrencyService(); cryptocurrencyService = new CryptocurrencyService(null);
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService( yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
cryptocurrencyService cryptocurrencyService

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

@ -207,14 +207,16 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
if (['ETF', 'MUTUALFUND'].includes(assetSubClass)) { if (['ETF', 'MUTUALFUND'].includes(assetSubClass)) {
response.holdings = response.holdings =
assetProfile.topHoldings?.holdings?.map( assetProfile.topHoldings?.holdings
({ holdingName, holdingPercent }) => { ?.filter(({ holdingName }) => {
return !holdingName?.includes('ETF');
})
?.map(({ holdingName, holdingPercent }) => {
return { return {
name: this.formatName({ longName: holdingName }), name: this.formatName({ longName: holdingName }),
weight: holdingPercent weight: holdingPercent
}; };
} }) ?? [];
) ?? [];
response.sectors = ( response.sectors = (
assetProfile.topHoldings?.sectorWeightings ?? [] assetProfile.topHoldings?.sectorWeightings ?? []

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

@ -1,3 +1,4 @@
import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -10,8 +11,10 @@ import {
PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_DATA_SOURCE_MAPPING PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos';
import { import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier,
getCurrencyFromSymbol, getCurrencyFromSymbol,
getStartOfUtcDate, getStartOfUtcDate,
isCurrency, isCurrency,
@ -27,7 +30,7 @@ import {
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { eachDayOfInterval, format, isValid } from 'date-fns'; import { eachDayOfInterval, format, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash'; import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
@ -185,6 +188,125 @@ export class DataProviderService implements OnModuleInit {
return dataSources.sort(); return dataSources.sort();
} }
public async validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
user
}: {
activitiesDto: Pick<
Partial<CreateOrderDto>,
'currency' | 'dataSource' | 'symbol' | 'type'
>[];
assetProfilesWithMarketDataDto?: ImportDataDto['assetProfiles'];
maxActivitiesToImport: number;
user: UserWithSettings;
}) {
if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
}
const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {};
const dataSources = await this.getDataSources();
for (const [
index,
{ currency, dataSource, symbol, type }
] of activitiesDto.entries()) {
const activityPath =
maxActivitiesToImport === 1 ? 'activity' : `activities.${index}`;
if (!dataSources.includes(dataSource)) {
throw new Error(
`${activityPath}.dataSource ("${dataSource}") is not valid`
);
}
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user.subscription.type === 'Basic'
) {
const dataProvider = this.getDataProvider(DataSource[dataSource]);
if (dataProvider.getDataProviderInfo().isPremium) {
throw new Error(
`${activityPath}.dataSource ("${dataSource}") is not valid`
);
}
}
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
if (!assetProfiles[assetProfileIdentifier]) {
if (
(dataSource === DataSource.MANUAL && type === 'BUY') ||
['FEE', 'INTEREST', 'LIABILITY'].includes(type)
) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(assetProfile) => {
return (
assetProfile.dataSource === dataSource &&
assetProfile.symbol === symbol
);
}
);
assetProfiles[assetProfileIdentifier] = {
currency,
dataSource,
symbol,
name: assetProfileInImport?.name ?? symbol
};
continue;
}
let assetProfile: Partial<SymbolProfile> = { currency };
try {
assetProfile = (
await this.getAssetProfiles([
{
dataSource,
symbol
}
])
)?.[symbol];
} catch {}
if (!assetProfile?.name) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
if (assetProfileInImport) {
Object.assign(assetProfile, assetProfileInImport);
}
}
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
assetProfiles[assetProfileIdentifier] = assetProfile;
}
}
return assetProfiles;
}
public async getDividends({ public async getDividends({
dataSource, dataSource,
from, from,
@ -225,37 +347,36 @@ export class DataProviderService implements OnModuleInit {
const granularityQuery = const granularityQuery =
aGranularity === 'month' aGranularity === 'month'
? `AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')` ? Prisma.sql`AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')`
: ''; : Prisma.empty;
const rangeQuery = const rangeQuery =
from && to from && to
? `AND date >= '${format(from, DATE_FORMAT)}' AND date <= '${format( ? Prisma.sql`AND date >= ${format(from, DATE_FORMAT)}::timestamp AND date <= ${format(
to, to,
DATE_FORMAT DATE_FORMAT
)}'` )}::timestamp`
: ''; : Prisma.empty;
const dataSources = aItems.map(({ dataSource }) => { const dataSources = aItems.map(({ dataSource }) => {
return dataSource; return dataSource;
}); });
const symbols = aItems.map(({ symbol }) => { const symbols = aItems.map(({ symbol }) => {
return symbol; return symbol;
}); });
try { try {
const queryRaw = ` const marketDataByGranularity: MarketData[] = await this.prismaService
.$queryRaw`
SELECT * SELECT *
FROM "MarketData" FROM "MarketData"
WHERE "dataSource" IN ('${dataSources.join(`','`)}') WHERE "dataSource"::text IN (${Prisma.join(dataSources)})
AND "symbol" IN ('${symbols.join( AND "symbol" IN (${Prisma.join(symbols)})
`','` ${granularityQuery}
)}') ${granularityQuery} ${rangeQuery} ${rangeQuery}
ORDER BY date;`; ORDER BY date;`;
const marketDataByGranularity: MarketData[] =
await this.prismaService.$queryRawUnsafe(queryRaw);
response = marketDataByGranularity.reduce((r, marketData) => { response = marketDataByGranularity.reduce((r, marketData) => {
const { date, marketPrice, symbol } = marketData; const { date, marketPrice, symbol } = marketData;

10
apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts

@ -22,7 +22,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types'; import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -33,14 +33,18 @@ import { addDays, format, isSameDay, isToday } from 'date-fns';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@Injectable() @Injectable()
export class EodHistoricalDataService implements DataProviderInterface { export class EodHistoricalDataService
implements DataProviderInterface, OnModuleInit
{
private apiKey: string; private apiKey: string;
private readonly URL = 'https://eodhistoricaldata.com/api'; private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) { ) {}
public onModuleInit() {
this.apiKey = this.configurationService.get('API_KEY_EOD_HISTORICAL_DATA'); this.apiKey = this.configurationService.get('API_KEY_EOD_HISTORICAL_DATA');
} }

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

@ -23,7 +23,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types'; import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -44,7 +44,9 @@ import {
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
@Injectable() @Injectable()
export class FinancialModelingPrepService implements DataProviderInterface { export class FinancialModelingPrepService
implements DataProviderInterface, OnModuleInit
{
private static countriesMapping = { private static countriesMapping = {
'Korea (the Republic of)': 'South Korea', 'Korea (the Republic of)': 'South Korea',
'Russian Federation': 'Russia', 'Russian Federation': 'Russia',
@ -57,7 +59,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService, private readonly cryptocurrencyService: CryptocurrencyService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) { ) {}
public onModuleInit() {
this.apiKey = this.configurationService.get( this.apiKey = this.configurationService.get(
'API_KEY_FINANCIAL_MODELING_PREP' 'API_KEY_FINANCIAL_MODELING_PREP'
); );

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

@ -105,7 +105,10 @@ export class ManualService implements DataProviderInterface {
return {}; return {};
} }
const value = await this.scrape(symbolProfile.scraperConfiguration); const value = await this.scrape({
symbol,
scraperConfiguration: symbolProfile.scraperConfiguration
});
return { return {
[symbol]: { [symbol]: {
@ -170,7 +173,10 @@ export class ManualService implements DataProviderInterface {
symbolProfilesWithScraperConfigurationAndInstantMode.map( symbolProfilesWithScraperConfigurationAndInstantMode.map(
async ({ scraperConfiguration, symbol }) => { async ({ scraperConfiguration, symbol }) => {
try { try {
const marketPrice = await this.scrape(scraperConfiguration); const marketPrice = await this.scrape({
scraperConfiguration,
symbol
});
return { marketPrice, symbol }; return { marketPrice, symbol };
} catch (error) { } catch (error) {
Logger.error( Logger.error(
@ -267,13 +273,23 @@ export class ManualService implements DataProviderInterface {
}; };
} }
public async test(scraperConfiguration: ScraperConfiguration) { public async test({
return this.scrape(scraperConfiguration); scraperConfiguration,
symbol
}: {
scraperConfiguration: ScraperConfiguration;
symbol: string;
}) {
return this.scrape({ scraperConfiguration, symbol });
} }
private async scrape( private async scrape({
scraperConfiguration: ScraperConfiguration scraperConfiguration,
): Promise<number> { symbol
}: {
scraperConfiguration: ScraperConfiguration;
symbol: string;
}): Promise<number> {
let locale = scraperConfiguration.locale; let locale = scraperConfiguration.locale;
const response = await fetch(scraperConfiguration.url, { const response = await fetch(scraperConfiguration.url, {
@ -283,6 +299,12 @@ export class ManualService implements DataProviderInterface {
) )
}); });
if (!response.ok) {
throw new Error(
`Failed to scrape the market price for ${symbol} (${this.getName()}): ${response.status} ${response.statusText} at ${scraperConfiguration.url}`
);
}
let value: string; let value: string;
if (response.headers.get('content-type')?.includes('application/json')) { if (response.headers.get('content-type')?.includes('application/json')) {

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

@ -1,16 +1,16 @@
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { readFileSync, readdirSync } from 'node:fs'; import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
@Injectable() @Injectable()
export class I18nService { export class I18nService implements OnModuleInit {
private localesPath = join(__dirname, 'assets', 'locales'); private localesPath = join(__dirname, 'assets', 'locales');
private translations: { [locale: string]: cheerio.CheerioAPI } = {}; private translations: { [locale: string]: cheerio.CheerioAPI } = {};
public constructor() { public onModuleInit() {
this.loadFiles(); this.loadFiles();
} }

2
apps/api/src/services/interfaces/environment.interface.ts

@ -10,6 +10,7 @@ export interface Environment extends CleanedEnvAccessors {
API_KEY_FINANCIAL_MODELING_PREP: string; API_KEY_FINANCIAL_MODELING_PREP: string;
API_KEY_OPEN_FIGI: string; API_KEY_OPEN_FIGI: string;
API_KEY_RAPID_API: string; API_KEY_RAPID_API: string;
BULL_BOARD_IS_READ_ONLY: boolean;
CACHE_QUOTES_TTL: number; CACHE_QUOTES_TTL: number;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string; DATA_SOURCE_EXCHANGE_RATES: string;
@ -19,6 +20,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_AUTH_GOOGLE: boolean; ENABLE_FEATURE_AUTH_GOOGLE: boolean;
ENABLE_FEATURE_AUTH_OIDC: boolean; ENABLE_FEATURE_AUTH_OIDC: boolean;
ENABLE_FEATURE_AUTH_TOKEN: boolean; ENABLE_FEATURE_AUTH_TOKEN: boolean;
ENABLE_FEATURE_BULL_BOARD: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean;

14
apps/api/src/services/queues/data-gathering/data-gathering.module.ts

@ -9,6 +9,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config'; import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullAdapter } from '@bull-board/api/bullAdapter';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import ms from 'ms'; import ms from 'ms';
@ -17,6 +19,18 @@ import { DataGatheringProcessor } from './data-gathering.processor';
@Module({ @Module({
imports: [ imports: [
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forFeature({
adapter: BullAdapter,
name: DATA_GATHERING_QUEUE,
options: {
displayName: 'Data Gathering',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
}
})
]
: []),
BullModule.registerQueue({ BullModule.registerQueue({
limiter: { limiter: {
duration: ms('4 seconds'), duration: ms('4 seconds'),

18
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts

@ -1,5 +1,5 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
@ -13,6 +13,8 @@ import {
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { BullAdapter } from '@bull-board/api/bullAdapter';
import { BullBoardModule } from '@bull-board/nestjs';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -22,6 +24,19 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
exports: [BullModule, PortfolioSnapshotService], exports: [BullModule, PortfolioSnapshotService],
imports: [ imports: [
AccountBalanceModule, AccountBalanceModule,
ActivitiesModule,
...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true'
? [
BullBoardModule.forFeature({
adapter: BullAdapter,
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
options: {
displayName: 'Portfolio Snapshot Computation',
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
}
})
]
: []),
BullModule.registerQueue({ BullModule.registerQueue({
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
settings: { settings: {
@ -36,7 +51,6 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule,
RedisCacheModule RedisCacheModule
], ],
providers: [ providers: [

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

@ -1,5 +1,5 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -23,9 +23,9 @@ import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue
export class PortfolioSnapshotProcessor { export class PortfolioSnapshotProcessor {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly activitiesService: ActivitiesService,
private readonly calculatorFactory: PortfolioCalculatorFactory, private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly orderService: OrderService,
private readonly redisCacheService: RedisCacheService private readonly redisCacheService: RedisCacheService
) {} ) {}
@ -47,7 +47,7 @@ export class PortfolioSnapshotProcessor {
); );
const { activities } = const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({ await this.activitiesService.getActivitiesForPortfolioCalculator({
filters: job.data.filters, filters: job.data.filters,
userCurrency: job.data.userCurrency, userCurrency: job.data.userCurrency,
userId: job.data.userId, userId: job.data.userId,

8
apps/api/src/services/twitter-bot/twitter-bot.service.ts

@ -10,19 +10,21 @@ import {
resolveMarketCondition resolveMarketCondition
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { isWeekend } from 'date-fns'; import { isWeekend } from 'date-fns';
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2'; import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@Injectable() @Injectable()
export class TwitterBotService { export class TwitterBotService implements OnModuleInit {
private twitterClient: TwitterApiReadWrite; private twitterClient: TwitterApiReadWrite;
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly symbolService: SymbolService private readonly symbolService: SymbolService
) { ) {}
public onModuleInit() {
this.twitterClient = new TwitterApi({ this.twitterClient = new TwitterApi({
accessSecret: this.configurationService.get( accessSecret: this.configurationService.get(
'TWITTER_ACCESS_TOKEN_SECRET' 'TWITTER_ACCESS_TOKEN_SECRET'

4
apps/client/proxy.conf.json

@ -1,4 +1,8 @@
{ {
"/admin/queues": {
"target": "http://0.0.0.0:3333",
"secure": false
},
"/api": { "/api": {
"target": "http://0.0.0.0:3333", "target": "http://0.0.0.0:3333",
"secure": false "secure": false

136
apps/client/src/app/app.component.ts

@ -10,12 +10,13 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef,
DOCUMENT, DOCUMENT,
HostBinding, HostBinding,
Inject, inject,
OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { import {
@ -30,15 +31,13 @@ import { DataSource } from '@prisma/client';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { openOutline } from 'ionicons/icons'; import { openOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { filter } from 'rxjs/operators';
import { filter, takeUntil } from 'rxjs/operators';
import { GfFooterComponent } from './components/footer/footer.component'; import { GfFooterComponent } from './components/footer/footer.component';
import { GfHeaderComponent } from './components/header/header.component'; import { GfHeaderComponent } from './components/header/header.component';
import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component'; import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component';
import { HoldingDetailDialogParams } from './components/holding-detail-dialog/interfaces/interfaces'; import { GfAppQueryParams } from './interfaces/interfaces';
import { ImpersonationStorageService } from './services/impersonation-storage.service'; import { ImpersonationStorageService } from './services/impersonation-storage.service';
import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service'; import { UserService } from './services/user/user.service';
@Component({ @Component({
@ -48,11 +47,7 @@ import { UserService } from './services/user/user.service';
styleUrls: ['./app.component.scss'], styleUrls: ['./app.component.scss'],
templateUrl: './app.component.html' templateUrl: './app.component.html'
}) })
export class GfAppComponent implements OnDestroy, OnInit { export class GfAppComponent implements OnInit {
@HostBinding('class.has-info-message') get getHasMessage() {
return this.hasInfoMessage;
}
public canCreateAccount: boolean; public canCreateAccount: boolean;
public currentRoute: string; public currentRoute: string;
public currentSubRoute: string; public currentSubRoute: string;
@ -67,52 +62,54 @@ export class GfAppComponent implements OnDestroy, OnInit {
public pageTitle: string; public pageTitle: string;
public routerLinkRegister = publicRoutes.register.routerLink; public routerLinkRegister = publicRoutes.register.routerLink;
public showFooter = false; public showFooter = false;
public user: User; public user: User | undefined;
private unsubscribeSubject = new Subject<void>(); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
public constructor( private readonly destroyRef = inject(DestroyRef);
private changeDetectorRef: ChangeDetectorRef, private readonly deviceService = inject(DeviceDetectorService);
private dataService: DataService, private readonly dialog = inject(MatDialog);
private deviceService: DeviceDetectorService, private readonly document = inject(DOCUMENT);
private dialog: MatDialog, private readonly impersonationStorageService = inject(
@Inject(DOCUMENT) private document: Document, ImpersonationStorageService
private impersonationStorageService: ImpersonationStorageService, );
private notificationService: NotificationService, private readonly notificationService = inject(NotificationService);
private route: ActivatedRoute, private readonly route = inject(ActivatedRoute);
private router: Router, private readonly router = inject(Router);
private title: Title, private readonly title = inject(Title);
private tokenStorageService: TokenStorageService, private readonly userService = inject(UserService);
private userService: UserService
) { public constructor() {
this.initializeTheme(); this.initializeTheme();
this.user = undefined; this.user = undefined;
this.route.queryParams this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe(
if ( ({ dataSource, holdingDetailDialog, symbol }: GfAppQueryParams) => {
params['dataSource'] && if (dataSource && holdingDetailDialog && symbol) {
params['holdingDetailDialog'] &&
params['symbol']
) {
this.openHoldingDetailDialog({ this.openHoldingDetailDialog({
dataSource: params['dataSource'], dataSource,
symbol: params['symbol'] symbol
}); });
} }
}); }
);
addIcons({ openOutline }); addIcons({ openOutline });
} }
@HostBinding('class.has-info-message') get getHasMessage() {
return this.hasInfoMessage;
}
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.hasImpersonationId = !!impersonationId;
}); });
@ -131,7 +128,7 @@ export class GfAppComponent implements OnDestroy, OnInit {
!this.currentSubRoute) || !this.currentSubRoute) ||
(this.currentRoute === internalRoutes.home.path && (this.currentRoute === internalRoutes.home.path &&
this.currentSubRoute === this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path) || internalRoutes.home.subRoutes?.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path && (this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute)) && !this.currentSubRoute)) &&
this.user?.settings?.viewMode !== 'ZEN' this.user?.settings?.viewMode !== 'ZEN'
@ -144,18 +141,18 @@ export class GfAppComponent implements OnDestroy, OnInit {
if ( if (
(this.currentRoute === internalRoutes.home.path && (this.currentRoute === internalRoutes.home.path &&
this.currentSubRoute === this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path) || internalRoutes.home.subRoutes?.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path && (this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute) || !this.currentSubRoute) ||
(this.currentRoute === internalRoutes.portfolio.path && (this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute === this.currentSubRoute ===
internalRoutes.portfolio.subRoutes.activities.path) || internalRoutes.portfolio.subRoutes?.activities.path) ||
(this.currentRoute === internalRoutes.portfolio.path && (this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute === this.currentSubRoute ===
internalRoutes.portfolio.subRoutes.allocations.path) || internalRoutes.portfolio.subRoutes?.allocations.path) ||
(this.currentRoute === internalRoutes.zen.path && (this.currentRoute === internalRoutes.zen.path &&
this.currentSubRoute === this.currentSubRoute ===
internalRoutes.home.subRoutes.holdings.path) internalRoutes.home.subRoutes?.holdings.path)
) { ) {
this.hasPermissionToChangeFilters = true; this.hasPermissionToChangeFilters = true;
} else { } else {
@ -201,7 +198,7 @@ export class GfAppComponent implements OnDestroy, OnInit {
}); });
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
this.user = state.user; this.user = state.user;
@ -226,31 +223,31 @@ export class GfAppComponent implements OnDestroy, OnInit {
} }
public onClickSystemMessage() { public onClickSystemMessage() {
if (this.user.systemMessage.routerLink) { const systemMessage = this.user?.systemMessage;
this.router.navigate(this.user.systemMessage.routerLink);
if (!systemMessage) {
return;
}
if (systemMessage.routerLink) {
void this.router.navigate(systemMessage.routerLink);
} else { } else {
this.notificationService.alert({ this.notificationService.alert({
title: this.user.systemMessage.message title: systemMessage.message
}); });
} }
} }
public onCreateAccount() { public onCreateAccount() {
this.tokenStorageService.signOut(); this.userService.signOut();
} }
public onSignOut() { public onSignOut() {
this.tokenStorageService.signOut(); this.userService.signOut();
this.userService.remove();
document.location.href = `/${document.documentElement.lang}`; document.location.href = `/${document.documentElement.lang}`;
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private initializeTheme(userPreferredColorScheme?: ColorScheme) { private initializeTheme(userPreferredColorScheme?: ColorScheme) {
const isDarkTheme = userPreferredColorScheme const isDarkTheme = userPreferredColorScheme
? userPreferredColorScheme === 'DARK' ? userPreferredColorScheme === 'DARK'
@ -274,14 +271,11 @@ export class GfAppComponent implements OnDestroy, OnInit {
}) { }) {
this.userService this.userService
.get() .get()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => { .subscribe((user) => {
this.user = user; this.user = user;
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open(GfHoldingDetailDialogComponent, {
GfHoldingDetailDialogComponent,
HoldingDetailDialogParams
>(GfHoldingDetailDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
dataSource, dataSource,
@ -296,15 +290,21 @@ export class GfAppComponent implements OnDestroy, OnInit {
), ),
hasPermissionToCreateActivity: hasPermissionToCreateActivity:
!this.hasImpersonationId && !this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createOrder) && hasPermission(
this.user?.permissions,
permissions.createActivity
) &&
!this.user?.settings?.isRestrictedView, !this.user?.settings?.isRestrictedView,
hasPermissionToReportDataGlitch: hasPermission( hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions, this.user?.permissions,
permissions.reportDataGlitch permissions.reportDataGlitch
), ),
hasPermissionToUpdateOrder: hasPermissionToUpdateActivity:
!this.hasImpersonationId && !this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.updateOrder) && hasPermission(
this.user?.permissions,
permissions.updateActivity
) &&
!this.user?.settings?.isRestrictedView, !this.user?.settings?.isRestrictedView,
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
@ -314,9 +314,9 @@ export class GfAppComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.router.navigate([], { void this.router.navigate([], {
queryParams: { queryParams: {
dataSource: null, dataSource: null,
holdingDetailDialog: null, holdingDetailDialog: null,
@ -342,6 +342,6 @@ export class GfAppComponent implements OnDestroy, OnInit {
this.document this.document
.querySelector('meta[name="theme-color"]') .querySelector('meta[name="theme-color"]')
.setAttribute('content', themeColor); ?.setAttribute('content', themeColor);
} }
} }

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

@ -39,7 +39,7 @@
getPublicUrl(element.id) getPublicUrl(element.id)
}}</a> }}</a>
</div> </div>
@if (user?.settings?.isExperimentalFeatures) { @if (user()?.settings?.isExperimentalFeatures) {
<div> <div>
<code <code
>GET {{ baseUrl }}/api/v1/public/{{ >GET {{ baseUrl }}/api/v1/public/{{
@ -69,7 +69,7 @@
class="no-max-width" class="no-max-width"
xPosition="before" xPosition="before"
> >
@if (user?.settings?.isExperimentalFeatures) { @if (user()?.settings?.isExperimentalFeatures) {
<button mat-menu-item (click)="onUpdateAccess(element.id)"> <button mat-menu-item (click)="onUpdateAccess(element.id)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" /> <ion-icon class="mr-2" name="create-outline" />
@ -86,7 +86,8 @@
</button> </button>
} }
@if ( @if (
user?.settings?.isExperimentalFeatures || element.type === 'PUBLIC' user()?.settings?.isExperimentalFeatures ||
element.type === 'PUBLIC'
) { ) {
<hr class="my-0" /> <hr class="my-0" />
} }
@ -100,7 +101,7 @@
</td> </td>
</ng-container> </ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns()" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns()" mat-row></tr>
</table> </table>
</div> </div>

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

@ -7,11 +7,12 @@ import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
EventEmitter, effect,
Input, inject,
OnChanges, input,
Output output
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
@ -46,23 +47,32 @@ import ms from 'ms';
templateUrl: './access-table.component.html', templateUrl: './access-table.component.html',
styleUrls: ['./access-table.component.scss'] styleUrls: ['./access-table.component.scss']
}) })
export class GfAccessTableComponent implements OnChanges { export class GfAccessTableComponent {
@Input() accesses: Access[]; public readonly accesses = input.required<Access[]>();
@Input() showActions: boolean; public readonly showActions = input<boolean>(false);
@Input() user: User; public readonly user = input.required<User>();
@Output() accessDeleted = new EventEmitter<string>(); public readonly accessDeleted = output<string>();
@Output() accessToUpdate = new EventEmitter<string>(); public readonly accessToUpdate = output<string>();
public baseUrl = window.location.origin; protected readonly baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>; protected readonly dataSource = new MatTableDataSource<Access>();
public displayedColumns = [];
protected readonly displayedColumns = computed(() => {
public constructor( const columns = ['alias', 'grantee', 'type', 'details'];
private clipboard: Clipboard,
private notificationService: NotificationService, if (this.showActions()) {
private snackBar: MatSnackBar columns.push('actions');
) { }
return columns;
});
private readonly clipboard = inject(Clipboard);
private readonly notificationService = inject(NotificationService);
private readonly snackBar = inject(MatSnackBar);
public constructor() {
addIcons({ addIcons({
copyOutline, copyOutline,
createOutline, createOutline,
@ -72,27 +82,19 @@ export class GfAccessTableComponent implements OnChanges {
lockOpenOutline, lockOpenOutline,
removeCircleOutline removeCircleOutline
}); });
}
public ngOnChanges() { effect(() => {
this.displayedColumns = ['alias', 'grantee', 'type', 'details']; this.dataSource.data = this.accesses() ?? [];
});
if (this.showActions) {
this.displayedColumns.push('actions');
}
if (this.accesses) {
this.dataSource = new MatTableDataSource(this.accesses);
}
} }
public getPublicUrl(aId: string): string { protected getPublicUrl(aId: string) {
const languageCode = this.user.settings.language; const languageCode = this.user().settings.language;
return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`; return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`;
} }
public onCopyUrlToClipboard(aId: string): void { protected onCopyUrlToClipboard(aId: string) {
this.clipboard.copy(this.getPublicUrl(aId)); this.clipboard.copy(this.getPublicUrl(aId));
this.snackBar.open( this.snackBar.open(
@ -104,7 +106,7 @@ export class GfAccessTableComponent implements OnChanges {
); );
} }
public onDeleteAccess(aId: string) { protected onDeleteAccess(aId: string) {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
this.accessDeleted.emit(aId); this.accessDeleted.emit(aId);
@ -114,7 +116,7 @@ export class GfAccessTableComponent implements OnChanges {
}); });
} }
public onUpdateAccess(aId: string) { protected onUpdateAccess(aId: string) {
this.accessToUpdate.emit(aId); this.accessToUpdate.emit(aId);
} }
} }

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

@ -26,11 +26,12 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef,
Inject, Inject,
OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
@ -49,8 +50,7 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { forkJoin, Subject } from 'rxjs'; import { forkJoin } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { AccountDetailDialogParams } from './interfaces/interfaces'; import { AccountDetailDialogParams } from './interfaces/interfaces';
@ -77,7 +77,7 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./account-detail-dialog.component.scss'], styleUrls: ['./account-detail-dialog.component.scss'],
templateUrl: 'account-detail-dialog.html' templateUrl: 'account-detail-dialog.html'
}) })
export class GfAccountDetailDialogComponent implements OnDestroy, OnInit { export class GfAccountDetailDialogComponent implements OnInit {
public accountBalances: AccountBalancesResponse['balances']; public accountBalances: AccountBalancesResponse['balances'];
public activities: OrderWithAccount[]; public activities: OrderWithAccount[];
public activitiesCount: number; public activitiesCount: number;
@ -104,18 +104,17 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
public user: User; public user: User;
public valueInBaseCurrency: number; public valueInBaseCurrency: number;
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService, private dataService: DataService,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<GfAccountDetailDialogComponent>, public dialogRef: MatDialogRef<GfAccountDetailDialogComponent>,
private router: Router, private router: Router,
private userService: UserService private userService: UserService
) { ) {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -154,7 +153,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
public onAddAccountBalance(accountBalance: CreateAccountBalanceDto) { public onAddAccountBalance(accountBalance: CreateAccountBalanceDto) {
this.dataService this.dataService
.postAccountBalance(accountBalance) .postAccountBalance(accountBalance)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.initialize(); this.initialize();
}); });
@ -163,7 +162,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
public onDeleteAccountBalance(aId: string) { public onDeleteAccountBalance(aId: string) {
this.dataService this.dataService
.deleteAccountBalance(aId) .deleteAccountBalance(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.initialize(); this.initialize();
}); });
@ -176,7 +175,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchExport({ activityIds }) .fetchExport({ activityIds })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => { .subscribe((data) => {
downloadAsFile({ downloadAsFile({
content: data, content: data,
@ -212,7 +211,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
private fetchAccount() { private fetchAccount() {
this.dataService this.dataService
.fetchAccount(this.data.accountId) .fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe( .subscribe(
({ ({
activitiesCount, activitiesCount,
@ -287,7 +286,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
sortColumn: this.sortColumn, sortColumn: this.sortColumn,
sortDirection: this.sortDirection sortDirection: this.sortDirection
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ activities, count }) => { .subscribe(({ activities, count }) => {
this.dataSource = new MatTableDataSource(activities); this.dataSource = new MatTableDataSource(activities);
this.totalItems = count; this.totalItems = count;
@ -304,7 +303,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
forkJoin({ forkJoin({
accountBalances: this.dataService accountBalances: this.dataService
.fetchAccountBalances(this.data.accountId) .fetchAccountBalances(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject)), .pipe(takeUntilDestroyed(this.destroyRef)),
portfolioPerformance: this.dataService portfolioPerformance: this.dataService
.fetchPortfolioPerformance({ .fetchPortfolioPerformance({
filters: [ filters: [
@ -317,7 +316,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
withExcludedAccounts: true, withExcludedAccounts: true,
withItems: true withItems: true
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
}).subscribe({ }).subscribe({
error: () => { error: () => {
this.isLoadingChart = false; this.isLoadingChart = false;
@ -360,7 +359,7 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
} }
] ]
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
this.holdings = holdings; this.holdings = holdings;
@ -374,9 +373,4 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
this.fetchChart(); this.fetchChart();
this.fetchPortfolioHoldings(); this.fetchPortfolioHoldings();
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

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

@ -24,7 +24,7 @@
class="h-100" class="h-100"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[isInPercent]=" [isInPercentage]="
data.hasImpersonationId || user.settings.isRestrictedView data.hasImpersonationId || user.settings.isRestrictedView
" "
[isLoading]="isLoadingChart" [isLoading]="isLoadingChart"
@ -102,8 +102,6 @@
<div class="d-none d-sm-block ml-2" i18n>Holdings</div> <div class="d-none d-sm-block ml-2" i18n>Holdings</div>
</ng-template> </ng-template>
<gf-holdings-table <gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[holdings]="holdings" [holdings]="holdings"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"

55
apps/client/src/app/components/admin-jobs/admin-jobs.component.ts

@ -1,5 +1,8 @@
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
BULL_BOARD_COOKIE_NAME,
BULL_BOARD_ROUTE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
@ -7,6 +10,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getDateWithTimeFormatString } from '@ghostfolio/common/helper'; import { getDateWithTimeFormatString } from '@ghostfolio/common/helper';
import { AdminJobs, User } from '@ghostfolio/common/interfaces'; import { AdminJobs, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService } from '@ghostfolio/ui/services'; import { AdminService } from '@ghostfolio/ui/services';
@ -15,10 +19,11 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
OnDestroy, DestroyRef,
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
FormBuilder, FormBuilder,
FormGroup, FormGroup,
@ -41,6 +46,7 @@ import {
chevronUpCircleOutline, chevronUpCircleOutline,
ellipsisHorizontal, ellipsisHorizontal,
ellipsisVertical, ellipsisVertical,
openOutline,
pauseOutline, pauseOutline,
playOutline, playOutline,
removeCircleOutline, removeCircleOutline,
@ -48,8 +54,6 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
import { get } from 'lodash'; import { get } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -69,7 +73,7 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./admin-jobs.scss'], styleUrls: ['./admin-jobs.scss'],
templateUrl: './admin-jobs.html' templateUrl: './admin-jobs.html'
}) })
export class GfAdminJobsComponent implements OnDestroy, OnInit { export class GfAdminJobsComponent implements OnInit {
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public DATA_GATHERING_QUEUE_PRIORITY_LOW = DATA_GATHERING_QUEUE_PRIORITY_LOW; public DATA_GATHERING_QUEUE_PRIORITY_LOW = DATA_GATHERING_QUEUE_PRIORITY_LOW;
@ -81,6 +85,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
public dataSource = new MatTableDataSource<AdminJobs['jobs'][0]>(); public dataSource = new MatTableDataSource<AdminJobs['jobs'][0]>();
public defaultDateTimeFormat: string; public defaultDateTimeFormat: string;
public filterForm: FormGroup; public filterForm: FormGroup;
public displayedColumns = [ public displayedColumns = [
'index', 'index',
'type', 'type',
@ -93,21 +98,24 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
'status', 'status',
'actions' 'actions'
]; ];
public hasPermissionToAccessBullBoard = false;
public isLoading = false; public isLoading = false;
public statusFilterOptions = QUEUE_JOB_STATUS_LIST; public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
public user: User;
private unsubscribeSubject = new Subject<void>(); private user: User;
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private destroyRef: DestroyRef,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService, private notificationService: NotificationService,
private tokenStorageService: TokenStorageService,
private userService: UserService private userService: UserService
) { ) {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
@ -115,6 +123,11 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
this.defaultDateTimeFormat = getDateWithTimeFormatString( this.defaultDateTimeFormat = getDateWithTimeFormatString(
this.user.settings.locale this.user.settings.locale
); );
this.hasPermissionToAccessBullBoard = hasPermission(
this.user.permissions,
permissions.accessAdminControlBullBoard
);
} }
}); });
@ -126,6 +139,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
chevronUpCircleOutline, chevronUpCircleOutline,
ellipsisHorizontal, ellipsisHorizontal,
ellipsisVertical, ellipsisVertical,
openOutline,
pauseOutline, pauseOutline,
playOutline, playOutline,
removeCircleOutline, removeCircleOutline,
@ -139,7 +153,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
}); });
this.filterForm.valueChanges this.filterForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
const currentFilter = this.filterForm.get('status').value; const currentFilter = this.filterForm.get('status').value;
this.fetchJobs(currentFilter ? [currentFilter] : undefined); this.fetchJobs(currentFilter ? [currentFilter] : undefined);
@ -151,7 +165,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
public onDeleteJob(aId: string) { public onDeleteJob(aId: string) {
this.adminService this.adminService
.deleteJob(aId) .deleteJob(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.fetchJobs(); this.fetchJobs();
}); });
@ -162,7 +176,7 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
this.adminService this.adminService
.deleteJobs({ status: currentFilter ? [currentFilter] : undefined }) .deleteJobs({ status: currentFilter ? [currentFilter] : undefined })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.fetchJobs(currentFilter ? [currentFilter] : undefined); this.fetchJobs(currentFilter ? [currentFilter] : undefined);
}); });
@ -171,12 +185,24 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
public onExecuteJob(aId: string) { public onExecuteJob(aId: string) {
this.adminService this.adminService
.executeJob(aId) .executeJob(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.fetchJobs(); this.fetchJobs();
}); });
} }
public onOpenBullBoard() {
const token = this.tokenStorageService.getToken();
document.cookie = [
`${BULL_BOARD_COOKIE_NAME}=${encodeURIComponent(token)}`,
'path=/',
'SameSite=Strict'
].join('; ');
window.open(BULL_BOARD_ROUTE, '_blank');
}
public onViewData(aData: AdminJobs['jobs'][0]['data']) { public onViewData(aData: AdminJobs['jobs'][0]['data']) {
this.notificationService.alert({ this.notificationService.alert({
title: JSON.stringify(aData, null, ' ') title: JSON.stringify(aData, null, ' ')
@ -189,17 +215,12 @@ export class GfAdminJobsComponent implements OnDestroy, OnInit {
}); });
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchJobs(aStatus?: JobStatus[]) { private fetchJobs(aStatus?: JobStatus[]) {
this.isLoading = true; this.isLoading = true;
this.adminService this.adminService
.fetchJobs({ status: aStatus }) .fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ jobs }) => { .subscribe(({ jobs }) => {
this.dataSource = new MatTableDataSource(jobs); this.dataSource = new MatTableDataSource(jobs);
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort;

9
apps/client/src/app/components/admin-jobs/admin-jobs.html

@ -1,6 +1,15 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
@if (hasPermissionToAccessBullBoard) {
<div class="d-flex justify-content-end mb-3">
<button mat-stroked-button (click)="onOpenBullBoard()">
<span><ng-container i18n>Overview</ng-container></span>
<ion-icon class="ml-2" name="open-outline" />
</button>
</div>
}
<form class="align-items-center d-flex" [formGroup]="filterForm"> <form class="align-items-center d-flex" [formGroup]="filterForm">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select formControlName="status"> <mat-select formControlName="status">

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

Loading…
Cancel
Save