Browse Source

Merge branch 'main' into usiel/quick-autocomplete

pull/3507/head
Usiel Riedl 2 weeks ago
parent
commit
859314d4b6
  1. 1
      .env.dev
  2. 2
      .github/workflows/build-code.yml
  3. 2
      .github/workflows/docker-image.yml
  4. 2
      .gitignore
  5. 2
      .nvmrc
  6. 340
      CHANGELOG.md
  7. 10
      DEVELOPMENT.md
  8. 9
      Dockerfile
  9. 25
      README.md
  10. 12
      apps/api/src/app/access/access.controller.ts
  11. 2
      apps/api/src/app/access/access.service.ts
  12. 12
      apps/api/src/app/account-balance/account-balance.service.ts
  13. 14
      apps/api/src/app/account/account.controller.ts
  14. 7
      apps/api/src/app/account/account.service.ts
  15. 87
      apps/api/src/app/admin/admin.controller.ts
  16. 2
      apps/api/src/app/admin/admin.module.ts
  17. 36
      apps/api/src/app/admin/admin.service.ts
  18. 25
      apps/api/src/app/app.module.ts
  19. 2
      apps/api/src/app/auth/api-key.strategy.ts
  20. 16
      apps/api/src/app/auth/jwt.strategy.ts
  21. 2
      apps/api/src/app/auth/web-auth.service.ts
  22. 10
      apps/api/src/app/endpoints/ai/ai.controller.ts
  23. 4
      apps/api/src/app/endpoints/ai/ai.module.ts
  24. 33
      apps/api/src/app/endpoints/ai/ai.service.ts
  25. 2
      apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
  26. 173
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  27. 4
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  28. 60
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  29. 8
      apps/api/src/app/endpoints/market-data/market-data.module.ts
  30. 2
      apps/api/src/app/endpoints/public/public.module.ts
  31. 51
      apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
  32. 5
      apps/api/src/app/endpoints/sitemap/sitemap.module.ts
  33. 116
      apps/api/src/app/endpoints/sitemap/sitemap.service.ts
  34. 15
      apps/api/src/app/export/export.controller.ts
  35. 9
      apps/api/src/app/export/export.module.ts
  36. 12
      apps/api/src/app/export/export.service.ts
  37. 40
      apps/api/src/app/health/health.controller.ts
  38. 2
      apps/api/src/app/import/create-account-with-balances.dto.ts
  39. 5
      apps/api/src/app/import/import.controller.ts
  40. 78
      apps/api/src/app/import/import.service.ts
  41. 33
      apps/api/src/app/info/info.service.ts
  42. 2
      apps/api/src/app/order/interfaces/activities.interface.ts
  43. 27
      apps/api/src/app/order/order.controller.ts
  44. 52
      apps/api/src/app/order/order.service.ts
  45. 30
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  46. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  47. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  48. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  49. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  50. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  51. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  52. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  53. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  54. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  55. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts
  56. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  57. 5
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  58. 33
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  59. 22
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  60. 1
      apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts
  61. 43
      apps/api/src/app/portfolio/portfolio.controller.ts
  62. 2
      apps/api/src/app/portfolio/portfolio.module.ts
  63. 391
      apps/api/src/app/portfolio/portfolio.service.ts
  64. 64
      apps/api/src/app/redis-cache/redis-cache.service.ts
  65. 80
      apps/api/src/app/sitemap/sitemap.controller.ts
  66. 3
      apps/api/src/app/subscription/subscription.controller.ts
  67. 10
      apps/api/src/app/subscription/subscription.service.ts
  68. 6
      apps/api/src/app/user/update-own-access-token.dto.ts
  69. 84
      apps/api/src/app/user/user.controller.ts
  70. 2
      apps/api/src/app/user/user.module.ts
  71. 89
      apps/api/src/app/user/user.service.ts
  72. 1101
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  73. 591
      apps/api/src/assets/sitemap.xml
  74. 4
      apps/api/src/helper/object.helper.spec.ts
  75. 11
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts
  76. 16
      apps/api/src/main.ts
  77. 158
      apps/api/src/middlewares/html-template.middleware.ts
  78. 15
      apps/api/src/models/rule.ts
  79. 68
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  80. 28
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  81. 51
      apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
  82. 51
      apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts
  83. 38
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  84. 42
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  85. 7
      apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts
  86. 7
      apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts
  87. 24
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  88. 36
      apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
  89. 7
      apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts
  90. 7
      apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts
  91. 7
      apps/api/src/models/rules/regional-market-cluster-risk/europe.ts
  92. 7
      apps/api/src/models/rules/regional-market-cluster-risk/japan.ts
  93. 7
      apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts
  94. 12
      apps/api/src/services/benchmark/benchmark.service.ts
  95. 23
      apps/api/src/services/cron/cron.module.ts
  96. 15
      apps/api/src/services/cron/cron.service.ts
  97. 4
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  98. 39
      apps/api/src/services/data-provider/data-provider.service.ts
  99. 15
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  100. 4
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts

1
.env.dev

@ -22,4 +22,3 @@ JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
# For more info, see: https://nx.dev/concepts/inferred-tasks # For more info, see: https://nx.dev/concepts/inferred-tasks
NX_ADD_PLUGINS=false NX_ADD_PLUGINS=false
NX_NATIVE_COMMAND_RUNNER=false

2
.github/workflows/build-code.yml

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

2
.github/workflows/docker-image.yml

@ -19,7 +19,7 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: ghostfolio/ghostfolio images: ${{ vars.DOCKER_REPOSITORY || 'ghostfolio/ghostfolio' }}
tags: | tags: |
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=semver,pattern={{version}} type=semver,pattern={{version}}

2
.gitignore

@ -25,8 +25,10 @@ npm-debug.log
# misc # misc
/.angular/cache /.angular/cache
.cursor/rules/nx-rules.mdc
.env .env
.env.prod .env.prod
.github/instructions/nx.instructions.md
.nx/cache .nx/cache
.nx/workspace-data .nx/workspace-data
/.sass-cache /.sass-cache

2
.nvmrc

@ -1 +1 @@
v20 v22

340
CHANGELOG.md

@ -5,29 +5,347 @@ 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.178.0 - 2025-07-05
### Changed
- Increased the width of the markets overview
- Increased the width of the watchlist
- Deprecated the `ITEM` activity type in favor of `BUY`
- Renamed `Access` to `accessesGet` in the `User` database schema
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for Italian (`it`)
- Upgraded `prisma` from version `6.10.1` to `6.11.1`
### Fixed
- Set the name column to sticky in the table of the benchmark component
## 2.177.0 - 2025-07-03
### Added
- Extended the _Fear & Greed Index_ (market mood) in the markets overview by cryptocurrencies (experimental)
### Changed
- Refactored the about pages to standalone
- Made the `getByKey()` function generic in the property service
- Renamed `AuthDevice` to `authDevices` in the `User` database schema
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for German (`de`)
- Improved the language localization for Portuguese (`pt`)
- Upgraded `@internationalized/number` from version `3.6.0` to `3.6.3`
- Upgraded `ngx-skeleton-loader` from version `11.0.0` to `11.2.1`
- Upgraded `yahoo-finance2` from version `3.3.5` to `3.4.1`
## 2.176.0 - 2025-06-30
### Added
- Added support for generating a new _Security Token_ via the user’s account access panel
### Changed
- Moved the main content of the holding detail dialog to a new overview tab
- Introduced fuzzy search for the holdings of the assistant
- Introduced fuzzy search for the quick links of the assistant
- Improved the search results of the assistant to only display categories with content
- Enhanced the sitemap to dynamically compose public routes
- Renamed `Account` to `account` in the `Order` database schema
- Improved the language localization for German (`de`)
- Upgraded `prettier` from version `3.5.3` to `3.6.2`
## 2.175.0 - 2025-06-28
### Added
- Set up the language localization for the static portfolio analysis rule: _Asset Class Cluster Risks_ (Equity)
- Set up the language localization for the static portfolio analysis rule: _Asset Class Cluster Risks_ (Fixed Income)
- Set up the language localization for the static portfolio analysis rule: _Currency Cluster Risks_ (Investment)
- Set up the language localization for the static portfolio analysis rule: _Currency Cluster Risks_ (Investment: Base Currency)
### Changed
- Extended the selector handling of the scraper configuration for more use cases
- Extended the _AI_ service by an access to _OpenRouter_ (experimental)
- Changed `node main` to `exec node main` in the `entrypoint.sh` file to improve the container signal handling
- Renamed `Account` to `account` in the `AccountBalance` database schema
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for Español (`es`)
- Improved the language localization for German (`de`)
- Improved the language localization for Turkish (`tr`)
### Fixed
- Fixed an issue with the locale in the scraper configuration
## 2.174.0 - 2025-06-24
### Added
- Set up the language localization for the static portfolio analysis rule: _Account Cluster Risks_ (Current Investment)
- Extended the data providers management of the admin control panel by the online status
### Changed
- Migrated the `@ghostfolio/ui/value` component to control flow
- Renamed `Platform` to `platform` in the `Account` database schema
- Refactored the health check endpoint for data enhancers
- Refactored the health check endpoint for data providers
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`)
- Refreshed the cryptocurrencies list
## 2.173.0 - 2025-06-21
### Added
- Set up `open-color` for CSS variable usage
### Changed
- Simplified the data providers management of the admin control panel
- Migrated the `@ghostfolio/ui/assistant` component to control flow
- Migrated the `@ghostfolio/ui/value` component to control flow
- Renamed `GranteeUser` to `granteeUser` in the `Access` database schema
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`)
- Upgraded `class-validator` from version `0.14.1` to `0.14.2`
- Upgraded `prisma` from version `6.9.0` to `6.10.1`
### Fixed
- Fixed an issue in the `HtmlTemplateMiddleware` related to incorrect variable resolution
- Eliminated the _Unsupported route path_ warning of the `LegacyRouteConverter` on startup
## 2.172.0 - 2025-06-19
### Added
- Set up the language localization for the static portfolio analysis rule: _Account Cluster Risks_ (Single Account)
- Included the admin control panel in the quick links of the assistant
### Changed
- Adapted the options of the date range selector in the assistant dynamically based on the user’s first activity
- Switched the data provider service to `OnModuleInit`, ensuring (currency) quotes are fetched only once
- Migrated the `@ghostfolio/ui/assistant` component to control flow
- Migrated the `@ghostfolio/ui/value` component to control flow
- Improved the language localization for Chinese (`zh`)
- Improved the language localization for Español (`es`)
- Improved the language localization for German (`de`)
- Improved the language localization for Portuguese (`pt`)
## 2.171.0 - 2025-06-15
### Added
- Added the current holdings as default options of the symbol search in the create or update activity dialog
### Changed
- Improved the style of the assistant
- Reused the value component in the data providers management of the admin control panel
- Set the market state of exchange rate symbols to `open` in the _Financial Modeling Prep_ service
- Restructured the content of the pricing page
- Migrated the `@ghostfolio/ui/assistant` component to control flow
- Migrated the `@ghostfolio/ui/value` component to control flow
- Migrated the `HtmlTemplateMiddleware` to use `@Injectable()`
- Renamed `User` to `user` in the database schema
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for Español (`es`)
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`)
- Improved the language localization for Italian (`it`)
- Improved the language localization for Polish (`pl`)
- Improved the language localization for Portuguese (`pt`)
- Improved the language localization for Turkish (`tr`)
- Upgraded the _Stripe_ dependencies
### Fixed
- Fixed a date offset issue with account balances
- Fixed missing `/.well-known/assetlinks.json` for TWA
## 2.170.0 - 2025-06-11
### Added
- Included quick links in the search results of the assistant
- Added a skeleton loader to the changelog page
- Extended the content of the _Self-Hosting_ section by information about additional data providers on the Frequently Asked Questions (FAQ) page
### Changed
- Renamed `ApiKey` to `apiKeys` in the `User` database schema
- Improved the language localization for French (`fr`)
- Improved the language localization for Portuguese (`pt`)
- Upgraded `@keyv/redis` from version `4.3.4` to `4.4.0`
- Upgraded `prisma` from version `6.8.2` to `6.9.0`
- Upgraded `zone.js` from version `0.15.0` to `0.15.1`
### Fixed
- Restricted the date range change permission in the _Zen Mode_
## 2.169.0 - 2025-06-08
### Changed
- Renamed the asset profile icon component to entity logo component and moved to `@ghostfolio/ui`
- Renamed `Account` to `accounts` in the `User` database schema
- Improved the cache verification in the health check endpoint (experimental)
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for French (`fr`)
- Improved the language localization for Polish (`pl`)
### Fixed
- Handled an exception in the get keys function of the _Redis_ cache service
- Fixed missing `/.well-known/assetlinks.json` for TWA
## 2.168.0 - 2025-06-07
### Added
- Added a background gradient to the sidebar navigation
### Changed
- Migrated the `i18n` service to use `@Injectable()`
- Improved the language localization for German (`de`)
- Upgraded `nestjs` from version `11.1.0` to `11.1.3`
## 2.167.0 - 2025-06-07
### Added
- Added support for column sorting to the markets overview
- Added support for column sorting to the watchlist
- Set up the language localization for the static portfolio analysis rule: _Emergency Fund_ (Setup)
- Set up the language localization for the static portfolio analysis rule: _Fees_ (Fee Ratio)
### Changed
- Extended the symbol search component by default options
- Renamed `Tag` to `tags` in the `User` database schema
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
- Improved the language localization for Turkish (`tr`)
- Upgraded `ng-extract-i18n-merge` from version `2.15.0` to `2.15.1`
- Upgraded `Nx` from version `20.8.1` to `21.1.2`
### Fixed
- Fixed an issue where the import button was not correctly enabled in the import activities dialog
- Fixed an issue with empty account balances in the import activities dialog
- Fixed an issue in the annualized performance calculation
## 2.166.0 - 2025-06-05
### Added
- Added support to create custom tags in the create or update activity dialog (experimental)
### Changed
- Improved the style of the card components
- Improved the style of the system message
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
- Improved the language localization for Turkish (`tr`)
- Improved the language localization for Ukrainian (`uk`)
- Upgraded the _Stripe_ dependencies
- Upgraded `ngx-stripe` from version `19.0.0` to `19.7.0`
### Fixed
- Respected the filter by holding when deleting activities on the portfolio activities page
- Respected the filter by holding when exporting activities on the portfolio activities page
- Fixed an exception with currencies in the historical market data editor of the admin control panel
## 2.165.0 - 2025-05-31
### Added
- Extended the content of the _General_ section by the performance calculation method on the Frequently Asked Questions (FAQ) page
### Changed
- Improved the _Live Demo_ setup by syncing activities based on tags
- Renamed `orders` to `activities` in the `Tag` database schema
- Modularized the cron service
- Refreshed the cryptocurrencies list
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for Polish (`pl`)
- Improved the language localization for Spanish (`es`)
- Upgraded `big.js` from version `6.2.2` to `7.0.1`
- Upgraded `ng-extract-i18n-merge` from version `2.14.3` to `2.15.0`
### Fixed
- Changed the investment value to take the currency effects into account in the holding detail dialog
## 2.164.0 - 2025-05-28
### Changed
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for French (`fr`)
- Improved the language localization for Polish (`pl`)
- Improved the language localization for Spanish (`es`)
- Upgraded `Node.js` from version `20` to `22` (`Dockerfile`)
- Upgraded `yahoo-finance2` from version `3.3.4` to `3.3.5`
## 2.163.0 - 2025-05-26
### Changed
- Improved the language localization for Italian (`it`)
- Improved the language localization for Turkish (`tr`)
- Upgraded `yahoo-finance2` from version `3.3.3` to `3.3.4`
## 2.162.1 - 2025-05-24
### Added ### Added
- Added a hint about delayed market data to the markets overview
- Added the asset profile count per data provider to the endpoint `GET api/v1/admin` - Added the asset profile count per data provider to the endpoint `GET api/v1/admin`
### Changed ### Changed
- Increased the robustness of the search in the _Yahoo Finance_ service by catching schema validation errors
- Improved the symbol lookup results by removing the currency from the name of cryptocurrencies (experimental)
- Harmonized the data providers management style of the admin control panel - Harmonized the data providers management style of the admin control panel
- Extended the data providers management of the admin control panel by the asset profile count
- Restricted the permissions of the demo user - Restricted the permissions of the demo user
- Renamed `Order` to `activities` in the `User` database schema - Renamed `Order` to `activities` in the `User` database schema
- Removed the deprecated endpoint `GET api/v1/admin/market-data/:dataSource/:symbol`
- Removed the deprecated endpoint `POST api/v1/admin/market-data/:dataSource/:symbol`
- Removed the deprecated endpoint `PUT api/v1/admin/market-data/:dataSource/:symbol/:dateString`
- Improved the language localization for Catalan (`ca`) - Improved the language localization for Catalan (`ca`)
- Improved the language localization for Chinese (`zh`) - Improved the language localization for Chinese (`zh`)
- Improved the language localization for Dutch (`nl`) - Improved the language localization for Dutch (`nl`)
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
- Improved the language localization for Italian (`it`) - Improved the language localization for Italian (`it`)
- Improved the language localization for Polish (`pl`)
- Improved the language localization for Portuguese (`pt`)
- Improved the language localization for Spanish (`es`)
- Upgraded `countup.js` from version `2.8.0` to `2.8.2` - Upgraded `countup.js` from version `2.8.0` to `2.8.2`
- Upgraded `nestjs` from version `10.4.15` to `11.0.12` - Upgraded `nestjs` from version `10.4.15` to `11.0.12`
- Upgraded `yahoo-finance2` from version `2.11.3` to `3.3.1` - Upgraded `prisma` from version `6.7.0` to `6.8.2`
- Upgraded `twitter-api-v2` from version `1.14.2` to `1.23.0`
- Upgraded `yahoo-finance2` from version `2.11.3` to `3.3.3`
### Fixed ### Fixed
- Displayed the button to fetch the current market price only if the activity is not in a custom currency
- Fixed an issue in the watchlist endpoint (`POST`) related to the `HasPermissionGuard` - Fixed an issue in the watchlist endpoint (`POST`) related to the `HasPermissionGuard`
- Improved the text alignment of the allocations by ETF holding on the allocations page (experimental)
## 2.161.0 - 2025-05-06 ## 2.161.0 - 2025-05-06
@ -73,7 +391,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the language localization for Français (`fr`) - Improved the language localization for French (`fr`)
- Upgraded `bootstrap` from version `4.6.0` to `4.6.2` - Upgraded `bootstrap` from version `4.6.0` to `4.6.2`
### Fixed ### Fixed
@ -114,7 +432,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the error message of the currency code validation - Improved the error message of the currency code validation
- Tightened the currency code validation by requiring uppercase letters - Tightened the currency code validation by requiring uppercase letters
- Respected the watcher count for the delete asset profiles checkbox in the historical market data table of the admin control panel - Respected the watcher count for the delete asset profiles checkbox in the historical market data table of the admin control panel
- Improved the language localization for Français (`fr`) - Improved the language localization for French (`fr`)
- Upgraded `ngx-skeleton-loader` from version `10.0.0` to `11.0.0` - Upgraded `ngx-skeleton-loader` from version `10.0.0` to `11.0.0`
- Upgraded `Nx` from version `20.8.0` to `20.8.1` - Upgraded `Nx` from version `20.8.0` to `20.8.1`
@ -214,7 +532,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the check for duplicates in the preview step of the activities import (allow different comments) - Improved the check for duplicates in the preview step of the activities import (allow different comments)
- Improved the language localization for Français (`fr`) - Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`) - Improved the language localization for Polish (`pl`)
- Upgraded `ng-extract-i18n-merge` from version `2.14.1` to `2.14.3` - Upgraded `ng-extract-i18n-merge` from version `2.14.1` to `2.14.3`
@ -3669,7 +3987,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the language localization for Français (`fr`) - Added the language localization for French (`fr`)
- Extended the landing page by a global heat map of subscribers - Extended the landing page by a global heat map of subscribers
- Added support for the thousand separator in the global heat map component - Added support for the thousand separator in the global heat map component
@ -3698,7 +4016,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support for the dividend timeline grouped by year - Added support for the dividend timeline grouped by year
- Added support for the investment timeline grouped by year - Added support for the investment timeline grouped by year
- Set up the language localization for Français (`fr`) - Set up the language localization for French (`fr`)
### Changed ### Changed
@ -3807,7 +4125,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the value redaction interceptor (including `comment`) - Improved the value redaction interceptor (including `comment`)
- Improved the language localization for Español (`es`) - Improved the language localization for Spanish (`es`)
- Upgraded `cheerio` from version `1.0.0-rc.6` to `1.0.0-rc.12` - Upgraded `cheerio` from version `1.0.0-rc.6` to `1.0.0-rc.12`
- Upgraded `prisma` from version `4.6.1` to `4.7.1` - Upgraded `prisma` from version `4.6.1` to `4.7.1`
@ -4036,7 +4354,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the usage of the value component in the admin control panel - Improved the usage of the value component in the admin control panel
- Improved the language localization for Español (`es`) - Improved the language localization for Spanish (`es`)
### Fixed ### Fixed
@ -4058,7 +4376,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Set up the language localization for Español (`es`) - Set up the language localization for Spanish (`es`)
- Added support for sectors in mutual funds - Added support for sectors in mutual funds
## 1.198.0 - 25.09.2022 ## 1.198.0 - 25.09.2022
@ -5841,7 +6159,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed the navigation to always show the portfolio page - Changed the navigation to always show the portfolio page
- Migrated the data type of currencies from `enum` to `string` in the database - Migrated the data type of currencies from `enum` to `string` in the database
- Supported unlimited currencies (instead of `CHF`, `EUR`, `GBP` and `USD`) - Supported unlimited currencies (instead of `CHF`, `EUR`, `GBP` and `USD`)
- Respected the accounts' currencies in the exchange rate service - Respected the accounts currencies in the exchange rate service
### Fixed ### Fixed

10
DEVELOPMENT.md

@ -5,7 +5,7 @@
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 20+) - [Node.js](https://nodejs.org/en/download) (version 22+)
- Create a local copy of this Git repository (clone) - Create a local copy of this Git repository (clone)
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`) - Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
@ -30,7 +30,13 @@ Run `npm run start:server`
### Start Client ### Start Client
Run `npm run start:client` and open https://localhost:4200/en in your browser #### English (Default)
Run `npm run start:client` and open https://localhost:4200/en in your browser.
#### Other Languages
To start the client in a different language, such as German (`de`), adapt the `start:client` script in the `package.json` file by changing `--configuration=development-en` to `--configuration=development-de`. Then, run `npm run start:client` and open https://localhost:4200/de in your browser.
### Start _Storybook_ ### Start _Storybook_

9
Dockerfile

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:20-slim AS builder FROM --platform=$BUILDPLATFORM node:22-slim AS builder
# Build application and add additional files # Build application and add additional files
WORKDIR /ghostfolio WORKDIR /ghostfolio
@ -33,24 +33,25 @@ COPY ./nx.json nx.json
COPY ./replace.build.mjs replace.build.mjs COPY ./replace.build.mjs replace.build.mjs
COPY ./tsconfig.base.json tsconfig.base.json COPY ./tsconfig.base.json tsconfig.base.json
ENV NX_DAEMON=false
RUN npm run build:production RUN npm run build:production
# Prepare the dist image with additional node_modules # Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original # package.json was generated by the build process, however the original
# package-lock.json needs to be used to ensure the same versions # package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
RUN npm install RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma COPY prisma /ghostfolio/dist/apps/api/prisma
# Overwrite the generated package.json with the original one to ensure having # Overwrite the generated package.json with the original one to ensure having
# all the scripts # all the scripts
COPY package.json /ghostfolio/dist/apps/api COPY package.json /ghostfolio/dist/apps/api
RUN npm run database:generate-typings RUN npm run database:generate-typings
# Image to run, copy everything needed from builder # Image to run, copy everything needed from builder
FROM node:20-slim FROM node:22-slim
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio" LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
ENV NODE_ENV=production ENV NODE_ENV=production

25
README.md

@ -138,7 +138,6 @@ docker compose -f docker/docker-compose.build.yml up -d
#### Upgrade Version #### Upgrade Version
1. Update the _Ghostfolio_ Docker image 1. Update the _Ghostfolio_ Docker image
- Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml` - Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
- Run the following command if `ghostfolio:latest` is set: - Run the following command if `ghostfolio:latest` is set:
```bash ```bash
@ -222,18 +221,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
} }
``` ```
| Field | Type | Description | | Field | Type | Description |
| ------------ | ------------------- | ----------------------------------------------------------------------------- | | ------------ | ------------------- | ------------------------------------------------------------------- |
| `accountId` | `string` (optional) | Id of the account | | `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity | | `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. | | `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` | | `dataSource` | `string` | `COINGECKO` \| `MANUAL` \| `YAHOO` |
| `date` | `string` | Date in the format `ISO-8601` | | `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity | | `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity of the activity | | `quantity` | `number` | Quantity of the activity |
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) | | `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` | | `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `LIABILITY` \| `SELL` |
| `unitPrice` | `number` | Price per unit of the activity | | `unitPrice` | `number` | Price per unit of the activity |
#### Response #### Response

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

@ -37,20 +37,20 @@ export class AccessController {
public async getAllAccesses(): Promise<Access[]> { public async getAllAccesses(): Promise<Access[]> {
const accessesWithGranteeUser = await this.accessService.accesses({ const accessesWithGranteeUser = await this.accessService.accesses({
include: { include: {
GranteeUser: true granteeUser: true
}, },
orderBy: { granteeUserId: 'asc' }, orderBy: { granteeUserId: 'asc' },
where: { userId: this.request.user.id } where: { userId: this.request.user.id }
}); });
return accessesWithGranteeUser.map( return accessesWithGranteeUser.map(
({ alias, GranteeUser, id, permissions }) => { ({ alias, granteeUser, id, permissions }) => {
if (GranteeUser) { if (granteeUser) {
return { return {
alias, alias,
id, id,
permissions, permissions,
grantee: GranteeUser?.id, grantee: granteeUser?.id,
type: 'PRIVATE' type: 'PRIVATE'
}; };
} }
@ -85,11 +85,11 @@ export class AccessController {
try { try {
return this.accessService.createAccess({ return this.accessService.createAccess({
alias: data.alias || undefined, alias: data.alias || undefined,
GranteeUser: data.granteeUserId granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: undefined, : undefined,
permissions: data.permissions, permissions: data.permissions,
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}); });
} catch { } catch {
throw new HttpException( throw new HttpException(

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

@ -13,7 +13,7 @@ export class AccessService {
): Promise<AccessWithGranteeUser | null> { ): Promise<AccessWithGranteeUser | null> {
return this.prismaService.access.findFirst({ return this.prismaService.access.findFirst({
include: { include: {
GranteeUser: true granteeUser: true
}, },
where: accessWhereInput where: accessWhereInput
}); });

12
apps/api/src/app/account-balance/account-balance.service.ts

@ -30,7 +30,7 @@ export class AccountBalanceService {
): Promise<AccountBalance | null> { ): Promise<AccountBalance | null> {
return this.prismaService.accountBalance.findFirst({ return this.prismaService.accountBalance.findFirst({
include: { include: {
Account: true account: true
}, },
where: accountBalanceWhereInput where: accountBalanceWhereInput
}); });
@ -46,7 +46,7 @@ export class AccountBalanceService {
}): Promise<AccountBalance> { }): Promise<AccountBalance> {
const accountBalance = await this.prismaService.accountBalance.upsert({ const accountBalance = await this.prismaService.accountBalance.upsert({
create: { create: {
Account: { account: {
connect: { connect: {
id_userId: { id_userId: {
userId, userId,
@ -154,7 +154,7 @@ export class AccountBalanceService {
} }
if (withExcludedAccounts === false) { if (withExcludedAccounts === false) {
where.Account = { isExcluded: false }; where.account = { isExcluded: false };
} }
const balances = await this.prismaService.accountBalance.findMany({ const balances = await this.prismaService.accountBalance.findMany({
@ -163,7 +163,7 @@ export class AccountBalanceService {
date: 'asc' date: 'asc'
}, },
select: { select: {
Account: true, account: true,
date: true, date: true,
id: true, id: true,
value: true value: true
@ -174,10 +174,10 @@ export class AccountBalanceService {
balances: balances.map((balance) => { balances: balances.map((balance) => {
return { return {
...balance, ...balance,
accountId: balance.Account.id, accountId: balance.account.id,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value, balance.value,
balance.Account.currency, balance.account.currency,
userCurrency userCurrency
) )
}; };

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

@ -152,8 +152,8 @@ export class AccountController {
return this.accountService.createAccount( return this.accountService.createAccount(
{ {
...data, ...data,
Platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id
); );
@ -163,7 +163,7 @@ export class AccountController {
return this.accountService.createAccount( return this.accountService.createAccount(
{ {
...data, ...data,
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id
); );
@ -250,8 +250,8 @@ export class AccountController {
{ {
data: { data: {
...data, ...data,
Platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id_userId: { id_userId: {
@ -270,10 +270,10 @@ export class AccountController {
{ {
data: { data: {
...data, ...data,
Platform: originalAccount.platformId platform: originalAccount.platformId
? { disconnect: true } ? { disconnect: true }
: undefined, : undefined,
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id_userId: { id_userId: {

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

@ -64,7 +64,7 @@ export class AccountService {
(Account & { (Account & {
activities?: Order[]; activities?: Order[];
balances?: AccountBalance[]; balances?: AccountBalance[];
Platform?: Platform; platform?: Platform;
})[] })[]
> { > {
const { include = {}, skip, take, cursor, where, orderBy } = params; const { include = {}, skip, take, cursor, where, orderBy } = params;
@ -140,7 +140,10 @@ export class AccountService {
public async getAccounts(aUserId: string): Promise<Account[]> { public async getAccounts(aUserId: string): Promise<Account[]> {
const accounts = await this.accounts({ const accounts = await this.accounts({
include: { activities: true, Platform: true }, include: {
activities: true,
platform: true
},
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
where: { userId: aUserId } where: { userId: aUserId }
}); });

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

@ -3,7 +3,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { DemoService } from '@ghostfolio/api/services/demo/demo.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
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 { import {
@ -16,9 +16,9 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails,
AdminUsers, AdminUsers,
EnhancedSymbolProfile EnhancedSymbolProfile,
ScraperConfiguration
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type {
@ -50,8 +50,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto'; import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
@ -59,8 +57,8 @@ export class AdminController {
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly demoService: DemoService,
private readonly manualService: ManualService, private readonly manualService: ManualService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -71,6 +69,13 @@ export class AdminController {
return this.adminService.get({ user: this.request.user }); return this.adminService.get({ user: this.request.user });
} }
@Get('demo-user/sync')
@HasPermission(permissions.syncDemoUserAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async syncDemoUserAccount(): Promise<Prisma.BatchPayload> {
return this.demoService.syncDemoUserAccount();
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('gather') @Post('gather')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -214,30 +219,16 @@ export class AdminController {
}); });
} }
/**
* @deprecated
*/
@Get('market-data/:dataSource/:symbol')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol/test') @Post('market-data/:dataSource/:symbol/test')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async testMarketData( public async testMarketData(
@Body() data: { scraperConfiguration: string }, @Body() data: { scraperConfiguration: ScraperConfiguration },
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<{ price: number }> { ): Promise<{ price: number }> {
try { try {
const scraperConfiguration = JSON.parse(data.scraperConfiguration); const price = await this.manualService.test(data.scraperConfiguration);
const price = await this.manualService.test(scraperConfiguration);
if (price) { if (price) {
return { price }; return { price };
@ -253,58 +244,6 @@ export class AdminController {
} }
} }
/**
* @deprecated
*/
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({
dataSource,
marketPrice,
symbol,
date: parseISO(date),
state: 'CLOSE'
})
);
return this.marketDataService.updateMany({
data: dataBulkUpdate
});
}
/**
* @deprecated
*/
@HasPermission(permissions.accessAdminControl)
@Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async update(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string,
@Body() data: UpdateMarketDataDto
) {
const date = parseISO(dateString);
return this.marketDataService.updateMarketData({
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
where: {
dataSource_date_symbol: {
dataSource,
date,
symbol
}
}
});
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('profile-data/:dataSource/:symbol') @Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

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

@ -4,6 +4,7 @@ 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';
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';
import { DemoModule } from '@ghostfolio/api/services/demo/demo.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -24,6 +25,7 @@ import { QueueModule } from './queue/queue.module';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
DemoModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,

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

@ -114,9 +114,8 @@ export class AdminService {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
const currency = getCurrencyFromSymbol(symbol); const currency = getCurrencyFromSymbol(symbol);
const customCurrencies = (await this.propertyService.getByKey( const customCurrencies =
PROPERTY_CURRENCIES await this.propertyService.getByKey<string[]>(PROPERTY_CURRENCIES);
)) as string[];
if (customCurrencies.includes(currency)) { if (customCurrencies.includes(currency)) {
const updatedCustomCurrencies = customCurrencies.filter( const updatedCustomCurrencies = customCurrencies.filter(
@ -135,7 +134,10 @@ export class AdminService {
} }
public async get({ user }: { user: UserWithSettings }): Promise<AdminData> { public async get({ user }: { user: UserWithSettings }): Promise<AdminData> {
const dataSources = await this.dataProviderService.getDataSources({ user }); const dataSources = await this.dataProviderService.getDataSources({
user,
includeGhostfolio: true
});
const [settings, transactionCount, userCount] = await Promise.all([ const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(), this.propertyService.get(),
@ -645,7 +647,7 @@ export class AdminService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
where = { where = {
NOT: { NOT: {
Analytics: null analytics: null
} }
}; };
} }
@ -671,7 +673,7 @@ export class AdminService {
select: { select: {
activities: { activities: {
where: { where: {
User: { user: {
subscriptions: { subscriptions: {
some: { some: {
expiresAt: { expiresAt: {
@ -803,13 +805,13 @@ export class AdminService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = { orderBy = {
Analytics: { analytics: {
lastRequestAt: 'desc' lastRequestAt: 'desc'
} }
}; };
where = { where = {
NOT: { NOT: {
Analytics: null analytics: null
} }
}; };
} }
@ -821,9 +823,9 @@ export class AdminService {
where, where,
select: { select: {
_count: { _count: {
select: { Account: true, activities: true } select: { accounts: true, activities: true }
}, },
Analytics: { analytics: {
select: { select: {
activityCount: true, activityCount: true,
country: true, country: true,
@ -849,11 +851,11 @@ export class AdminService {
}); });
return usersWithAnalytics.map( return usersWithAnalytics.map(
({ _count, Analytics, createdAt, id, role, subscriptions }) => { ({ _count, analytics, createdAt, id, role, subscriptions }) => {
const daysSinceRegistration = const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1; differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics const engagement = analytics
? Analytics.activityCount / daysSinceRegistration ? analytics.activityCount / daysSinceRegistration
: undefined; : undefined;
const subscription = const subscription =
@ -868,11 +870,11 @@ export class AdminService {
id, id,
role, role,
subscription, subscription,
accountCount: _count.Account || 0, accountCount: _count.accounts || 0,
activityCount: _count.activities || 0, activityCount: _count.activities || 0,
country: Analytics?.country, country: analytics?.country,
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0, dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0,
lastActivity: Analytics?.updatedAt lastActivity: analytics?.updatedAt
}; };
} }
); );

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

@ -1,20 +1,21 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { EventsModule } from '@ghostfolio/api/events/events.module';
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 { CronService } from '@ghostfolio/api/services/cron.service'; import { CronModule } from '@ghostfolio/api/services/cron/cron.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
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 { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@ -37,6 +38,7 @@ import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module'; import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PublicModule } from './endpoints/public/public.module'; import { PublicModule } from './endpoints/public/public.module';
import { SitemapModule } from './endpoints/sitemap/sitemap.module';
import { TagsModule } from './endpoints/tags/tags.module'; import { TagsModule } from './endpoints/tags/tags.module';
import { WatchlistModule } from './endpoints/watchlist/watchlist.module'; import { WatchlistModule } from './endpoints/watchlist/watchlist.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
@ -49,7 +51,6 @@ 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';
import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module'; import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@ -78,6 +79,7 @@ import { UserModule } from './user/user.module';
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
ConfigurationModule, ConfigurationModule,
CronModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
@ -101,7 +103,7 @@ import { UserModule } from './user/user.module';
RedisCacheModule, RedisCacheModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
exclude: ['/api*', '/sitemap.xml'], exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'],
rootPath: join(__dirname, '..', 'client'), rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: { serveStaticOptions: {
setHeaders: (res) => { setHeaders: (res) => {
@ -124,14 +126,21 @@ import { UserModule } from './user/user.module';
} }
} }
}), }),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'client', '.well-known'),
serveRoot: '/.well-known'
}),
SitemapModule, SitemapModule,
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TagsModule, TagsModule,
TwitterBotModule,
UserModule, UserModule,
WatchlistModule WatchlistModule
], ],
providers: [CronService] providers: [I18nService]
}) })
export class AppModule {} export class AppModule implements NestModule {
public configure(consumer: MiddlewareConsumer) {
consumer.apply(HtmlTemplateMiddleware).forRoutes('*wildcard');
}
}

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

@ -36,7 +36,7 @@ export class ApiKeyStrategy extends PassportStrategy(
} }
await this.prismaService.analytics.upsert({ await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } }, create: { user: { connect: { id: user.id } } },
update: { update: {
activityCount: { increment: 1 }, activityCount: { increment: 1 },
lastRequestAt: new Date() lastRequestAt: new Date()

16
apps/api/src/app/auth/jwt.strategy.ts

@ -1,7 +1,11 @@
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE,
HEADER_KEY_TIMEZONE
} from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions'; import { hasRole } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
@ -42,7 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
countriesAndTimezones.getCountryForTimezone(timezone)?.id; countriesAndTimezones.getCountryForTimezone(timezone)?.id;
await this.prismaService.analytics.upsert({ await this.prismaService.analytics.upsert({
create: { country, User: { connect: { id: user.id } } }, create: { country, user: { connect: { id: user.id } } },
update: { update: {
country, country,
activityCount: { increment: 1 }, activityCount: { increment: 1 },
@ -52,6 +56,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}); });
} }
if (!user.Settings.settings.baseCurrency) {
user.Settings.settings.baseCurrency = DEFAULT_CURRENCY;
}
if (!user.Settings.settings.language) {
user.Settings.settings.language = DEFAULT_LANGUAGE_CODE;
}
return user; return user;
} else { } else {
throw new HttpException( throw new HttpException(

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

@ -131,7 +131,7 @@ export class WebAuthService {
counter, counter,
credentialId: Buffer.from(credentialId), credentialId: Buffer.from(credentialId),
credentialPublicKey: Buffer.from(credentialPublicKey), credentialPublicKey: Buffer.from(credentialPublicKey),
User: { connect: { id: user.id } } user: { connect: { id: user.id } }
}); });
} }

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

@ -1,10 +1,6 @@
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 { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import {
DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE
} from '@ghostfolio/common/config';
import { AiPromptResponse } from '@ghostfolio/common/interfaces'; import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types'; import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
@ -53,10 +49,8 @@ export class AiController {
filters, filters,
mode, mode,
impersonationId: undefined, impersonationId: undefined,
languageCode: languageCode: this.request.user.Settings.settings.language,
this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE, userCurrency: this.request.user.Settings.settings.baseCurrency,
userCurrency:
this.request.user.Settings.settings.baseCurrency ?? DEFAULT_CURRENCY,
userId: this.request.user.id userId: this.request.user.id
}); });

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

@ -12,10 +12,12 @@ import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.mo
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';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -32,11 +34,13 @@ import { AiService } from './ai.service';
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,
PortfolioSnapshotQueueModule, PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,
PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
UserModule UserModule

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

@ -1,12 +1,41 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL
} from '@ghostfolio/common/config';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import type { AiPromptMode } from '@ghostfolio/common/types'; import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText } from 'ai';
@Injectable() @Injectable()
export class AiService { export class AiService {
public constructor(private readonly portfolioService: PortfolioService) {} public constructor(
private readonly portfolioService: PortfolioService,
private readonly propertyService: PropertyService
) {}
public async generateText({ prompt }: { prompt: string }) {
const openRouterApiKey = await this.propertyService.getByKey<string>(
PROPERTY_API_KEY_OPENROUTER
);
const openRouterModel = await this.propertyService.getByKey<string>(
PROPERTY_OPENROUTER_MODEL
);
const openRouterService = createOpenRouter({
apiKey: openRouterApiKey
});
return generateText({
prompt,
model: openRouterService.chat(openRouterModel)
});
}
public async getPrompt({ public async getPrompt({
filters, filters,
@ -30,7 +59,7 @@ export class AiService {
}); });
const holdingsTable = [ const holdingsTable = [
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |', '| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |',
'| --- | --- | --- | --- | --- | --- |', '| --- | --- | --- | --- | --- | --- |',
...Object.values(holdings) ...Object.values(holdings)
.sort((a, b) => { .sort((a, b) => {

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

@ -15,6 +15,7 @@ import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.s
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';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
@ -35,6 +36,7 @@ import { BenchmarksService } from './benchmarks.service';
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,

173
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -74,48 +74,6 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('dividends/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getDividendsV1(
@Param('symbol') symbol: string,
@Query() query: GetDividendsDto
): Promise<DividendsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const dividends = await this.ghostfolioService.getDividends({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return dividends;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('dividends/:symbol') @Get('dividends/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -156,48 +114,6 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getHistoricalV1(
@Param('symbol') symbol: string,
@Query() query: GetHistoricalDto
): Promise<HistoricalResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const historicalData = await this.ghostfolioService.getHistorical({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return historicalData;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('historical/:symbol') @Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -238,47 +154,6 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async lookupSymbolV1(
@Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = ''
): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const result = await this.ghostfolioService.lookup({
includeIndices,
query: query.toLowerCase()
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return result;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('lookup') @Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -320,44 +195,6 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getQuotesV1(
@Query() query: GetQuotesDto
): Promise<QuotesResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const quotes = await this.ghostfolioService.getQuotes({
symbols: query.symbols
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return quotes;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('quotes') @Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@ -394,16 +231,6 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getStatusV1(): Promise<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user });
}
@Get('status') @Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)

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

@ -164,9 +164,9 @@ export class GhostfolioService {
public async getMaxDailyRequests() { public async getMaxDailyRequests() {
return parseInt( return parseInt(
((await this.propertyService.getByKey( (await this.propertyService.getByKey<string>(
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS
)) as string) || '0', )) || '0',
10 10
); );
} }

60
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -1,8 +1,20 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
ghostfolioFearAndGreedIndexDataSourceStocks,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces'; import {
MarketDataDetailsResponse,
MarketDataOfMarketsResponse
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
@ -14,6 +26,7 @@ import {
Inject, Inject,
Param, Param,
Post, Post,
Query,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
@ -30,9 +43,48 @@ export class MarketDataController {
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService,
private readonly symbolService: SymbolService
) {} ) {}
@Get('markets')
@HasPermission(permissions.readMarketDataOfMarkets)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataOfMarkets(
@Query('includeHistoricalData') includeHistoricalData = 0
): Promise<MarketDataOfMarketsResponse> {
const [
marketDataFearAndGreedIndexCryptocurrencies,
marketDataFearAndGreedIndexStocks
] = await Promise.all([
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies
}
}),
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceStocks,
symbol: ghostfolioFearAndGreedIndexSymbolStocks
}
})
]);
return {
fearAndGreedIndex: {
CRYPTOCURRENCIES: {
...marketDataFearAndGreedIndexCryptocurrencies
},
STOCKS: {
...marketDataFearAndGreedIndexStocks
}
}
};
}
@Get(':dataSource/:symbol') @Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol( public async getMarketDataBySymbol(
@ -85,7 +137,7 @@ export class MarketDataController {
{ dataSource, symbol } { dataSource, symbol }
]); ]);
if (!assetProfile) { if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND StatusCodes.NOT_FOUND
@ -103,7 +155,7 @@ export class MarketDataController {
); );
const canUpsertOwnAssetProfile = const canUpsertOwnAssetProfile =
assetProfile.userId === this.request.user.id && assetProfile?.userId === this.request.user.id &&
hasPermission( hasPermission(
this.request.user.permissions, this.request.user.permissions,
permissions.createMarketDataOfOwnAssetProfile permissions.createMarketDataOfOwnAssetProfile

8
apps/api/src/app/endpoints/market-data/market-data.module.ts

@ -1,4 +1,5 @@
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -8,6 +9,11 @@ import { MarketDataController } from './market-data.controller';
@Module({ @Module({
controllers: [MarketDataController], controllers: [MarketDataController],
imports: [AdminModule, MarketDataServiceModule, SymbolProfileModule] imports: [
AdminModule,
MarketDataServiceModule,
SymbolModule,
SymbolProfileModule
]
}) })
export class MarketDataModule {} export class MarketDataModule {}

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

@ -12,6 +12,7 @@ import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -29,6 +30,7 @@ import { PublicController } from './public.controller';
BenchmarkModule, BenchmarkModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,

51
apps/api/src/app/endpoints/sitemap/sitemap.controller.ts

@ -0,0 +1,51 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DATE_FORMAT,
getYesterday,
interpolate
} from '@ghostfolio/common/helper';
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { SitemapService } from './sitemap.service';
@Controller('sitemap.xml')
export class SitemapController {
public sitemapXml = '';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly sitemapService: SitemapService
) {
try {
this.sitemapXml = readFileSync(
join(__dirname, 'assets', 'sitemap.xml'),
'utf8'
);
} catch {}
}
@Get()
@Version(VERSION_NEUTRAL)
public getSitemapXml(@Res() response: Response) {
const currentDate = format(getYesterday(), DATE_FORMAT);
response.setHeader('content-type', 'application/xml');
response.send(
interpolate(this.sitemapXml, {
personalFinanceTools: this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.sitemapService.getPersonalFinanceTools({ currentDate })
: '',
publicRoutes: this.sitemapService.getPublicRoutes({
currentDate
})
})
);
}
}

5
apps/api/src/app/sitemap/sitemap.module.ts → apps/api/src/app/endpoints/sitemap/sitemap.module.ts

@ -1,11 +1,14 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SitemapController } from './sitemap.controller'; import { SitemapController } from './sitemap.controller';
import { SitemapService } from './sitemap.service';
@Module({ @Module({
controllers: [SitemapController], controllers: [SitemapController],
imports: [ConfigurationModule] imports: [ConfigurationModule, I18nModule],
providers: [SitemapService]
}) })
export class SitemapModule {} export class SitemapModule {}

116
apps/api/src/app/endpoints/sitemap/sitemap.service.ts

@ -0,0 +1,116 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { PublicRoute } from '@ghostfolio/common/routes/interfaces/public-route.interface';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Injectable } from '@nestjs/common';
@Injectable()
export class SitemapService {
private static readonly TRANSLATION_TAGGED_MESSAGE_REGEX =
/:.*@@(?<id>[a-zA-Z0-9.]+):(?<message>.+)/;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly i18nService: I18nService
) {}
public getPersonalFinanceTools({ currentDate }: { currentDate: string }) {
const rootUrl = this.configurationService.get('ROOT_URL');
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => {
return personalFinanceTools.map(({ alias, key }) => {
const route =
publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes
.product;
const params = {
currentDate,
languageCode,
rootUrl,
urlPostfix: alias ?? key
};
return this.createRouteSitemapUrl({ ...params, route });
});
}).join('\n');
}
public getPublicRoutes({ currentDate }: { currentDate: string }) {
const rootUrl = this.configurationService.get('ROOT_URL');
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => {
const params = {
currentDate,
languageCode,
rootUrl
};
return [
this.createRouteSitemapUrl(params),
...this.createSitemapUrls(params, publicRoutes)
];
}).join('\n');
}
private createRouteSitemapUrl({
currentDate,
languageCode,
rootUrl,
route,
urlPostfix
}: {
currentDate: string;
languageCode: string;
rootUrl: string;
route?: PublicRoute;
urlPostfix?: string;
}): string {
const segments =
route?.routerLink.map((link) => {
const match = link.match(
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX
);
const segment = match
? (this.i18nService.getTranslation({
languageCode,
id: match.groups.id
}) ?? match.groups.message)
: link;
return segment.replace(/^\/+|\/+$/, '');
}) ?? [];
const location =
[rootUrl, languageCode, ...segments].join('/') +
(urlPostfix ? `-${urlPostfix}` : '');
return [
' <url>',
` <loc>${location}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
' </url>'
].join('\n');
}
private createSitemapUrls(
params: { currentDate: string; languageCode: string; rootUrl: string },
routes: Record<string, PublicRoute>
): string[] {
return Object.values(routes).flatMap((route) => {
if (route.excludeFromSitemap) {
return [];
}
const urls = [this.createRouteSitemapUrl({ ...params, route })];
if (route.subRoutes) {
urls.push(...this.createSitemapUrls(params, route.subRoutes));
}
return urls;
});
}
}

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

@ -1,9 +1,17 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { Export } from '@ghostfolio/common/interfaces'; import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common'; import {
Controller,
Get,
Inject,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -19,16 +27,21 @@ export class ExportController {
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string, @Query('activityIds') filterByActivityIds?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<Export> { ): Promise<Export> {
const activityIds = filterByActivityIds?.split(',') ?? []; const activityIds = filterByActivityIds?.split(',') ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });

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

@ -1,5 +1,6 @@
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 { OrderModule } from '@ghostfolio/api/app/order/order.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 { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -9,8 +10,14 @@ import { ExportController } from './export.controller';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
@Module({ @Module({
imports: [AccountModule, ApiModule, OrderModule, TagModule],
controllers: [ExportController], controllers: [ExportController],
imports: [
AccountModule,
ApiModule,
OrderModule,
TagModule,
TransformDataSourceInRequestModule
],
providers: [ExportService] providers: [ExportService]
}) })
export class ExportModule {} export class ExportModule {}

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

@ -48,7 +48,7 @@ export class ExportService {
await this.accountService.accounts({ await this.accountService.accounts({
include: { include: {
balances: true, balances: true,
Platform: true platform: true
}, },
orderBy: { orderBy: {
name: 'asc' name: 'asc'
@ -72,7 +72,7 @@ export class ExportService {
id, id,
isExcluded, isExcluded,
name, name,
Platform: platform, platform,
platformId platformId
}) => { }) => {
if (platformId) { if (platformId) {
@ -141,9 +141,11 @@ export class ExportService {
currency: currency ?? SymbolProfile.currency, currency: currency ?? SymbolProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toISOString(), date: date.toISOString(),
symbol: ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(type) symbol:
? SymbolProfile.name ['FEE', 'INTEREST', 'LIABILITY'].includes(type) ||
: SymbolProfile.symbol, (SymbolProfile.dataSource === 'MANUAL' && type === 'BUY')
? SymbolProfile.name
: SymbolProfile.symbol,
tags: currentTags.map(({ id: tagId }) => { tags: currentTags.map(({ id: tagId }) => {
return tagId; return tagId;
}) })

40
apps/api/src/app/health/health.controller.ts

@ -1,4 +1,8 @@
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 {
DataEnhancerHealthResponse,
DataProviderHealthResponse
} from '@ghostfolio/common/interfaces';
import { import {
Controller, Controller,
@ -37,23 +41,30 @@ export class HealthController {
} }
@Get('data-enhancer/:name') @Get('data-enhancer/:name')
public async getHealthOfDataEnhancer(@Param('name') name: string) { public async getHealthOfDataEnhancer(
@Param('name') name: string,
@Res() response: Response
): Promise<Response<DataEnhancerHealthResponse>> {
const hasResponse = const hasResponse =
await this.healthService.hasResponseFromDataEnhancer(name); await this.healthService.hasResponseFromDataEnhancer(name);
if (hasResponse !== true) { if (hasResponse) {
throw new HttpException( return response.status(HttpStatus.OK).json({
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), status: getReasonPhrase(StatusCodes.OK)
StatusCodes.SERVICE_UNAVAILABLE });
); } else {
return response
.status(HttpStatus.SERVICE_UNAVAILABLE)
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
} }
} }
@Get('data-provider/:dataSource') @Get('data-provider/:dataSource')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getHealthOfDataProvider( public async getHealthOfDataProvider(
@Param('dataSource') dataSource: DataSource @Param('dataSource') dataSource: DataSource,
) { @Res() response: Response
): Promise<Response<DataProviderHealthResponse>> {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
@ -64,11 +75,14 @@ export class HealthController {
const hasResponse = const hasResponse =
await this.healthService.hasResponseFromDataProvider(dataSource); await this.healthService.hasResponseFromDataProvider(dataSource);
if (hasResponse !== true) { if (hasResponse) {
throw new HttpException( return response
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), .status(HttpStatus.OK)
StatusCodes.SERVICE_UNAVAILABLE .json({ status: getReasonPhrase(StatusCodes.OK) });
); } else {
return response
.status(HttpStatus.SERVICE_UNAVAILABLE)
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
} }
} }
} }

2
apps/api/src/app/import/create-account-with-balances.dto.ts

@ -6,5 +6,5 @@ import { IsArray, IsOptional } from 'class-validator';
export class CreateAccountWithBalancesDto extends CreateAccountDto { export class CreateAccountWithBalancesDto extends CreateAccountDto {
@IsArray() @IsArray()
@IsOptional() @IsOptional()
balances?: AccountBalance; balances?: AccountBalance[];
} }

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

@ -71,7 +71,7 @@ export class ImportController {
const activities = await this.importService.import({ const activities = await this.importService.import({
isDryRun, isDryRun,
maxActivitiesToImport, maxActivitiesToImport,
accountsDto: importData.accounts ?? [], accountsWithBalancesDto: importData.accounts ?? [],
activitiesDto: importData.activities, activitiesDto: importData.activities,
user: this.request.user user: this.request.user
}); });
@ -100,7 +100,8 @@ export class ImportController {
): Promise<ImportResponse> { ): Promise<ImportResponse> {
const activities = await this.importService.getDividends({ const activities = await this.importService.getDividends({
dataSource, dataSource,
symbol symbol,
userId: this.request.user.id
}); });
return { activities }; return { activities };

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

@ -28,9 +28,11 @@ import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma, SymbolProfile } 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 { uniqBy } from 'lodash'; import { omit, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { ImportDataDto } from './import-data.dto';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
public constructor( public constructor(
@ -46,11 +48,17 @@ export class ImportService {
public async getDividends({ public async getDividends({
dataSource, dataSource,
symbol symbol,
}: AssetProfileIdentifier): Promise<Activity[]> { userId
}: AssetProfileIdentifier & { userId: string }): Promise<Activity[]> {
try { try {
const { activities, firstBuyDate, historicalData } = const { activities, firstBuyDate, historicalData } =
await this.portfolioService.getHolding(dataSource, undefined, symbol); await this.portfolioService.getHolding({
dataSource,
symbol,
userId,
impersonationId: undefined
});
const [[assetProfile], dividends] = await Promise.all([ const [[assetProfile], dividends] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([ this.symbolProfileService.getSymbolProfiles([
@ -69,14 +77,14 @@ export class ImportService {
]); ]);
const accounts = activities const accounts = activities
.filter(({ Account }) => { .filter(({ account }) => {
return !!Account; return !!account;
}) })
.map(({ Account }) => { .map(({ account }) => {
return Account; return account;
}); });
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
return await Promise.all( return await Promise.all(
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => { Object.entries(dividends).map(async ([dateString, { marketPrice }]) => {
@ -90,7 +98,7 @@ export class ImportService {
const date = parseDate(dateString); const date = parseDate(dateString);
const isDuplicate = activities.some((activity) => { const isDuplicate = activities.some((activity) => {
return ( return (
activity.accountId === Account?.id && activity.accountId === account?.id &&
activity.SymbolProfile.currency === assetProfile.currency && activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource && activity.SymbolProfile.dataSource === assetProfile.dataSource &&
isSameSecond(activity.date, date) && isSameSecond(activity.date, date) &&
@ -106,12 +114,12 @@ export class ImportService {
: undefined; : undefined;
return { return {
Account, account,
date, date,
error, error,
quantity, quantity,
value, value,
accountId: Account?.id, accountId: account?.id,
accountUserId: undefined, accountUserId: undefined,
comment: undefined, comment: undefined,
currency: undefined, currency: undefined,
@ -127,7 +135,7 @@ export class ImportService {
unitPrice: marketPrice, unitPrice: marketPrice,
unitPriceInAssetProfileCurrency: marketPrice, unitPriceInAssetProfileCurrency: marketPrice,
updatedAt: undefined, updatedAt: undefined,
userId: Account?.userId, userId: account?.userId,
valueInBaseCurrency: value valueInBaseCurrency: value
}; };
}) })
@ -138,14 +146,14 @@ export class ImportService {
} }
public async import({ public async import({
accountsDto, accountsWithBalancesDto,
activitiesDto, activitiesDto,
isDryRun = false, isDryRun = false,
maxActivitiesToImport, maxActivitiesToImport,
user user
}: { }: {
accountsDto: Partial<CreateAccountDto>[]; accountsWithBalancesDto: ImportDataDto['accounts'];
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: ImportDataDto['activities'];
isDryRun?: boolean; isDryRun?: boolean;
maxActivitiesToImport: number; maxActivitiesToImport: number;
user: UserWithSettings; user: UserWithSettings;
@ -153,12 +161,12 @@ export class ImportService {
const accountIdMapping: { [oldAccountId: string]: string } = {}; const accountIdMapping: { [oldAccountId: string]: string } = {};
const userCurrency = user.Settings.settings.baseCurrency; const userCurrency = user.Settings.settings.baseCurrency;
if (!isDryRun && accountsDto?.length) { if (!isDryRun && accountsWithBalancesDto?.length) {
const [existingAccounts, existingPlatforms] = await Promise.all([ const [existingAccounts, existingPlatforms] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
where: { where: {
id: { id: {
in: accountsDto.map(({ id }) => { in: accountsWithBalancesDto.map(({ id }) => {
return id; return id;
}) })
} }
@ -167,14 +175,19 @@ export class ImportService {
this.platformService.getPlatforms() this.platformService.getPlatforms()
]); ]);
for (const account of accountsDto) { for (const accountWithBalances of accountsWithBalancesDto) {
// Check if there is any existing account with the same ID // Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find((existingAccount) => { const accountWithSameId = existingAccounts.find((existingAccount) => {
return existingAccount.id === account.id; return existingAccount.id === accountWithBalances.id;
}); });
// If there is no account or if the account belongs to a different user then create a new account // If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== user.id) { if (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account: CreateAccountDto = omit(
accountWithBalances,
'balances'
);
let oldAccountId: string; let oldAccountId: string;
const platformId = account.platformId; const platformId = account.platformId;
@ -187,7 +200,10 @@ export class ImportService {
let accountObject: Prisma.AccountCreateInput = { let accountObject: Prisma.AccountCreateInput = {
...account, ...account,
User: { connect: { id: user.id } } balances: {
create: accountWithBalances.balances ?? []
},
user: { connect: { id: user.id } }
}; };
if ( if (
@ -197,7 +213,7 @@ export class ImportService {
) { ) {
accountObject = { accountObject = {
...accountObject, ...accountObject,
Platform: { connect: { id: platformId } } platform: { connect: { id: platformId } }
}; };
} }
@ -216,7 +232,7 @@ export class ImportService {
for (const activity of activitiesDto) { for (const activity of activitiesDto) {
if (!activity.dataSource) { if (!activity.dataSource) {
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(activity.type)) { if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) {
activity.dataSource = DataSource.MANUAL; activity.dataSource = DataSource.MANUAL;
} else { } else {
activity.dataSource = activity.dataSource =
@ -251,7 +267,7 @@ export class ImportService {
); );
if (isDryRun) { if (isDryRun) {
accountsDto.forEach(({ id, name }) => { accountsWithBalancesDto.forEach(({ id, name }) => {
accounts.push({ id, name }); accounts.push({ id, name });
}); });
} }
@ -386,7 +402,7 @@ export class ImportService {
} }
}, },
updateAccountBalance: false, updateAccountBalance: false,
User: { connect: { id: user.id } }, user: { connect: { id: user.id } },
userId: user.id userId: user.id
}); });
@ -548,6 +564,12 @@ export class ImportService {
index, index,
{ currency, dataSource, symbol, type } { currency, dataSource, symbol, type }
] of activitiesDto.entries()) { ] of activitiesDto.entries()) {
if (type === 'ITEM') {
throw new Error(
`activities.${index}.type ("${type}") is deprecated, please use "BUY" instead`
);
}
if (!dataSources.includes(dataSource)) { if (!dataSources.includes(dataSource)) {
throw new Error( throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid` `activities.${index}.dataSource ("${dataSource}") is not valid`
@ -579,7 +601,11 @@ export class ImportService {
)?.[symbol] )?.[symbol]
}; };
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') { if (
(dataSource !== 'MANUAL' && type === 'BUY') ||
type === 'DIVIDEND' ||
type === 'SELL'
) {
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`

33
apps/api/src/app/info/info.service.ts

@ -14,7 +14,7 @@ import {
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS, PROPERTY_SLACK_COMMUNITY_USERS,
ghostfolioFearAndGreedIndexDataSource ghostfolioFearAndGreedIndexDataSourceStocks
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
@ -54,19 +54,20 @@ export class InfoService {
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource( info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSource ghostfolioFearAndGreedIndexDataSourceStocks
); );
} else { } else {
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource; info.fearAndGreedDataSource =
ghostfolioFearAndGreedIndexDataSourceStocks;
} }
globalPermissions.push(permissions.enableFearAndGreedIndex); globalPermissions.push(permissions.enableFearAndGreedIndex);
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
isReadOnlyMode = (await this.propertyService.getByKey( isReadOnlyMode = await this.propertyService.getByKey<boolean>(
PROPERTY_IS_READ_ONLY_MODE PROPERTY_IS_READ_ONLY_MODE
)) as boolean; );
} }
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) { if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
@ -81,9 +82,9 @@ export class InfoService {
globalPermissions.push(permissions.enableSubscription); globalPermissions.push(permissions.enableSubscription);
info.countriesOfSubscribers = info.countriesOfSubscribers =
((await this.propertyService.getByKey( (await this.propertyService.getByKey<string[]>(
PROPERTY_COUNTRIES_OF_SUBSCRIBERS PROPERTY_COUNTRIES_OF_SUBSCRIBERS
)) as string[]) ?? []; )) ?? [];
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY'); info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
} }
@ -133,11 +134,11 @@ export class InfoService {
AND: [ AND: [
{ {
NOT: { NOT: {
Analytics: null analytics: null
} }
}, },
{ {
Analytics: { analytics: {
lastRequestAt: { lastRequestAt: {
gt: subDays(new Date(), aDays) gt: subDays(new Date(), aDays)
} }
@ -216,7 +217,7 @@ export class InfoService {
AND: [ AND: [
{ {
NOT: { NOT: {
Analytics: null analytics: null
} }
}, },
{ {
@ -230,15 +231,15 @@ export class InfoService {
} }
private async countSlackCommunityUsers() { private async countSlackCommunityUsers() {
return (await this.propertyService.getByKey( return await this.propertyService.getByKey<string>(
PROPERTY_SLACK_COMMUNITY_USERS PROPERTY_SLACK_COMMUNITY_USERS
)) as string; );
} }
private async getDemoAuthToken() { private async getDemoAuthToken() {
const demoUserId = (await this.propertyService.getByKey( const demoUserId = await this.propertyService.getByKey<string>(
PROPERTY_DEMO_USER_ID PROPERTY_DEMO_USER_ID
)) as string; );
if (demoUserId) { if (demoUserId) {
return this.jwtService.sign({ return this.jwtService.sign({
@ -298,9 +299,9 @@ export class InfoService {
private async getUptime(): Promise<number> { private async getUptime(): Promise<number> {
{ {
try { try {
const monitorId = (await this.propertyService.getByKey( const monitorId = await this.propertyService.getByKey<string>(
PROPERTY_BETTER_UPTIME_MONITOR_ID PROPERTY_BETTER_UPTIME_MONITOR_ID
)) as string; );
const { data } = await fetch( const { data } = await fetch(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(

2
apps/api/src/app/order/interfaces/activities.interface.ts

@ -9,7 +9,7 @@ export interface Activities {
} }
export interface Activity extends Order { export interface Activity extends Order {
Account?: AccountWithPlatform; account?: AccountWithPlatform;
error?: ActivityError; error?: ActivityError;
feeInAssetProfileCurrency: number; feeInAssetProfileCurrency: number;
feeInBaseCurrency: number; feeInBaseCurrency: number;

27
apps/api/src/app/order/order.controller.ts

@ -53,14 +53,19 @@ export class OrderController {
@Delete() @Delete()
@HasPermission(permissions.deleteOrder) @HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteOrders( public async deleteOrders(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<number> { ): Promise<number> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });
@ -184,6 +189,7 @@ export class OrderController {
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
const currency = data.currency; const currency = data.currency;
const customCurrency = data.customCurrency; const customCurrency = data.customCurrency;
const dataSource = data.dataSource;
if (customCurrency) { if (customCurrency) {
data.currency = customCurrency; data.currency = customCurrency;
@ -191,6 +197,8 @@ export class OrderController {
delete data.customCurrency; delete data.customCurrency;
} }
delete data.dataSource;
const order = await this.orderService.createOrder({ const order = await this.orderService.createOrder({
...data, ...data,
date: parseISO(data.date), date: parseISO(data.date),
@ -198,28 +206,28 @@ export class OrderController {
connectOrCreate: { connectOrCreate: {
create: { create: {
currency, currency,
dataSource: data.dataSource, dataSource,
symbol: data.symbol symbol: data.symbol
}, },
where: { where: {
dataSource_symbol: { dataSource_symbol: {
dataSource: data.dataSource, dataSource,
symbol: data.symbol symbol: data.symbol
} }
} }
} }
}, },
User: { connect: { id: this.request.user.id } }, user: { connect: { id: this.request.user.id } },
userId: this.request.user.id userId: this.request.user.id
}); });
if (data.dataSource && !order.isDraft) { if (dataSource && !order.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: data.dataSource, dataSource,
date: order.date, date: order.date,
symbol: data.symbol symbol: data.symbol
} }
@ -251,6 +259,7 @@ export class OrderController {
const accountId = data.accountId; const accountId = data.accountId;
const customCurrency = data.customCurrency; const customCurrency = data.customCurrency;
const dataSource = data.dataSource;
delete data.accountId; delete data.accountId;
@ -260,11 +269,13 @@ export class OrderController {
delete data.customCurrency; delete data.customCurrency;
} }
delete data.dataSource;
return this.orderService.updateOrder({ return this.orderService.updateOrder({
data: { data: {
...data, ...data,
date, date,
Account: { account: {
connect: { connect: {
id_userId: { id: accountId, userId: this.request.user.id } id_userId: { id: accountId, userId: this.request.user.id }
} }
@ -272,7 +283,7 @@ export class OrderController {
SymbolProfile: { SymbolProfile: {
connect: { connect: {
dataSource_symbol: { dataSource_symbol: {
dataSource: data.dataSource, dataSource,
symbol: data.symbol symbol: data.symbol
} }
}, },
@ -282,7 +293,7 @@ export class OrderController {
name: data.symbol name: data.symbol
} }
}, },
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id id

52
apps/api/src/app/order/order.service.ts

@ -93,17 +93,16 @@ export class OrderService {
assetClass?: AssetClass; assetClass?: AssetClass;
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
currency?: string; currency?: string;
dataSource?: DataSource;
symbol?: string; symbol?: string;
tags?: Tag[]; tags?: Tag[];
updateAccountBalance?: boolean; updateAccountBalance?: boolean;
userId: string; userId: string;
} }
): Promise<Order> { ): Promise<Order> {
let Account: Prisma.AccountCreateNestedOneWithoutActivitiesInput; let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput;
if (data.accountId) { if (data.accountId) {
Account = { account = {
connect: { connect: {
id_userId: { id_userId: {
userId: data.userId, userId: data.userId,
@ -118,7 +117,11 @@ export class OrderService {
const updateAccountBalance = data.updateAccountBalance ?? false; const updateAccountBalance = data.updateAccountBalance ?? false;
const userId = data.userId; const userId = data.userId;
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) { if (
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) ||
(data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' &&
data.type === 'BUY')
) {
const assetClass = data.assetClass; const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
const dataSource: DataSource = 'MANUAL'; const dataSource: DataSource = 'MANUAL';
@ -164,7 +167,6 @@ export class OrderService {
delete data.comment; delete data.comment;
} }
delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
delete data.updateAccountBalance; delete data.updateAccountBalance;
@ -172,14 +174,14 @@ export class OrderService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type) const isDraft = ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type)
? false ? false
: isAfter(data.date as Date, endOfToday()); : isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({ const order = await this.prismaService.order.create({
data: { data: {
...orderData, ...orderData,
Account, account,
isDraft, isDraft,
tags: { tags: {
connect: tags.map(({ id }) => { connect: tags.map(({ id }) => {
@ -335,7 +337,7 @@ export class OrderService {
]; ];
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
let symbolProfileConditions: Prisma.SymbolProfileWhereInput[] = []; const symbolProfileConditions: Prisma.SymbolProfileWhereInput[] = [];
where.SymbolProfile = { where.SymbolProfile = {
AND: symbolProfileConditions AND: symbolProfileConditions
}; };
@ -489,8 +491,8 @@ export class OrderService {
if (withExcludedAccounts === false) { if (withExcludedAccounts === false) {
where.OR = [ where.OR = [
{ Account: null }, { account: null },
{ Account: { NOT: { isExcluded: true } } } { account: { NOT: { isExcluded: true } } }
]; ];
} }
@ -501,10 +503,9 @@ export class OrderService {
take, take,
where, where,
include: { include: {
// eslint-disable-next-line @typescript-eslint/naming-convention account: {
Account: {
include: { include: {
Platform: true platform: true
} }
}, },
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
@ -646,7 +647,6 @@ export class OrderService {
assetClass?: AssetClass; assetClass?: AssetClass;
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
currency?: string; currency?: string;
dataSource?: DataSource;
symbol?: string; symbol?: string;
tags?: Tag[]; tags?: Tag[];
type?: ActivityType; type?: ActivityType;
@ -661,12 +661,17 @@ export class OrderService {
let isDraft = false; let isDraft = false;
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) { if (
delete data.SymbolProfile.connect; ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) ||
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' &&
if (data.Account?.connect?.id_userId?.id === null) { data.type === 'BUY')
data.Account = { disconnect: true }; ) {
if (data.account?.connect?.id_userId?.id === null) {
data.account = { disconnect: true };
} }
delete data.SymbolProfile.connect;
delete data.SymbolProfile.update.name;
} else { } else {
delete data.SymbolProfile.update; delete data.SymbolProfile.update;
@ -690,17 +695,17 @@ export class OrderService {
delete data.assetClass; delete data.assetClass;
delete data.assetSubClass; delete data.assetSubClass;
delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
// Remove existing tags // Remove existing tags
await this.prismaService.order.update({ await this.prismaService.order.update({
data: { tags: { set: [] } }, where,
where data: { tags: { set: [] } }
}); });
const order = await this.prismaService.order.update({ const order = await this.prismaService.order.update({
where,
data: { data: {
...data, ...data,
isDraft, isDraft,
@ -709,8 +714,7 @@ export class OrderService {
return { id }; return { id };
}) })
} }
}, }
where
}); });
this.eventEmitter.emit( this.eventEmitter.emit(

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

@ -187,8 +187,7 @@ export abstract class PortfolioCalculator {
totalInterestWithCurrencyEffect: new Big(0), totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(0), totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0), totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big(0), totalLiabilitiesWithCurrencyEffect: new Big(0)
totalValuablesWithCurrencyEffect: new Big(0)
}; };
} }
@ -198,7 +197,6 @@ export abstract class PortfolioCalculator {
let firstTransactionPoint: TransactionPoint = null; let firstTransactionPoint: TransactionPoint = null;
let totalInterestWithCurrencyEffect = new Big(0); let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0); let totalLiabilitiesWithCurrencyEffect = new Big(0);
let totalValuablesWithCurrencyEffect = new Big(0);
for (const { currency, dataSource, symbol } of transactionPoints[ for (const { currency, dataSource, symbol } of transactionPoints[
firstIndex - 1 firstIndex - 1
@ -364,8 +362,7 @@ export abstract class PortfolioCalculator {
totalInterestInBaseCurrency, totalInterestInBaseCurrency,
totalInvestment, totalInvestment,
totalInvestmentWithCurrencyEffect, totalInvestmentWithCurrencyEffect,
totalLiabilitiesInBaseCurrency, totalLiabilitiesInBaseCurrency
totalValuablesInBaseCurrency
} = this.getSymbolMetrics({ } = this.getSymbolMetrics({
chartDateMap, chartDateMap,
marketSymbolMap, marketSymbolMap,
@ -444,10 +441,6 @@ export abstract class PortfolioCalculator {
totalLiabilitiesWithCurrencyEffect = totalLiabilitiesWithCurrencyEffect =
totalLiabilitiesWithCurrencyEffect.plus(totalLiabilitiesInBaseCurrency); totalLiabilitiesWithCurrencyEffect.plus(totalLiabilitiesInBaseCurrency);
totalValuablesWithCurrencyEffect = totalValuablesWithCurrencyEffect.plus(
totalValuablesInBaseCurrency
);
if ( if (
(hasErrors || (hasErrors ||
currentRateErrors.find(({ dataSource, symbol }) => { currentRateErrors.find(({ dataSource, symbol }) => {
@ -597,7 +590,6 @@ export abstract class PortfolioCalculator {
netPerformance: totalNetPerformanceValue.toNumber(), netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(), totalNetPerformanceValueWithCurrencyEffect.toNumber(),
// TODO: Add valuables
netWorth: totalCurrentValueWithCurrencyEffect netWorth: totalCurrentValueWithCurrencyEffect
.plus(totalAccountBalanceWithCurrencyEffect) .plus(totalAccountBalanceWithCurrencyEffect)
.toNumber(), .toNumber(),
@ -619,7 +611,6 @@ export abstract class PortfolioCalculator {
positions, positions,
totalInterestWithCurrencyEffect, totalInterestWithCurrencyEffect,
totalLiabilitiesWithCurrencyEffect, totalLiabilitiesWithCurrencyEffect,
totalValuablesWithCurrencyEffect,
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
}; };
} }
@ -754,7 +745,7 @@ export abstract class PortfolioCalculator {
? 0 ? 0
: netPerformanceWithCurrencyEffectSinceStartDate / : netPerformanceWithCurrencyEffectSinceStartDate /
timeWeightedInvestmentValue timeWeightedInvestmentValue
// TODO: Add net worth with valuables // TODO: Add net worth
// netWorth: totalCurrentValueWithCurrencyEffect // netWorth: totalCurrentValueWithCurrencyEffect
// .plus(totalAccountBalanceWithCurrencyEffect) // .plus(totalAccountBalanceWithCurrencyEffect)
// .toNumber() // .toNumber()
@ -819,12 +810,6 @@ export abstract class PortfolioCalculator {
return this.transactionPoints; return this.transactionPoints;
} }
public async getValuablesInBaseCurrency() {
await this.snapshotPromise;
return this.snapshot.totalValuablesWithCurrencyEffect;
}
private getChartDateMap({ private getChartDateMap({
endDate, endDate,
startDate, startDate,
@ -1000,19 +985,12 @@ export abstract class PortfolioCalculator {
liabilities = quantity.mul(unitPrice); liabilities = quantity.mul(unitPrice);
} }
let valuables = new Big(0);
if (type === 'ITEM') {
valuables = quantity.mul(unitPrice);
}
if (lastDate !== date || lastTransactionPoint === null) { if (lastDate !== date || lastTransactionPoint === null) {
lastTransactionPoint = { lastTransactionPoint = {
date, date,
fees, fees,
interest, interest,
liabilities, liabilities,
valuables,
items: newItems items: newItems
}; };
@ -1024,8 +1002,6 @@ export abstract class PortfolioCalculator {
lastTransactionPoint.items = newItems; lastTransactionPoint.items = newItems;
lastTransactionPoint.liabilities = lastTransactionPoint.liabilities =
lastTransactionPoint.liabilities.plus(liabilities); lastTransactionPoint.liabilities.plus(liabilities);
lastTransactionPoint.valuables =
lastTransactionPoint.valuables.plus(valuables);
} }
lastDate = date; lastDate = date;

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

@ -194,8 +194,7 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(

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

@ -179,8 +179,7 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(

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

@ -170,8 +170,7 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('273.2'), totalInvestment: new Big('273.2'),
totalInvestmentWithCurrencyEffect: new Big('273.2'), totalInvestmentWithCurrencyEffect: new Big('273.2'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(

5
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -63,7 +63,7 @@ describe('PortfolioCalculator', () => {
beforeAll(() => { beforeAll(() => {
activityDtos = loadActivityExportFile( activityDtos = loadActivityExportFile(
join(__dirname, '../../../../../../../test/import/ok-btceur.json') join(__dirname, '../../../../../../../test/import/ok/btceur.json')
); );
}); });
@ -224,8 +224,7 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('44558.42'), totalInvestment: new Big('44558.42'),
totalInvestmentWithCurrencyEffect: new Big('44558.42'), totalInvestmentWithCurrencyEffect: new Big('44558.42'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(investments).toEqual([ expect(investments).toEqual([

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

@ -198,8 +198,7 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('320.43').mul(0.97373), totalInvestment: new Big('320.43').mul(0.97373),
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'), totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(

5
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -63,7 +63,7 @@ describe('PortfolioCalculator', () => {
beforeAll(() => { beforeAll(() => {
activityDtos = loadActivityExportFile( activityDtos = loadActivityExportFile(
join(__dirname, '../../../../../../../test/import/ok-btcusd.json') join(__dirname, '../../../../../../../test/import/ok/btcusd.json')
); );
}); });
@ -224,8 +224,7 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('44558.42'), totalInvestment: new Big('44558.42'),
totalInvestmentWithCurrencyEffect: new Big('44558.42'), totalInvestmentWithCurrencyEffect: new Big('44558.42'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(investments).toEqual([ expect(investments).toEqual([

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

@ -151,8 +151,7 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(

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

@ -177,8 +177,7 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12').mul(0.8854), totalInvestment: new Big('89.12').mul(0.8854),
totalInvestmentWithCurrencyEffect: new Big('82.329056'), totalInvestmentWithCurrencyEffect: new Big('82.329056'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(

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

@ -170,8 +170,7 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('298.58'), totalInvestment: new Big('298.58'),
totalInvestmentWithCurrencyEffect: new Big('298.58'), totalInvestmentWithCurrencyEffect: new Big('298.58'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts

@ -105,8 +105,7 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big(0), totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0), totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(investments).toEqual([]); expect(investments).toEqual([]);

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

@ -65,7 +65,7 @@ describe('PortfolioCalculator', () => {
activityDtos = loadActivityExportFile( activityDtos = loadActivityExportFile(
join( join(
__dirname, __dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell-partially.json' '../../../../../../../test/import/ok/novn-buy-and-sell-partially.json'
) )
); );
}); });
@ -177,8 +177,7 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('75.80'), totalInvestment: new Big('75.80'),
totalInvestmentWithCurrencyEffect: new Big('75.80'), totalInvestmentWithCurrencyEffect: new Big('75.80'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(

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

@ -65,7 +65,7 @@ describe('PortfolioCalculator', () => {
activityDtos = loadActivityExportFile( activityDtos = loadActivityExportFile(
join( join(
__dirname, __dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell.json' '../../../../../../../test/import/ok/novn-buy-and-sell.json'
) )
); );
}); });
@ -228,8 +228,7 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(

33
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-item.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -82,7 +82,7 @@ describe('PortfolioCalculator', () => {
}); });
describe('compute portfolio snapshot', () => { describe('compute portfolio snapshot', () => {
it.only('with item activity', async () => { it.only('with valuable activity', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
@ -98,7 +98,7 @@ describe('PortfolioCalculator', () => {
name: 'Penthouse Apartment', name: 'Penthouse Apartment',
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde' symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
}, },
type: 'ITEM', type: 'BUY',
unitPriceInAssetProfileCurrency: 500000 unitPriceInAssetProfileCurrency: 500000
} }
]; ];
@ -113,9 +113,15 @@ describe('PortfolioCalculator', () => {
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('500000'),
errors: [], // TODO: []
hasErrors: true, errors: [
{
dataSource: 'MANUAL',
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
}
],
hasErrors: true, // TODO: false
positions: [ positions: [
{ {
averagePrice: new Big('500000'), averagePrice: new Big('500000'),
@ -130,29 +136,28 @@ describe('PortfolioCalculator', () => {
grossPerformancePercentage: null, grossPerformancePercentage: null,
grossPerformancePercentageWithCurrencyEffect: null, grossPerformancePercentageWithCurrencyEffect: null,
grossPerformanceWithCurrencyEffect: null, grossPerformanceWithCurrencyEffect: null,
investment: new Big('0'), investment: new Big('0'), // TODO: new Big('500000')
investmentWithCurrencyEffect: new Big('0'), investmentWithCurrencyEffect: new Big('0'), // TODO: new Big('500000')
marketPrice: null, marketPrice: null,
marketPriceInBaseCurrency: 500000, marketPriceInBaseCurrency: 500000,
netPerformance: null, netPerformance: null,
netPerformancePercentage: null, netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffectMap: null, netPerformancePercentageWithCurrencyEffectMap: null,
netPerformanceWithCurrencyEffectMap: null, netPerformanceWithCurrencyEffectMap: null,
quantity: new Big('0'), quantity: new Big('1'),
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde', symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde',
tags: [], tags: [],
timeWeightedInvestment: new Big('0'), timeWeightedInvestment: new Big('0'),
timeWeightedInvestmentWithCurrencyEffect: new Big('0'), timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
transactionCount: 1, transactionCount: 1,
valueInBaseCurrency: new Big('0') valueInBaseCurrency: new Big('500000')
} }
], ],
totalFeesWithCurrencyEffect: new Big('0'), totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'), // TODO: new Big('500000')
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0'), // TODO: new Big('500000')
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
@ -161,7 +166,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0, netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
totalInvestmentValueWithCurrencyEffect: 0 totalInvestmentValueWithCurrencyEffect: 0 // TODO: 500000
}) })
); );
}); });

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

@ -108,8 +108,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
createdAt: new Date(), createdAt: new Date(),
errors: [], errors: [],
historicalData: [], historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0), totalLiabilitiesWithCurrencyEffect: new Big(0)
totalValuablesWithCurrencyEffect: new Big(0)
}; };
} }
@ -179,8 +178,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let totalLiabilitiesInBaseCurrency = new Big(0); let totalLiabilitiesInBaseCurrency = new Big(0);
let totalQuantityFromBuyTransactions = new Big(0); let totalQuantityFromBuyTransactions = new Big(0);
let totalUnits = new Big(0); let totalUnits = new Big(0);
let totalValuables = new Big(0);
let totalValuablesInBaseCurrency = new Big(0);
let valueAtStartDate: Big; let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: Big; let valueAtStartDateWithCurrencyEffect: Big;
@ -224,9 +221,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
totalInvestment: new Big(0), totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0), totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0), totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0), totalLiabilitiesInBaseCurrency: new Big(0)
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
}; };
} }
@ -274,9 +269,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
totalInvestment: new Big(0), totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0), totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0), totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0), totalLiabilitiesInBaseCurrency: new Big(0)
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
}; };
} }
@ -412,13 +405,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus( totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1) interest.mul(exchangeRateAtOrderDate ?? 1)
); );
} else if (order.type === 'ITEM') {
const valuables = order.quantity.mul(order.unitPrice);
totalValuables = totalValuables.plus(valuables);
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus(
valuables.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') { } else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice); const liabilities = order.quantity.mul(order.unitPrice);
@ -971,8 +957,6 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
totalInvestmentWithCurrencyEffect, totalInvestmentWithCurrencyEffect,
totalLiabilities, totalLiabilities,
totalLiabilitiesInBaseCurrency, totalLiabilitiesInBaseCurrency,
totalValuables,
totalValuablesInBaseCurrency,
grossPerformance: totalGrossPerformance, grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect, totalGrossPerformanceWithCurrencyEffect,

1
apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts

@ -8,5 +8,4 @@ export interface TransactionPoint {
interest: Big; interest: Big;
items: TransactionPointSymbol[]; items: TransactionPointSymbol[];
liabilities: Big; liabilities: Big;
valuables: Big;
} }

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

@ -1,4 +1,3 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.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';
@ -377,11 +376,12 @@ export class PortfolioController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<PortfolioHoldingResponse> { ): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getHolding( const holding = await this.portfolioService.getHolding({
dataSource, dataSource,
impersonationId, impersonationId,
symbol symbol,
); userId: this.request.user.id
});
if (!holding) { if (!holding) {
throw new HttpException( throw new HttpException(
@ -414,19 +414,19 @@ export class PortfolioController {
filterByAssetClasses, filterByAssetClasses,
filterByDataSource, filterByDataSource,
filterByHoldingType, filterByHoldingType,
filterBySearchQuery,
filterBySymbol, filterBySymbol,
filterByTags filterByTags
}); });
const { holdings } = await this.portfolioService.getDetails({ const holdings = await this.portfolioService.getHoldings({
dateRange, dateRange,
filters, filters,
impersonationId, impersonationId,
query: filterBySearchQuery,
userId: this.request.user.id userId: this.request.user.id
}); });
return { holdings: Object.values(holdings) }; return { holdings };
} }
@Get('lookup') @Get('lookup')
@ -477,7 +477,8 @@ export class PortfolioController {
filters, filters,
groupBy, groupBy,
impersonationId, impersonationId,
savingsRate: this.request.user?.Settings?.settings.savingsRate savingsRate: this.request.user?.Settings?.settings.savingsRate,
userId: this.request.user.id
}); });
if ( if (
@ -646,11 +647,12 @@ export class PortfolioController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<PortfolioHoldingResponse> { ): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getHolding( const holding = await this.portfolioService.getHolding({
dataSource, dataSource,
impersonationId, impersonationId,
symbol symbol,
); userId: this.request.user.id
});
if (!holding) { if (!holding) {
throw new HttpException( throw new HttpException(
@ -667,7 +669,10 @@ export class PortfolioController {
public async getReport( public async getReport(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<PortfolioReportResponse> { ): Promise<PortfolioReportResponse> {
const report = await this.portfolioService.getReport(impersonationId); const report = await this.portfolioService.getReport({
impersonationId,
userId: this.request.user.id
});
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
@ -696,11 +701,12 @@ export class PortfolioController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
const holding = await this.portfolioService.getHolding( const holding = await this.portfolioService.getHolding({
dataSource, dataSource,
impersonationId, impersonationId,
symbol symbol,
); userId: this.request.user.id
});
if (!holding) { if (!holding) {
throw new HttpException( throw new HttpException(
@ -731,11 +737,12 @@ export class PortfolioController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
const holding = await this.portfolioService.getHolding( const holding = await this.portfolioService.getHolding({
dataSource, dataSource,
impersonationId, impersonationId,
symbol symbol,
); userId: this.request.user.id
});
if (!holding) { if (!holding) {
throw new HttpException( throw new HttpException(

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

@ -13,6 +13,7 @@ import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.mo
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';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -39,6 +40,7 @@ import { RulesService } from './rules.service';
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,

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

@ -23,6 +23,7 @@ import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/ru
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
@ -31,7 +32,7 @@ import {
} from '@ghostfolio/common/calculation-helper'; } from '@ghostfolio/common/calculation-helper';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID, TAG_ID_EMERGENCY_FUND,
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
@ -54,7 +55,6 @@ import {
PortfolioPosition, PortfolioPosition,
PortfolioReportResponse, PortfolioReportResponse,
PortfolioSummary, PortfolioSummary,
Position,
UserSettings UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models'; import { TimelinePosition } from '@ghostfolio/common/models';
@ -97,6 +97,8 @@ import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
const Fuse = require('fuse.js');
const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json'); const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json');
const developedMarkets = require('../../assets/countries/developed-markets.json'); const developedMarkets = require('../../assets/countries/developed-markets.json');
const emergingMarkets = require('../../assets/countries/emerging-markets.json'); const emergingMarkets = require('../../assets/countries/emerging-markets.json');
@ -111,6 +113,7 @@ export class PortfolioService {
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 impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
@ -162,7 +165,10 @@ export class PortfolioService {
const [accounts, details] = await Promise.all([ const [accounts, details] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
where, where,
include: { activities: true, Platform: true }, include: {
activities: true,
platform: true
},
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getDetails({ this.getDetails({
@ -270,20 +276,59 @@ export class PortfolioService {
return dividends; return dividends;
} }
public async getHoldings({
dateRange,
filters,
impersonationId,
query,
userId
}: {
dateRange: DateRange;
filters?: Filter[];
impersonationId: string;
query?: string;
userId: string;
}) {
userId = await this.getUserId(impersonationId, userId);
const { holdings: holdingsMap } = await this.getDetails({
dateRange,
filters,
impersonationId,
userId
});
let holdings = Object.values(holdingsMap);
if (query) {
const fuse = new Fuse(holdings, {
keys: ['isin', 'name', 'symbol'],
threshold: 0.3
});
holdings = fuse.search(query).map(({ item }) => {
return item;
});
}
return holdings;
}
public async getInvestments({ public async getInvestments({
dateRange, dateRange,
filters, filters,
groupBy, groupBy,
impersonationId, impersonationId,
savingsRate savingsRate,
userId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
groupBy?: GroupBy; groupBy?: GroupBy;
impersonationId: string; impersonationId: string;
savingsRate: number; savingsRate: number;
userId: string;
}): Promise<PortfolioInvestments> { }): Promise<PortfolioInvestments> {
const userId = await this.getUserId(impersonationId, this.request.user.id); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
@ -587,7 +632,7 @@ export class PortfolioService {
} }
if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) { if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) {
const cashPositions = await this.getCashPositions({ const cashPositions = this.getCashPositions({
cashDetails, cashDetails,
userCurrency, userCurrency,
value: filteredValueInBaseCurrency value: filteredValueInBaseCurrency
@ -609,10 +654,10 @@ export class PortfolioService {
if ( if (
filters?.length === 1 && filters?.length === 1 &&
filters[0].id === EMERGENCY_FUND_TAG_ID && filters[0].id === TAG_ID_EMERGENCY_FUND &&
filters[0].type === 'TAG' filters[0].type === 'TAG'
) { ) {
const emergencyFundCashPositions = await this.getCashPositions({ const emergencyFundCashPositions = this.getCashPositions({
cashDetails, cashDetails,
userCurrency, userCurrency,
value: filteredValueInBaseCurrency value: filteredValueInBaseCurrency
@ -678,12 +723,18 @@ export class PortfolioService {
}; };
} }
public async getHolding( public async getHolding({
aDataSource: DataSource, dataSource,
aImpersonationId: string, impersonationId,
aSymbol: string symbol,
): Promise<PortfolioHoldingResponse> { userId
const userId = await this.getUserId(aImpersonationId, this.request.user.id); }: {
dataSource: DataSource;
impersonationId: string;
symbol: string;
userId: string;
}): Promise<PortfolioHoldingResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
@ -708,7 +759,7 @@ export class PortfolioService {
grossPerformancePercentWithCurrencyEffect: undefined, grossPerformancePercentWithCurrencyEffect: undefined,
grossPerformanceWithCurrencyEffect: undefined, grossPerformanceWithCurrencyEffect: undefined,
historicalData: [], historicalData: [],
investment: undefined, investmentInBaseCurrencyWithCurrencyEffect: undefined,
marketPrice: undefined, marketPrice: undefined,
marketPriceMax: undefined, marketPriceMax: undefined,
marketPriceMin: undefined, marketPriceMin: undefined,
@ -726,7 +777,7 @@ export class PortfolioService {
} }
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([ const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource: aDataSource, symbol: aSymbol } { dataSource, symbol }
]); ]);
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
@ -741,26 +792,33 @@ export class PortfolioService {
const { positions } = await portfolioCalculator.getSnapshot(); const { positions } = await portfolioCalculator.getSnapshot();
const position = positions.find(({ dataSource, symbol }) => { const holding = positions.find((position) => {
return dataSource === aDataSource && symbol === aSymbol; return position.dataSource === dataSource && position.symbol === symbol;
}); });
if (position) { if (holding) {
const { const {
averagePrice, averagePrice,
currency, currency,
dataSource,
dividendInBaseCurrency, dividendInBaseCurrency,
fee, fee,
firstBuyDate, firstBuyDate,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
investmentWithCurrencyEffect,
marketPrice, marketPrice,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceWithCurrencyEffectMap,
quantity, quantity,
symbol,
tags, tags,
timeWeightedInvestment, timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect, timeWeightedInvestmentWithCurrencyEffect,
transactionCount transactionCount
} = position; } = holding;
const activitiesOfHolding = activities.filter(({ SymbolProfile }) => { const activitiesOfHolding = activities.filter(({ SymbolProfile }) => {
return ( return (
@ -789,7 +847,7 @@ export class PortfolioService {
}); });
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
[{ dataSource, symbol: aSymbol }], [{ dataSource, symbol }],
'day', 'day',
parseISO(firstBuyDate), parseISO(firstBuyDate),
new Date() new Date()
@ -809,10 +867,10 @@ export class PortfolioService {
marketPrice marketPrice
); );
if (historicalData[aSymbol]) { if (historicalData[symbol]) {
let j = -1; let j = -1;
for (const [date, { marketPrice }] of Object.entries( for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol] historicalData[symbol]
)) { )) {
while ( while (
j + 1 < transactionPoints.length && j + 1 < transactionPoints.length &&
@ -825,8 +883,8 @@ export class PortfolioService {
let currentQuantity = 0; let currentQuantity = 0;
const currentSymbol = transactionPoints[j]?.items.find( const currentSymbol = transactionPoints[j]?.items.find(
({ symbol }) => { (transactionPointSymbol) => {
return symbol === aSymbol; return transactionPointSymbol.symbol === symbol;
} }
); );
@ -890,23 +948,21 @@ export class PortfolioService {
SymbolProfile.currency, SymbolProfile.currency,
userCurrency userCurrency
), ),
grossPerformance: position.grossPerformance?.toNumber(), grossPerformance: grossPerformance?.toNumber(),
grossPerformancePercent: grossPerformancePercent: grossPerformancePercentage?.toNumber(),
position.grossPerformancePercentage?.toNumber(),
grossPerformancePercentWithCurrencyEffect: grossPerformancePercentWithCurrencyEffect:
position.grossPerformancePercentageWithCurrencyEffect?.toNumber(), grossPerformancePercentageWithCurrencyEffect?.toNumber(),
grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect:
position.grossPerformanceWithCurrencyEffect?.toNumber(), grossPerformanceWithCurrencyEffect?.toNumber(),
historicalData: historicalDataArray, historicalData: historicalDataArray,
investment: position.investment?.toNumber(), investmentInBaseCurrencyWithCurrencyEffect:
netPerformance: position.netPerformance?.toNumber(), investmentWithCurrencyEffect?.toNumber(),
netPerformancePercent: position.netPerformancePercentage?.toNumber(), netPerformance: netPerformance?.toNumber(),
netPerformancePercent: netPerformancePercentage?.toNumber(),
netPerformancePercentWithCurrencyEffect: netPerformancePercentWithCurrencyEffect:
position.netPerformancePercentageWithCurrencyEffectMap?.[ netPerformancePercentageWithCurrencyEffectMap?.['max']?.toNumber(),
'max'
]?.toNumber(),
netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(), netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(),
performances: { performances: {
allTimeHigh: { allTimeHigh: {
performancePercent, performancePercent,
@ -923,12 +979,12 @@ export class PortfolioService {
} else { } else {
const currentData = await this.dataProviderService.getQuotes({ const currentData = await this.dataProviderService.getQuotes({
user, user,
items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }] items: [{ symbol, dataSource: DataSource.YAHOO }]
}); });
const marketPrice = currentData[aSymbol]?.marketPrice; const marketPrice = currentData[symbol]?.marketPrice;
let historicalData = await this.dataProviderService.getHistorical( let historicalData = await this.dataProviderService.getHistorical(
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }], [{ symbol, dataSource: DataSource.YAHOO }],
'day', 'day',
portfolioStart, portfolioStart,
new Date() new Date()
@ -937,15 +993,13 @@ export class PortfolioService {
if (isEmpty(historicalData)) { if (isEmpty(historicalData)) {
try { try {
historicalData = await this.dataProviderService.getHistoricalRaw({ historicalData = await this.dataProviderService.getHistoricalRaw({
assetProfileIdentifiers: [ assetProfileIdentifiers: [{ symbol, dataSource: DataSource.YAHOO }],
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
],
from: portfolioStart, from: portfolioStart,
to: new Date() to: new Date()
}); });
} catch { } catch {
historicalData = { historicalData = {
[aSymbol]: {} [symbol]: {}
}; };
} }
} }
@ -956,7 +1010,7 @@ export class PortfolioService {
let marketPriceMin = marketPrice; let marketPriceMin = marketPrice;
for (const [date, { marketPrice }] of Object.entries( for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol] historicalData[symbol]
)) { )) {
historicalDataArray.push({ historicalDataArray.push({
date, date,
@ -997,7 +1051,7 @@ export class PortfolioService {
grossPerformancePercentWithCurrencyEffect: undefined, grossPerformancePercentWithCurrencyEffect: undefined,
grossPerformanceWithCurrencyEffect: undefined, grossPerformanceWithCurrencyEffect: undefined,
historicalData: historicalDataArray, historicalData: historicalDataArray,
investment: 0, investmentInBaseCurrencyWithCurrencyEffect: 0,
netPerformance: undefined, netPerformance: undefined,
netPerformancePercent: undefined, netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined, netPerformancePercentWithCurrencyEffect: undefined,
@ -1016,155 +1070,6 @@ export class PortfolioService {
} }
} }
public async getHoldings({
dateRange = 'max',
filters,
impersonationId
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
}): Promise<{ hasErrors: boolean; positions: Position[] }> {
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
filters,
userCurrency,
userId
});
if (activities.length === 0) {
return {
hasErrors: false,
positions: []
};
}
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
filters,
userId,
calculationType: this.getUserPerformanceCalculationType(user),
currency: userCurrency
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const hasErrors = portfolioSnapshot.hasErrors;
let positions = portfolioSnapshot.positions;
positions = positions.filter(({ quantity }) => {
return !quantity.eq(0);
});
const assetProfileIdentifiers = positions.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
});
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes({
user,
items: assetProfileIdentifiers
}),
this.symbolProfileService.getSymbolProfiles(
positions.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
)
]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) {
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
}
if (searchQuery) {
positions = positions.filter(({ symbol }) => {
const enhancedSymbolProfile = symbolProfileMap[symbol];
return (
enhancedSymbolProfile.isin?.toLowerCase().startsWith(searchQuery) ||
enhancedSymbolProfile.name?.toLowerCase().startsWith(searchQuery) ||
enhancedSymbolProfile.symbol?.toLowerCase().startsWith(searchQuery)
);
});
}
return {
hasErrors,
positions: positions.map(
({
averagePrice,
currency,
dataSource,
firstBuyDate,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
investment,
investmentWithCurrencyEffect,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceWithCurrencyEffectMap,
quantity,
symbol,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
transactionCount
}) => {
return {
currency,
dataSource,
firstBuyDate,
symbol,
transactionCount,
assetClass: symbolProfileMap[symbol].assetClass,
assetSubClass: symbolProfileMap[symbol].assetSubClass,
averagePrice: averagePrice.toNumber(),
grossPerformance: grossPerformance?.toNumber() ?? null,
grossPerformancePercentage:
grossPerformancePercentage?.toNumber() ?? null,
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? null,
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect?.toNumber() ?? null,
investment: investment.toNumber(),
investmentWithCurrencyEffect:
investmentWithCurrencyEffect?.toNumber(),
marketState:
dataProviderResponses[symbol]?.marketState ?? 'delayed',
name: symbolProfileMap[symbol].name,
netPerformance: netPerformance?.toNumber() ?? null,
netPerformancePercentage:
netPerformancePercentage?.toNumber() ?? null,
netPerformancePercentageWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffectMap?.[
dateRange
]?.toNumber() ?? null,
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ??
null,
quantity: quantity.toNumber(),
timeWeightedInvestment: timeWeightedInvestment?.toNumber(),
timeWeightedInvestmentWithCurrencyEffect:
timeWeightedInvestmentWithCurrencyEffect?.toNumber()
};
}
)
};
}
public async getPerformance({ public async getPerformance({
dateRange = 'max', dateRange = 'max',
filters, filters,
@ -1266,10 +1171,14 @@ export class PortfolioService {
}; };
} }
public async getReport( public async getReport({
impersonationId: string impersonationId,
): Promise<PortfolioReportResponse> { userId
const userId = await this.getUserId(impersonationId, this.request.user.id); }: {
impersonationId: string;
userId: string;
}): Promise<PortfolioReportResponse> {
userId = await this.getUserId(impersonationId, userId);
const userSettings = this.request.user.Settings.settings as UserSettings; const userSettings = this.request.user.Settings.settings as UserSettings;
const { accounts, holdings, markets, marketsAdvanced, summary } = const { accounts, holdings, markets, marketsAdvanced, summary } =
@ -1294,15 +1203,19 @@ export class PortfolioService {
const rules: PortfolioReportResponse['rules'] = { const rules: PortfolioReportResponse['rules'] = {
accountClusterRisk: accountClusterRisk:
summary.ordersCount > 0 summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new AccountClusterRiskCurrentInvestment( new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts accounts
), ),
new AccountClusterRiskSingleAccount( new AccountClusterRiskSingleAccount(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts accounts
) )
], ],
@ -1310,15 +1223,19 @@ export class PortfolioService {
) )
: undefined, : undefined,
assetClassClusterRisk: assetClassClusterRisk:
summary.ordersCount > 0 summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new AssetClassClusterRiskEquity( new AssetClassClusterRiskEquity(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService,
userSettings.language,
Object.values(holdings) Object.values(holdings)
), ),
new AssetClassClusterRiskFixedIncome( new AssetClassClusterRiskFixedIncome(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService,
userSettings.language,
Object.values(holdings) Object.values(holdings)
) )
], ],
@ -1326,23 +1243,27 @@ export class PortfolioService {
) )
: undefined, : undefined,
currencyClusterRisk: currencyClusterRisk:
summary.ordersCount > 0 summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new CurrencyClusterRiskBaseCurrencyCurrentInvestment( new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
Object.values(holdings) this.i18nService,
Object.values(holdings),
userSettings.language
), ),
new CurrencyClusterRiskCurrentInvestment( new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
Object.values(holdings) this.i18nService,
Object.values(holdings),
userSettings.language
) )
], ],
userSettings userSettings
) )
: undefined, : undefined,
economicMarketClusterRisk: economicMarketClusterRisk:
summary.ordersCount > 0 summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new EconomicMarketClusterRiskDevelopedMarkets( new EconomicMarketClusterRiskDevelopedMarkets(
@ -1363,6 +1284,8 @@ export class PortfolioService {
[ [
new EmergencyFundSetup( new EmergencyFundSetup(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService,
userSettings.language,
this.getTotalEmergencyFund({ this.getTotalEmergencyFund({
userSettings, userSettings,
emergencyFundHoldingsValueInBaseCurrency: emergencyFundHoldingsValueInBaseCurrency:
@ -1376,6 +1299,8 @@ export class PortfolioService {
[ [
new FeeRatioInitialInvestment( new FeeRatioInitialInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService,
userSettings.language,
summary.committedFunds, summary.committedFunds,
summary.fees summary.fees
) )
@ -1383,7 +1308,7 @@ export class PortfolioService {
userSettings userSettings
), ),
regionalMarketClusterRisk: regionalMarketClusterRisk:
summary.ordersCount > 0 summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new RegionalMarketClusterRiskAsiaPacific( new RegionalMarketClusterRiskAsiaPacific(
@ -1577,7 +1502,7 @@ export class PortfolioService {
return { markets, marketsAdvanced }; return { markets, marketsAdvanced };
} }
private async getCashPositions({ private getCashPositions({
cashDetails, cashDetails,
userCurrency, userCurrency,
value value
@ -1699,7 +1624,7 @@ export class PortfolioService {
const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => { const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => {
return ( return (
tags?.some(({ id }) => { tags?.some(({ id }) => {
return id === EMERGENCY_FUND_TAG_ID; return id === TAG_ID_EMERGENCY_FUND;
}) ?? false }) ?? false
); );
}); });
@ -1906,7 +1831,7 @@ export class PortfolioService {
const nonExcludedActivities: Activity[] = []; const nonExcludedActivities: Activity[] = [];
for (const activity of activities) { for (const activity of activities) {
if (activity.Account?.isExcluded) { if (activity.account?.isExcluded) {
excludedActivities.push(activity); excludedActivities.push(activity);
} else { } else {
nonExcludedActivities.push(activity); nonExcludedActivities.push(activity);
@ -1945,8 +1870,6 @@ export class PortfolioService {
const liabilities = const liabilities =
await portfolioCalculator.getLiabilitiesInBaseCurrency(); await portfolioCalculator.getLiabilitiesInBaseCurrency();
const valuables = await portfolioCalculator.getValuablesInBaseCurrency();
const totalBuy = this.getSumOfActivityType({ const totalBuy = this.getSumOfActivityType({
userCurrency, userCurrency,
activities: nonExcludedActivities, activities: nonExcludedActivities,
@ -1995,7 +1918,6 @@ export class PortfolioService {
const netWorth = new Big(balanceInBaseCurrency) const netWorth = new Big(balanceInBaseCurrency)
.plus(currentValueInBaseCurrency) .plus(currentValueInBaseCurrency)
.plus(valuables)
.plus(excludedAccountsAndActivities) .plus(excludedAccountsAndActivities)
.minus(liabilities) .minus(liabilities)
.toNumber(); .toNumber();
@ -2026,6 +1948,9 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
totalBuy, totalBuy,
totalSell, totalSell,
activityCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
committedFunds: committedFunds.toNumber(), committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
@ -2051,11 +1976,7 @@ export class PortfolioService {
.plus(fees) .plus(fees)
.toNumber(), .toNumber(),
interest: interest.toNumber(), interest: interest.toNumber(),
items: valuables.toNumber(),
liabilities: liabilities.toNumber(), liabilities: liabilities.toNumber(),
ordersCount: activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
totalInvestment: totalInvestment.toNumber(), totalInvestment: totalInvestment.toNumber(),
totalValueInBaseCurrency: netWorth totalValueInBaseCurrency: netWorth
}; };
@ -2143,14 +2064,14 @@ export class PortfolioService {
let currentAccounts: (Account & { let currentAccounts: (Account & {
Order?: Order[]; Order?: Order[];
Platform?: Platform; platform?: Platform;
})[] = []; })[] = [];
if (filters.length === 0) { if (filters.length === 0) {
currentAccounts = await this.accountService.getAccounts(userId); currentAccounts = await this.accountService.getAccounts(userId);
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') { } else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
currentAccounts = await this.accountService.accounts({ currentAccounts = await this.accountService.accounts({
include: { Platform: true }, include: { platform: true },
where: { id: filters[0].id } where: { id: filters[0].id }
}); });
} else { } else {
@ -2167,7 +2088,7 @@ export class PortfolioService {
); );
currentAccounts = await this.accountService.accounts({ currentAccounts = await this.accountService.accounts({
include: { Platform: true }, include: { platform: true },
where: { id: { in: accountIds } } where: { id: { in: accountIds } }
}); });
} }
@ -2192,18 +2113,18 @@ export class PortfolioService {
) )
}; };
if (platforms[account.Platform?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) {
platforms[account.Platform?.id || UNKNOWN_KEY].valueInBaseCurrency += platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency +=
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
account.balance, account.balance,
account.currency, account.currency,
userCurrency userCurrency
); );
} else { } else {
platforms[account.Platform?.id || UNKNOWN_KEY] = { platforms[account.platformId || UNKNOWN_KEY] = {
balance: account.balance, balance: account.balance,
currency: account.currency, currency: account.currency,
name: account.Platform?.name, name: account.platform?.name,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance, account.balance,
account.currency, account.currency,
@ -2213,7 +2134,7 @@ export class PortfolioService {
} }
for (const { for (const {
Account, account,
quantity, quantity,
SymbolProfile, SymbolProfile,
type type
@ -2224,28 +2145,28 @@ export class PortfolioService {
(portfolioItemsNow[SymbolProfile.symbol]?.marketPriceInBaseCurrency ?? (portfolioItemsNow[SymbolProfile.symbol]?.marketPriceInBaseCurrency ??
0); 0);
if (accounts[Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { if (accounts[account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
accounts[Account?.id || UNKNOWN_KEY].valueInBaseCurrency += accounts[account?.id || UNKNOWN_KEY].valueInBaseCurrency +=
currentValueOfSymbolInBaseCurrency; currentValueOfSymbolInBaseCurrency;
} else { } else {
accounts[Account?.id || UNKNOWN_KEY] = { accounts[account?.id || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: Account?.currency, currency: account?.currency,
name: account.name, name: account.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
}; };
} }
if ( if (
platforms[Account?.Platform?.id || UNKNOWN_KEY]?.valueInBaseCurrency platforms[account?.platformId || UNKNOWN_KEY]?.valueInBaseCurrency
) { ) {
platforms[Account?.Platform?.id || UNKNOWN_KEY].valueInBaseCurrency += platforms[account?.platformId || UNKNOWN_KEY].valueInBaseCurrency +=
currentValueOfSymbolInBaseCurrency; currentValueOfSymbolInBaseCurrency;
} else { } else {
platforms[Account?.Platform?.id || UNKNOWN_KEY] = { platforms[account?.platformId || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: Account?.currency, currency: account?.currency,
name: account.Platform?.name, name: account.platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
}; };
} }

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

@ -5,17 +5,28 @@ import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager'; import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import Keyv from 'keyv';
import ms from 'ms'; import ms from 'ms';
@Injectable() @Injectable()
export class RedisCacheService { export class RedisCacheService {
private client: Keyv;
public constructor( public constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache, @Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {
const client = cache.stores[0]; this.client = cache.stores[0];
this.client.deserialize = (value) => {
try {
return JSON.parse(value);
} catch {}
client.on('error', (error) => { return value;
};
this.client.on('error', (error) => {
Logger.error(error, 'RedisCacheService'); Logger.error(error, 'RedisCacheService');
}); });
} }
@ -28,28 +39,13 @@ export class RedisCacheService {
const keys: string[] = []; const keys: string[] = [];
const prefix = aPrefix; const prefix = aPrefix;
this.cache.stores[0].deserialize = (value) => { try {
try { for await (const [key] of this.client.iterator({})) {
return JSON.parse(value); if ((prefix && key.startsWith(prefix)) || !prefix) {
} catch (error: any) { keys.push(key);
if (error instanceof SyntaxError) {
Logger.debug(
`Failed to parse json, returning the value as String: ${value}`,
'RedisCacheService'
);
return value;
} else {
throw error;
} }
} }
}; } catch {}
for await (const [key] of this.cache.stores[0].iterator({})) {
if ((prefix && key.startsWith(prefix)) || !prefix) {
keys.push(key);
}
}
return keys; return keys;
} }
@ -79,20 +75,36 @@ export class RedisCacheService {
} }
public async isHealthy() { public async isHealthy() {
const testKey = '__health_check__';
const testValue = Date.now().toString();
try { try {
const isHealthy = await Promise.race([ await Promise.race([
this.getKeys(), (async () => {
await this.set(testKey, testValue, ms('1 second'));
const result = await this.get(testKey);
if (result !== testValue) {
throw new Error('Redis health check failed: value mismatch');
}
})(),
new Promise((_, reject) => new Promise((_, reject) =>
setTimeout( setTimeout(
() => reject(new Error('Redis health check timeout')), () => reject(new Error('Redis health check failed: timeout')),
ms('2 seconds') ms('2 seconds')
) )
) )
]); ]);
return isHealthy === 'PONG'; return true;
} catch (error) { } catch (error) {
Logger.error(error?.message, 'RedisCacheService');
return false; return false;
} finally {
try {
await this.remove(testKey);
} catch {}
} }
} }

80
apps/api/src/app/sitemap/sitemap.controller.ts

@ -1,80 +0,0 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DATE_FORMAT,
getYesterday,
interpolate
} from '@ghostfolio/common/helper';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
@Controller('sitemap.xml')
export class SitemapController {
public sitemapXml = '';
public constructor(
private readonly configurationService: ConfigurationService
) {
try {
this.sitemapXml = readFileSync(
join(__dirname, 'assets', 'sitemap.xml'),
'utf8'
);
} catch {}
}
@Get()
@Version(VERSION_NEUTRAL)
public async getSitemapXml(@Res() response: Response): Promise<void> {
const currentDate = format(getYesterday(), DATE_FORMAT);
response.setHeader('content-type', 'application/xml');
response.send(
interpolate(this.sitemapXml, {
currentDate,
personalFinanceTools: this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? personalFinanceTools
.map(({ alias, key }) => {
return [
'<url>',
` <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/es/recursos/personal-finance-tools/alternativa-de-software-libre-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/fr/ressources/personal-finance-tools/alternative-open-source-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/pt/recursos/personal-finance-tools/alternativa-de-software-livre-ao-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>'
].join('\n');
})
.join('\n')
: ''
})
);
}
}

3
apps/api/src/app/subscription/subscription.controller.ts

@ -49,8 +49,7 @@ export class SubscriptionController {
} }
let coupons = let coupons =
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ?? (await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS)) ?? [];
[];
const coupon = coupons.find((currentCoupon) => { const coupon = coupons.find((currentCoupon) => {
return currentCoupon.code === couponCode; return currentCoupon.code === couponCode;

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

@ -32,7 +32,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: '2024-09-30.acacia' apiVersion: '2025-05-28.basil'
} }
); );
} }
@ -50,8 +50,7 @@ export class SubscriptionService {
const subscriptionOffers: { const subscriptionOffers: {
[offer in SubscriptionOfferKey]: SubscriptionOffer; [offer in SubscriptionOfferKey]: SubscriptionOffer;
} = } =
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ?? (await this.propertyService.getByKey<any>(PROPERTY_STRIPE_CONFIG)) ?? {};
{};
const subscriptionOffer = Object.values(subscriptionOffers).find( const subscriptionOffer = Object.values(subscriptionOffers).find(
(subscriptionOffer) => { (subscriptionOffer) => {
@ -61,7 +60,7 @@ export class SubscriptionService {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = { const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/${ cancel_url: `${this.configurationService.get('ROOT_URL')}/${
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE user.Settings.settings.language
}/account`, }/account`,
client_reference_id: user.id, client_reference_id: user.id,
line_items: [ line_items: [
@ -213,8 +212,7 @@ export class SubscriptionService {
const offers: { const offers: {
[offer in SubscriptionOfferKey]: SubscriptionOffer; [offer in SubscriptionOfferKey]: SubscriptionOffer;
} = } =
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ?? (await this.propertyService.getByKey<any>(PROPERTY_STRIPE_CONFIG)) ?? {};
{};
return { return {
...offers[key], ...offers[key],

6
apps/api/src/app/user/update-own-access-token.dto.ts

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class UpdateOwnAccessTokenDto {
@IsString()
accessToken: string;
}

84
apps/api/src/app/user/user.controller.ts

@ -33,6 +33,7 @@ import { merge, size } from 'lodash';
import { DeleteOwnUserDto } from './delete-own-user.dto'; import { DeleteOwnUserDto } from './delete-own-user.dto';
import { UserItem } from './interfaces/user-item.interface'; import { UserItem } from './interfaces/user-item.interface';
import { UpdateOwnAccessTokenDto } from './update-own-access-token.dto';
import { UpdateUserSettingDto } from './update-user-setting.dto'; import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UserService } from './user.service'; import { UserService } from './user.service';
@ -53,24 +54,12 @@ export class UserController {
public async deleteOwnUser( public async deleteOwnUser(
@Body() data: DeleteOwnUserDto @Body() data: DeleteOwnUserDto
): Promise<UserModel> { ): Promise<UserModel> {
const hashedAccessToken = this.userService.createAccessToken({ const user = await this.validateAccessToken(
password: data.accessToken, data.accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT') this.request.user.id
}); );
const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken, id: this.request.user.id }
});
if (!user) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.userService.deleteUser({ return this.userService.deleteUser({
accessToken: hashedAccessToken,
id: user.id id: user.id
}); });
} }
@ -94,20 +83,24 @@ export class UserController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post(':id/access-token') @Post(':id/access-token')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async generateAccessToken( public async updateUserAccessToken(
@Param('id') id: string @Param('id') id: string
): Promise<AccessTokenResponse> { ): Promise<AccessTokenResponse> {
const { accessToken, hashedAccessToken } = return this.rotateUserAccessToken(id);
this.userService.generateAccessToken({ }
userId: id
});
await this.prismaService.user.update({ @HasPermission(permissions.updateOwnAccessToken)
data: { accessToken: hashedAccessToken }, @Post('access-token')
where: { id } @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
}); public async updateOwnAccessToken(
@Body() data: UpdateOwnAccessTokenDto
): Promise<AccessTokenResponse> {
const user = await this.validateAccessToken(
data.accessToken,
this.request.user.id
);
return { accessToken }; return this.rotateUserAccessToken(user.id);
} }
@Get() @Get()
@ -189,4 +182,43 @@ export class UserController {
userId: this.request.user.id userId: this.request.user.id
}); });
} }
private async rotateUserAccessToken(
userId: string
): Promise<AccessTokenResponse> {
const { accessToken, hashedAccessToken } =
this.userService.generateAccessToken({
userId
});
await this.prismaService.user.update({
data: { accessToken: hashedAccessToken },
where: { id: userId }
});
return { accessToken };
}
private async validateAccessToken(
accessToken: string,
userId: string
): Promise<UserModel> {
const hashedAccessToken = this.userService.createAccessToken({
password: accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
});
const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken, id: userId }
});
if (!user) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return user;
}
} }

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

@ -1,6 +1,7 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -16,6 +17,7 @@ import { UserService } from './user.service';
exports: [UserService], exports: [UserService],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
I18nModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }

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

@ -52,11 +52,10 @@ import { sortBy, without } from 'lodash';
@Injectable() @Injectable()
export class UserService { export class UserService {
private i18nService = new I18nService();
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly i18nService: I18nService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -96,16 +95,16 @@ export class UserService {
} }
public async getUser( public async getUser(
{ Account, id, permissions, Settings, subscription }: UserWithSettings, { accounts, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale aLocale = locale
): Promise<IUser> { ): Promise<IUser> {
const userData = await Promise.all([ const userData = await Promise.all([
this.prismaService.access.findMany({ this.prismaService.access.findMany({
include: { include: {
User: true user: true
}, },
orderBy: { alias: 'asc' }, orderBy: { alias: 'asc' },
where: { GranteeUser: { id } } where: { granteeUserId: id }
}), }),
this.prismaService.order.count({ this.prismaService.order.count({
where: { userId: id } where: { userId: id }
@ -126,9 +125,10 @@ export class UserService {
let systemMessage: SystemMessage; let systemMessage: SystemMessage;
const systemMessageProperty = (await this.propertyService.getByKey( const systemMessageProperty =
PROPERTY_SYSTEM_MESSAGE await this.propertyService.getByKey<SystemMessage>(
)) as SystemMessage; PROPERTY_SYSTEM_MESSAGE
);
if (systemMessageProperty?.targetGroups?.includes(subscription?.type)) { if (systemMessageProperty?.targetGroups?.includes(subscription?.type)) {
systemMessage = systemMessageProperty; systemMessage = systemMessageProperty;
@ -142,6 +142,7 @@ export class UserService {
} }
return { return {
accounts,
activitiesCount, activitiesCount,
id, id,
permissions, permissions,
@ -155,7 +156,6 @@ export class UserService {
permissions: accessItem.permissions permissions: accessItem.permissions
}; };
}), }),
accounts: Account,
dateOfFirstActivity: firstActivity?.date ?? new Date(), dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: { settings: {
...(Settings.settings as UserSettings), ...(Settings.settings as UserSettings),
@ -180,10 +180,10 @@ export class UserService {
userWhereUniqueInput: Prisma.UserWhereUniqueInput userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> { ): Promise<UserWithSettings | null> {
const { const {
Access, accessesGet,
accessToken, accessToken,
Account, accounts,
Analytics, analytics,
authChallenge, authChallenge,
createdAt, createdAt,
id, id,
@ -195,11 +195,11 @@ export class UserService {
updatedAt updatedAt
} = await this.prismaService.user.findUnique({ } = await this.prismaService.user.findUnique({
include: { include: {
Access: true, accessesGet: true,
Account: { accounts: {
include: { Platform: true } include: { platform: true }
}, },
Analytics: true, analytics: true,
Settings: true, Settings: true,
subscriptions: true subscriptions: true
}, },
@ -207,9 +207,9 @@ export class UserService {
}); });
const user: UserWithSettings = { const user: UserWithSettings = {
Access, accessesGet,
accessToken, accessToken,
Account, accounts,
authChallenge, authChallenge,
createdAt, createdAt,
id, id,
@ -218,9 +218,9 @@ export class UserService {
Settings: Settings as UserWithSettings['Settings'], Settings: Settings as UserWithSettings['Settings'],
thirdPartyId, thirdPartyId,
updatedAt, updatedAt,
activityCount: Analytics?.activityCount, activityCount: analytics?.activityCount,
dataProviderGhostfolioDailyRequests: dataProviderGhostfolioDailyRequests:
Analytics?.dataProviderGhostfolioDailyRequests analytics?.dataProviderGhostfolioDailyRequests
}; };
if (user?.Settings) { if (user?.Settings) {
@ -260,28 +260,41 @@ export class UserService {
(user.Settings.settings as UserSettings).xRayRules = { (user.Settings.settings as UserSettings).xRayRules = {
AccountClusterRiskCurrentInvestment: AccountClusterRiskCurrentInvestment:
new AccountClusterRiskCurrentInvestment(undefined, {}).getSettings( new AccountClusterRiskCurrentInvestment(
user.Settings.settings undefined,
), undefined,
undefined,
{}
).getSettings(user.Settings.settings),
AccountClusterRiskSingleAccount: new AccountClusterRiskSingleAccount( AccountClusterRiskSingleAccount: new AccountClusterRiskSingleAccount(
undefined,
undefined,
undefined, undefined,
{} {}
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
AssetClassClusterRiskEquity: new AssetClassClusterRiskEquity( AssetClassClusterRiskEquity: new AssetClassClusterRiskEquity(
undefined,
undefined,
undefined, undefined,
undefined undefined
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
AssetClassClusterRiskFixedIncome: new AssetClassClusterRiskFixedIncome( AssetClassClusterRiskFixedIncome: new AssetClassClusterRiskFixedIncome(
undefined,
undefined,
undefined, undefined,
undefined undefined
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
CurrencyClusterRiskBaseCurrencyCurrentInvestment: CurrencyClusterRiskBaseCurrencyCurrentInvestment:
new CurrencyClusterRiskBaseCurrencyCurrentInvestment( new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
undefined,
undefined,
undefined, undefined,
undefined undefined
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
CurrencyClusterRiskCurrentInvestment: CurrencyClusterRiskCurrentInvestment:
new CurrencyClusterRiskCurrentInvestment( new CurrencyClusterRiskCurrentInvestment(
undefined,
undefined,
undefined, undefined,
undefined undefined
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
@ -298,10 +311,14 @@ export class UserService {
undefined undefined
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
EmergencyFundSetup: new EmergencyFundSetup( EmergencyFundSetup: new EmergencyFundSetup(
undefined,
undefined,
undefined, undefined,
undefined undefined
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
FeeRatioInitialInvestment: new FeeRatioInitialInvestment( FeeRatioInitialInvestment: new FeeRatioInitialInvestment(
undefined,
undefined,
undefined, undefined,
undefined, undefined,
undefined undefined
@ -338,6 +355,11 @@ export class UserService {
let currentPermissions = getPermissions(user.role); let currentPermissions = getPermissions(user.role);
if (user.provider === 'ANONYMOUS') {
currentPermissions.push(permissions.deleteOwnUser);
currentPermissions.push(permissions.updateOwnAccessToken);
}
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) { if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
// currentPermissions = without( // currentPermissions = without(
// currentPermissions, // currentPermissions,
@ -372,7 +394,7 @@ export class UserService {
frequency = 6; frequency = 6;
} }
if (Analytics?.activityCount % frequency === 1) { if (analytics?.activityCount % frequency === 1) {
currentPermissions.push(permissions.enableSubscriptionInterstitial); currentPermissions.push(permissions.enableSubscriptionInterstitial);
} }
@ -397,6 +419,7 @@ export class UserService {
if (!hasRole(user, Role.DEMO)) { if (!hasRole(user, Role.DEMO)) {
currentPermissions.push(permissions.createApiKey); currentPermissions.push(permissions.createApiKey);
currentPermissions.push(permissions.enableDataProviderGhostfolio); currentPermissions.push(permissions.enableDataProviderGhostfolio);
currentPermissions.push(permissions.readMarketDataOfMarkets);
currentPermissions.push(permissions.reportDataGlitch); currentPermissions.push(permissions.reportDataGlitch);
} }
@ -411,6 +434,10 @@ export class UserService {
user.subscription.offer.durationExtension = undefined; user.subscription.offer.durationExtension = undefined;
user.subscription.offer.label = undefined; user.subscription.offer.label = undefined;
} }
if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.syncDemoUserAccount);
}
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
@ -418,9 +445,9 @@ export class UserService {
currentPermissions.push(permissions.toggleReadOnlyMode); currentPermissions.push(permissions.toggleReadOnlyMode);
} }
const isReadOnlyMode = (await this.propertyService.getByKey( const isReadOnlyMode = await this.propertyService.getByKey<boolean>(
PROPERTY_IS_READ_ONLY_MODE PROPERTY_IS_READ_ONLY_MODE
)) as boolean; );
if (isReadOnlyMode) { if (isReadOnlyMode) {
currentPermissions = currentPermissions.filter((permission) => { currentPermissions = currentPermissions.filter((permission) => {
@ -433,11 +460,11 @@ export class UserService {
} }
} }
if (!environment.production && role === 'ADMIN') { if (!environment.production && hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.impersonateAllUsers); currentPermissions.push(permissions.impersonateAllUsers);
} }
user.Account = sortBy(user.Account, ({ name }) => { user.accounts = sortBy(user.accounts, ({ name }) => {
return name.toLowerCase(); return name.toLowerCase();
}); });
user.permissions = currentPermissions.sort(); user.permissions = currentPermissions.sort();
@ -474,7 +501,7 @@ export class UserService {
const user = await this.prismaService.user.create({ const user = await this.prismaService.user.create({
data: { data: {
...data, ...data,
Account: { accounts: {
create: { create: {
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
name: this.i18nService.getTranslation({ name: this.i18nService.getTranslation({
@ -496,7 +523,7 @@ export class UserService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
await this.prismaService.analytics.create({ await this.prismaService.analytics.create({
data: { data: {
User: { connect: { id: user.id } } user: { connect: { id: user.id } }
} }
}); });
} }
@ -591,7 +618,7 @@ export class UserService {
const { settings } = await this.prismaService.settings.upsert({ const { settings } = await this.prismaService.settings.upsert({
create: { create: {
settings: userSettings as unknown as Prisma.JsonObject, settings: userSettings as unknown as Prisma.JsonObject,
User: { user: {
connect: { connect: {
id: userId id: userId
} }

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

File diff suppressed because it is too large

591
apps/api/src/assets/sitemap.xml

@ -4,595 +4,6 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<!-- ${publicRoutes}
<url>
<loc>https://ghostfol.io/ca</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<url>
<loc>https://ghostfol.io/de</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/features</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/haeufig-gestellte-fragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/maerkte</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/preise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/registrierung</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/lexikon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/maerkte</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/ratgeber</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/datenschutzbestimmungen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/lizenz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/development/storybook</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/license</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/how-do-i-get-my-finances-in-order</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/08/500-stars-on-github</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/10/hacktoberfest-2022</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/11/black-friday-2022</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/12/the-importance-of-tracking-your-personal-finances</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/02/ghostfolio-meets-umbrel</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/08/ghostfolio-joins-oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/11/black-week-2023</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/11/hacktoberfest-2023-debriefing</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2024/09/hacktoberfest-2024</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2024/11/black-weeks-2024</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/saas</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/self-hosting</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/features</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/markets</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/pricing</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/register</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/glossary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/guides</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/markets</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/funcionalidades</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/mercados</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/precios</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/preguntas-mas-frecuentes</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/recursos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/recursos/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/registro</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/licencia</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/licence</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/enregistrement</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/fonctionnalites</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/foire-aux-questions</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/marches</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/prix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/ressources</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/ressources/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/domande-piu-frequenti</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/funzionalita</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/licenza</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/iscrizione</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/mercati</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/prezzi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/functionaliteiten</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/markten</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/licentie</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/prijzen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/registratie</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/cennik</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<!--
<url>
<loc>https://ghostfol.io/pl/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<url>
<loc>https://ghostfol.io/pl/funkcje</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/o-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/open</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/rynki</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/zarejestruj</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/funcionalidades</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/mercados</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/open</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/perguntas-mais-frequentes</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/precos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/recursos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/recursos/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/registo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/licenca</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/tr</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<!--
<url>
<loc>https://ghostfol.io/uk</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<url>
<loc>https://ghostfol.io/zh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
${personalFinanceTools} ${personalFinanceTools}
</urlset> </urlset>

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

@ -1515,6 +1515,7 @@ describe('redactAttributes', () => {
} }
}, },
summary: { summary: {
activityCount: 29,
annualizedPerformancePercent: 0.16690880197786, annualizedPerformancePercent: 0.16690880197786,
annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876, annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876,
cash: null, cash: null,
@ -1538,7 +1539,6 @@ describe('redactAttributes', () => {
interest: null, interest: null,
items: null, items: null,
liabilities: null, liabilities: null,
ordersCount: 29,
totalInvestment: null, totalInvestment: null,
totalValueInBaseCurrency: null, totalValueInBaseCurrency: null,
currentNetWorth: null currentNetWorth: null
@ -3018,6 +3018,7 @@ describe('redactAttributes', () => {
} }
}, },
summary: { summary: {
activityCount: 29,
annualizedPerformancePercent: 0.16690880197786, annualizedPerformancePercent: 0.16690880197786,
annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876, annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876,
cash: null, cash: null,
@ -3041,7 +3042,6 @@ describe('redactAttributes', () => {
interest: null, interest: null,
items: null, items: null,
liabilities: null, liabilities: null,
ordersCount: 29,
totalInvestment: null, totalInvestment: null,
totalValueInBaseCurrency: null, totalValueInBaseCurrency: null,
currentNetWorth: null currentNetWorth: null

11
apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts

@ -43,7 +43,16 @@ export class TransformDataSourceInRequestInterceptor<T>
const dataSourceValue = request[type]?.dataSource; const dataSourceValue = request[type]?.dataSource;
if (dataSourceValue && !DataSource[dataSourceValue]) { if (dataSourceValue && !DataSource[dataSourceValue]) {
request[type].dataSource = decodeDataSource(dataSourceValue); // In Express 5, request.query is read-only, so request[type].dataSource cannot be directly modified
Object.defineProperty(request, type, {
configurable: true,
enumerable: true,
value: {
...request[type],
dataSource: decodeDataSource(dataSourceValue)
},
writable: true
});
} }
} }
} }

16
apps/api/src/main.ts

@ -1,7 +1,8 @@
import { import {
DEFAULT_HOST, DEFAULT_HOST,
DEFAULT_PORT, DEFAULT_PORT,
STORYBOOK_PATH STORYBOOK_PATH,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
@ -18,7 +19,6 @@ import helmet from 'helmet';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
import { HtmlTemplateMiddleware } from './middlewares/html-template.middleware';
async function bootstrap() { async function bootstrap() {
const configApp = await NestFactory.create(AppModule); const configApp = await NestFactory.create(AppModule);
@ -44,7 +44,15 @@ async function bootstrap() {
defaultVersion: '1', defaultVersion: '1',
type: VersioningType.URI type: VersioningType.URI
}); });
app.setGlobalPrefix('api', { exclude: ['sitemap.xml'] }); app.setGlobalPrefix('api', {
exclude: [
'sitemap.xml',
...SUPPORTED_LANGUAGE_CODES.map((languageCode) => {
// Exclude language-specific routes with an optional wildcard
return `/${languageCode}{/*wildcard}`;
})
]
});
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidNonWhitelisted: true, forbidNonWhitelisted: true,
@ -77,8 +85,6 @@ async function bootstrap() {
}); });
} }
app.use(HtmlTemplateMiddleware);
const HOST = configService.get<string>('HOST') || DEFAULT_HOST; const HOST = configService.get<string>('HOST') || DEFAULT_HOST;
const PORT = configService.get<number>('PORT') || DEFAULT_PORT; const PORT = configService.get<number>('PORT') || DEFAULT_PORT;

158
apps/api/src/middlewares/html-template.middleware.ts

@ -7,30 +7,14 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import * as fs from 'fs'; import * as fs from 'fs';
import { join } from 'path'; import { join } from 'path';
const i18nService = new I18nService();
let indexHtmlMap: { [languageCode: string]: string } = {};
const title = 'Ghostfolio'; const title = 'Ghostfolio';
try {
indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({
...map,
[languageCode]: fs.readFileSync(
join(__dirname, '..', 'client', languageCode, 'index.html'),
'utf8'
)
}),
{}
);
} catch {}
const locales = { const locales = {
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': { '/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png', featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png',
@ -94,71 +78,93 @@ const locales = {
} }
}; };
const isFileRequest = (filename: string) => { @Injectable()
if (filename === '/assets/LICENSE') { export class HtmlTemplateMiddleware implements NestMiddleware {
return true; private indexHtmlMap: { [languageCode: string]: string } = {};
} else if (
filename.includes('auth/ey') ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-de.fi'
) ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-markets.sh'
)
) {
return false;
}
return filename.split('.').pop() !== filename; public constructor(private readonly i18nService: I18nService) {
}; try {
this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({
...map,
[languageCode]: fs.readFileSync(
join(__dirname, '..', 'client', languageCode, 'index.html'),
'utf8'
)
}),
{}
);
} catch (error) {
Logger.error(
'Failed to initialize index HTML map',
error,
'HTMLTemplateMiddleware'
);
}
}
export const HtmlTemplateMiddleware = async ( public use(request: Request, response: Response, next: NextFunction) {
request: Request, const path = request.originalUrl.replace(/\/$/, '');
response: Response, let languageCode = path.substr(1, 2);
next: NextFunction
) => {
const path = request.originalUrl.replace(/\/$/, '');
let languageCode = path.substr(1, 2);
if (!SUPPORTED_LANGUAGE_CODES.includes(languageCode)) { if (!SUPPORTED_LANGUAGE_CODES.includes(languageCode)) {
languageCode = DEFAULT_LANGUAGE_CODE; languageCode = DEFAULT_LANGUAGE_CODE;
} }
const currentDate = format(new Date(), DATE_FORMAT); const currentDate = format(new Date(), DATE_FORMAT);
const rootUrl = process.env.ROOT_URL || environment.rootUrl; const rootUrl = process.env.ROOT_URL || environment.rootUrl;
if ( if (
path.startsWith('/api/') || path.startsWith('/api/') ||
path.startsWith(STORYBOOK_PATH) || path.startsWith(STORYBOOK_PATH) ||
isFileRequest(path) || this.isFileRequest(path) ||
!environment.production !environment.production
) { ) {
// Skip // Skip
next(); next();
} else { } else {
const indexHtml = interpolate(indexHtmlMap[languageCode], { const indexHtml = interpolate(this.indexHtmlMap[languageCode], {
currentDate, currentDate,
languageCode,
path,
rootUrl,
description: i18nService.getTranslation({
languageCode, languageCode,
id: 'metaDescription' path,
}), rootUrl,
featureGraphicPath: description: this.i18nService.getTranslation({
locales[path]?.featureGraphicPath ?? 'assets/cover.png', languageCode,
keywords: i18nService.getTranslation({ id: 'metaDescription'
languageCode, }),
id: 'metaKeywords' featureGraphicPath:
}), locales[path]?.featureGraphicPath ?? 'assets/cover.png',
title: keywords: this.i18nService.getTranslation({
locales[path]?.title ??
`${title}${i18nService.getTranslation({
languageCode, languageCode,
id: 'slogan' id: 'metaKeywords'
})}` }),
}); title:
locales[path]?.title ??
`${title}${this.i18nService.getTranslation({
languageCode,
id: 'slogan'
})}`
});
return response.send(indexHtml); return response.send(indexHtml);
}
} }
};
private isFileRequest(filename: string) {
if (filename === '/assets/LICENSE') {
return true;
} else if (
filename.includes('auth/ey') ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-de.fi'
) ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-markets.sh'
)
) {
return false;
}
return filename.split('.').pop() !== filename;
}
}

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

@ -1,5 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
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 { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { groupBy } from '@ghostfolio/common/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { import {
PortfolioPosition, PortfolioPosition,
@ -14,28 +15,28 @@ import { RuleInterface } from './interfaces/rule.interface';
export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> { export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
private key: string; private key: string;
private name: string; private languageCode: string;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
{ {
key, key,
name languageCode = DEFAULT_LANGUAGE_CODE
}: { }: {
key: string; key: string;
name: string; languageCode?: string; // TODO: Make mandatory
} }
) { ) {
this.key = key; this.key = key;
this.name = name; this.languageCode = languageCode;
} }
public getKey() { public getKey() {
return this.key; return this.key;
} }
public getName() { public getLanguageCode() {
return this.name; return this.languageCode;
} }
public groupCurrentHoldingsByAttribute( public groupCurrentHoldingsByAttribute(
@ -73,5 +74,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
PortfolioReportRule['configuration'] PortfolioReportRule['configuration']
>; >;
public abstract getName(): string;
public abstract getSettings(aUserSettings: UserSettings): T; public abstract getSettings(aUserSettings: UserSettings): T;
} }

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

@ -1,22 +1,23 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
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 { import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
PortfolioDetails, import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
PortfolioPosition,
UserSettings import { Account } from '@prisma/client';
} from '@ghostfolio/common/interfaces';
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> { export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
private accounts: PortfolioDetails['accounts']; private accounts: PortfolioDetails['accounts'];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: AccountClusterRiskCurrentInvestment.name, languageCode,
name: 'Investment' key: AccountClusterRiskCurrentInvestment.name
}); });
this.accounts = accounts; this.accounts = accounts;
@ -24,54 +25,62 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {
const accounts: { const accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { [symbol: string]: Pick<Account, 'name'> & {
investment: number; investment: number;
}; };
} = {}; } = {};
for (const [accountId, account] of Object.entries(this.accounts)) { for (const [accountId, account] of Object.entries(this.accounts)) {
accounts[accountId] = { accounts[accountId] = {
name: account.name, investment: account.valueInBaseCurrency,
investment: account.valueInBaseCurrency name: account.name
}; };
} }
let maxItem: (typeof accounts)[0]; let maxAccount: (typeof accounts)[0];
let totalInvestment = 0; let totalInvestment = 0;
for (const account of Object.values(accounts)) { for (const account of Object.values(accounts)) {
if (!maxItem) { if (!maxAccount) {
maxItem = account; maxAccount = account;
} }
// Calculate total investment // Calculate total investment
totalInvestment += account.investment; totalInvestment += account.investment;
// Find maximum // Find maximum
if (account.investment > maxItem?.investment) { if (account.investment > maxAccount?.investment) {
maxItem = account; maxAccount = account;
} }
} }
const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0; const maxInvestmentRatio = maxAccount?.investment / totalInvestment || 0;
if (maxInvestmentRatio > ruleSettings.thresholdMax) { if (maxInvestmentRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: `Over ${ evaluation: this.i18nService.getTranslation({
ruleSettings.thresholdMax * 100 id: 'rule.accountClusterRiskCurrentInvestment.false',
}% of your current investment is at ${maxItem.name} (${( languageCode: this.getLanguageCode(),
maxInvestmentRatio * 100 placeholders: {
).toPrecision(3)}%)`, maxAccountName: maxAccount.name,
maxInvestmentRatio: (maxInvestmentRatio * 100).toPrecision(3),
thresholdMax: ruleSettings.thresholdMax * 100
}
}),
value: false value: false
}; };
} }
return { return {
evaluation: `The major part of your current investment is at ${ evaluation: this.i18nService.getTranslation({
maxItem.name id: 'rule.accountClusterRiskCurrentInvestment.true',
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${ languageCode: this.getLanguageCode(),
ruleSettings.thresholdMax * 100 placeholders: {
}%`, maxAccountName: maxAccount.name,
maxInvestmentRatio: (maxInvestmentRatio * 100).toPrecision(3),
thresholdMax: ruleSettings.thresholdMax * 100
}
}),
value: true value: true
}; };
} }
@ -88,6 +97,13 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getName() {
return this.i18nService.getTranslation({
id: 'rule.accountClusterRiskCurrentInvestment',
languageCode: this.getLanguageCode()
});
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

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

@ -1,6 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces'; import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> { export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
@ -8,11 +9,13 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: AccountClusterRiskSingleAccount.name, languageCode,
name: 'Single Account' key: AccountClusterRiskSingleAccount.name
}); });
this.accounts = accounts; this.accounts = accounts;
@ -23,13 +26,22 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
if (accounts.length === 1) { if (accounts.length === 1) {
return { return {
evaluation: `Your net worth is managed by a single account`, evaluation: this.i18nService.getTranslation({
id: 'rule.accountClusterRiskSingleAccount.false',
languageCode: this.getLanguageCode()
}),
value: false value: false
}; };
} }
return { return {
evaluation: `Your net worth is managed by ${accounts.length} accounts`, evaluation: this.i18nService.getTranslation({
id: 'rule.accountClusterRiskSingleAccount.true',
languageCode: this.getLanguageCode(),
placeholders: {
accountsLength: accounts.length
}
}),
value: true value: true
}; };
} }
@ -38,6 +50,14 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
return undefined; return undefined;
} }
public getName() {
return this.i18nService.getTranslation({
id: 'rule.accountClusterRiskSingleAccount',
languageCode: this.getLanguageCode()
});
return 'Single Account';
}
public getSettings({ xRayRules }: UserSettings): RuleSettings { public getSettings({ xRayRules }: UserSettings): RuleSettings {
return { return {
isActive: xRayRules?.[this.getKey()].isActive ?? true isActive: xRayRules?.[this.getKey()].isActive ?? true

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

@ -1,6 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
export class AssetClassClusterRiskEquity extends Rule<Settings> { export class AssetClassClusterRiskEquity extends Rule<Settings> {
@ -8,11 +9,13 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
holdings: PortfolioPosition[] holdings: PortfolioPosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: AssetClassClusterRiskEquity.name, languageCode,
name: 'Equity' key: AssetClassClusterRiskEquity.name
}); });
this.holdings = holdings; this.holdings = holdings;
@ -41,26 +44,39 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
if (equityValueRatio > ruleSettings.thresholdMax) { if (equityValueRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) exceeds ${( evaluation: this.i18nService.getTranslation({
ruleSettings.thresholdMax * 100 id: 'rule.assetClassClusterRiskEquity.false.max',
).toPrecision(3)}%`, languageCode: this.getLanguageCode(),
placeholders: {
equityValueRatio: (equityValueRatio * 100).toPrecision(3),
thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3)
}
}),
value: false value: false
}; };
} else if (equityValueRatio < ruleSettings.thresholdMin) { } else if (equityValueRatio < ruleSettings.thresholdMin) {
return { return {
evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) is below ${( evaluation: this.i18nService.getTranslation({
ruleSettings.thresholdMin * 100 id: 'rule.assetClassClusterRiskEquity.false.min',
).toPrecision(3)}%`, languageCode: this.getLanguageCode(),
placeholders: {
equityValueRatio: (equityValueRatio * 100).toPrecision(3),
thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3)
}
}),
value: false value: false
}; };
} }
return { return {
evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) is within the range of ${( evaluation: this.i18nService.getTranslation({
ruleSettings.thresholdMin * 100 id: 'rule.assetClassClusterRiskEquity.true',
).toPrecision( languageCode: this.getLanguageCode(),
3 placeholders: {
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`, equityValueRatio: (equityValueRatio * 100).toPrecision(3),
thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3),
thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3)
}
}),
value: true value: true
}; };
} }
@ -78,6 +94,13 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
}; };
} }
public getName() {
return this.i18nService.getTranslation({
id: 'rule.assetClassClusterRiskEquity',
languageCode: this.getLanguageCode()
});
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

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

@ -1,6 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
export class AssetClassClusterRiskFixedIncome extends Rule<Settings> { export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
@ -8,11 +9,13 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
holdings: PortfolioPosition[] holdings: PortfolioPosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: AssetClassClusterRiskFixedIncome.name, languageCode,
name: 'Fixed Income' key: AssetClassClusterRiskFixedIncome.name
}); });
this.holdings = holdings; this.holdings = holdings;
@ -41,26 +44,39 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
if (fixedIncomeValueRatio > ruleSettings.thresholdMax) { if (fixedIncomeValueRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) exceeds ${( evaluation: this.i18nService.getTranslation({
ruleSettings.thresholdMax * 100 id: 'rule.assetClassClusterRiskFixedIncome.false.max',
).toPrecision(3)}%`, languageCode: this.getLanguageCode(),
placeholders: {
fixedIncomeValueRatio: (fixedIncomeValueRatio * 100).toPrecision(3),
thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3)
}
}),
value: false value: false
}; };
} else if (fixedIncomeValueRatio < ruleSettings.thresholdMin) { } else if (fixedIncomeValueRatio < ruleSettings.thresholdMin) {
return { return {
evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) is below ${( evaluation: this.i18nService.getTranslation({
ruleSettings.thresholdMin * 100 id: 'rule.assetClassClusterRiskFixedIncome.false.min',
).toPrecision(3)}%`, languageCode: this.getLanguageCode(),
placeholders: {
fixedIncomeValueRatio: (fixedIncomeValueRatio * 100).toPrecision(3),
thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3)
}
}),
value: false value: false
}; };
} }
return { return {
evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) is within the range of ${( evaluation: this.i18nService.getTranslation({
ruleSettings.thresholdMin * 100 id: 'rule.assetClassClusterRiskFixedIncome.true',
).toPrecision( languageCode: this.getLanguageCode(),
3 placeholders: {
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`, fixedIncomeValueRatio: (fixedIncomeValueRatio * 100).toPrecision(3),
thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3),
thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3)
}
}),
value: true value: true
}; };
} }
@ -78,6 +94,13 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
}; };
} }
public getName() {
return this.i18nService.getTranslation({
id: 'rule.assetClassClusterRiskFixedIncome',
languageCode: this.getLanguageCode()
});
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

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

@ -1,6 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
@ -8,11 +9,13 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
holdings: PortfolioPosition[] private i18nService: I18nService,
holdings: PortfolioPosition[],
languageCode: string
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name, key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name,
name: 'Investment: Base Currency' languageCode
}); });
this.holdings = holdings; this.holdings = holdings;
@ -49,17 +52,29 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
if (maxItem?.groupKey !== ruleSettings.baseCurrency) { if (maxItem?.groupKey !== ruleSettings.baseCurrency) {
return { return {
evaluation: `The major part of your current investment is not in your base currency (${( evaluation: this.i18nService.getTranslation({
baseCurrencyValueRatio * 100 id: 'rule.currencyClusterRiskBaseCurrencyCurrentInvestment.false',
).toPrecision(3)}% in ${ruleSettings.baseCurrency})`, languageCode: this.getLanguageCode(),
placeholders: {
baseCurrency: ruleSettings.baseCurrency,
baseCurrencyValueRatio: (baseCurrencyValueRatio * 100).toPrecision(
3
)
}
}),
value: false value: false
}; };
} }
return { return {
evaluation: `The major part of your current investment is in your base currency (${( evaluation: this.i18nService.getTranslation({
baseCurrencyValueRatio * 100 id: 'rule.currencyClusterRiskBaseCurrencyCurrentInvestment.true',
).toPrecision(3)}% in ${ruleSettings.baseCurrency})`, languageCode: this.getLanguageCode(),
placeholders: {
baseCurrency: ruleSettings.baseCurrency,
baseCurrencyValueRatio: (baseCurrencyValueRatio * 100).toPrecision(3)
}
}),
value: true value: true
}; };
} }
@ -68,6 +83,13 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
return undefined; return undefined;
} }
public getName() {
return this.i18nService.getTranslation({
id: 'rule.currencyClusterRiskBaseCurrencyCurrentInvestment',
languageCode: this.getLanguageCode()
});
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

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

@ -1,6 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
@ -8,11 +9,13 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
holdings: PortfolioPosition[] private i18nService: I18nService,
holdings: PortfolioPosition[],
languageCode: string
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: CurrencyClusterRiskCurrentInvestment.name, key: CurrencyClusterRiskCurrentInvestment.name,
name: 'Investment' languageCode
}); });
this.holdings = holdings; this.holdings = holdings;
@ -42,21 +45,29 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
if (maxValueRatio > ruleSettings.thresholdMax) { if (maxValueRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: `Over ${ evaluation: this.i18nService.getTranslation({
ruleSettings.thresholdMax * 100 id: 'rule.currencyClusterRiskCurrentInvestment.false',
}% of your current investment is in ${maxItem.groupKey} (${( languageCode: this.getLanguageCode(),
maxValueRatio * 100 placeholders: {
).toPrecision(3)}%)`, currency: maxItem.groupKey as string,
maxValueRatio: (maxValueRatio * 100).toPrecision(3),
thresholdMax: ruleSettings.thresholdMax * 100
}
}),
value: false value: false
}; };
} }
return { return {
evaluation: `The major part of your current investment is in ${ evaluation: this.i18nService.getTranslation({
maxItem?.groupKey ?? ruleSettings.baseCurrency id: 'rule.currencyClusterRiskCurrentInvestment.true',
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${ languageCode: this.getLanguageCode(),
ruleSettings.thresholdMax * 100 placeholders: {
}%`, currency: maxItem.groupKey as string,
maxValueRatio: (maxValueRatio * 100).toPrecision(3),
thresholdMax: ruleSettings.thresholdMax * 100
}
}),
value: true value: true
}; };
} }
@ -73,6 +84,13 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getName() {
return this.i18nService.getTranslation({
id: 'rule.currencyClusterRiskCurrentInvestment',
languageCode: this.getLanguageCode()
});
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

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

@ -13,8 +13,7 @@ export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
developedMarketsValueInBaseCurrency: number developedMarketsValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: EconomicMarketClusterRiskDevelopedMarkets.name, key: EconomicMarketClusterRiskDevelopedMarkets.name
name: 'Developed Markets'
}); });
this.currentValueInBaseCurrency = currentValueInBaseCurrency; this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -67,6 +66,10 @@ export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
}; };
} }
public getName() {
return 'Developed Markets';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

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

@ -13,8 +13,7 @@ export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
emergingMarketsValueInBaseCurrency: number emergingMarketsValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: EconomicMarketClusterRiskEmergingMarkets.name, key: EconomicMarketClusterRiskEmergingMarkets.name
name: 'Emerging Markets'
}); });
this.currentValueInBaseCurrency = currentValueInBaseCurrency; this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -67,6 +66,10 @@ export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
}; };
} }
public getName() {
return 'Emerging Markets';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

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

@ -1,6 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
export class EmergencyFundSetup extends Rule<Settings> { export class EmergencyFundSetup extends Rule<Settings> {
@ -8,11 +9,13 @@ export class EmergencyFundSetup extends Rule<Settings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
emergencyFund: number emergencyFund: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: EmergencyFundSetup.name, languageCode,
name: 'Emergency Fund: Set up' key: EmergencyFundSetup.name
}); });
this.emergencyFund = emergencyFund; this.emergencyFund = emergencyFund;
@ -21,13 +24,19 @@ export class EmergencyFundSetup extends Rule<Settings> {
public evaluate() { public evaluate() {
if (!this.emergencyFund) { if (!this.emergencyFund) {
return { return {
evaluation: 'No emergency fund has been set up', evaluation: this.i18nService.getTranslation({
id: 'rule.emergencyFundSetup.false',
languageCode: this.getLanguageCode()
}),
value: false value: false
}; };
} }
return { return {
evaluation: 'An emergency fund has been set up', evaluation: this.i18nService.getTranslation({
id: 'rule.emergencyFundSetup.true',
languageCode: this.getLanguageCode()
}),
value: true value: true
}; };
} }
@ -36,6 +45,13 @@ export class EmergencyFundSetup extends Rule<Settings> {
return undefined; return undefined;
} }
public getName() {
return this.i18nService.getTranslation({
id: 'rule.emergencyFundSetup',
languageCode: this.getLanguageCode()
});
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

36
apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts

@ -1,6 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
export class FeeRatioInitialInvestment extends Rule<Settings> { export class FeeRatioInitialInvestment extends Rule<Settings> {
@ -9,12 +10,14 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
totalInvestment: number, totalInvestment: number,
fees: number fees: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: FeeRatioInitialInvestment.name, languageCode,
name: 'Fee Ratio' key: FeeRatioInitialInvestment.name
}); });
this.fees = fees; this.fees = fees;
@ -28,17 +31,27 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
if (feeRatio > ruleSettings.thresholdMax) { if (feeRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: `The fees do exceed ${ evaluation: this.i18nService.getTranslation({
ruleSettings.thresholdMax * 100 id: 'rule.feeRatioInitialInvestment.false',
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`, languageCode: this.getLanguageCode(),
placeholders: {
feeRatio: (ruleSettings.thresholdMax * 100).toFixed(2),
thresholdMax: (feeRatio * 100).toPrecision(3)
}
}),
value: false value: false
}; };
} }
return { return {
evaluation: `The fees do not exceed ${ evaluation: this.i18nService.getTranslation({
ruleSettings.thresholdMax * 100 id: 'rule.feeRatioInitialInvestment.true',
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`, languageCode: this.getLanguageCode(),
placeholders: {
feeRatio: (feeRatio * 100).toPrecision(3),
thresholdMax: (ruleSettings.thresholdMax * 100).toFixed(2)
}
}),
value: true value: true
}; };
} }
@ -55,6 +68,13 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
}; };
} }
public getName() {
return this.i18nService.getTranslation({
id: 'rule.feeRatioInitialInvestment',
languageCode: this.getLanguageCode()
});
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

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

@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
asiaPacificValueInBaseCurrency: number asiaPacificValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: RegionalMarketClusterRiskAsiaPacific.name, key: RegionalMarketClusterRiskAsiaPacific.name
name: 'Asia-Pacific'
}); });
this.asiaPacificValueInBaseCurrency = asiaPacificValueInBaseCurrency; this.asiaPacificValueInBaseCurrency = asiaPacificValueInBaseCurrency;
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
}; };
} }
public getName() {
return 'Asia-Pacific';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

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

@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskEmergingMarkets extends Rule<Settings> {
emergingMarketsValueInBaseCurrency: number emergingMarketsValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: RegionalMarketClusterRiskEmergingMarkets.name, key: RegionalMarketClusterRiskEmergingMarkets.name
name: 'Emerging Markets'
}); });
this.currentValueInBaseCurrency = currentValueInBaseCurrency; this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -68,6 +67,10 @@ export class RegionalMarketClusterRiskEmergingMarkets extends Rule<Settings> {
}; };
} }
public getName() {
return 'Emerging Markets';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

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

@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskEurope extends Rule<Settings> {
europeValueInBaseCurrency: number europeValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: RegionalMarketClusterRiskEurope.name, key: RegionalMarketClusterRiskEurope.name
name: 'Europe'
}); });
this.currentValueInBaseCurrency = currentValueInBaseCurrency; this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskEurope extends Rule<Settings> {
}; };
} }
public getName() {
return 'Europe';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

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

@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskJapan extends Rule<Settings> {
japanValueInBaseCurrency: number japanValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: RegionalMarketClusterRiskJapan.name, key: RegionalMarketClusterRiskJapan.name
name: 'Japan'
}); });
this.currentValueInBaseCurrency = currentValueInBaseCurrency; this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskJapan extends Rule<Settings> {
}; };
} }
public getName() {
return 'Japan';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

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

@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule<Settings> {
northAmericaValueInBaseCurrency: number northAmericaValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: RegionalMarketClusterRiskNorthAmerica.name, key: RegionalMarketClusterRiskNorthAmerica.name
name: 'North America'
}); });
this.currentValueInBaseCurrency = currentValueInBaseCurrency; this.currentValueInBaseCurrency = currentValueInBaseCurrency;
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule<Settings> {
}; };
} }
public getName() {
return 'North America';
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,

12
apps/api/src/services/benchmark/benchmark.service.ts

@ -106,9 +106,9 @@ export class BenchmarkService {
enableSharing = false enableSharing = false
} = {}): Promise<Partial<SymbolProfile>[]> { } = {}): Promise<Partial<SymbolProfile>[]> {
const symbolProfileIds: string[] = ( const symbolProfileIds: string[] = (
((await this.propertyService.getByKey( (await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? [] )) ?? []
) )
.filter((benchmark) => { .filter((benchmark) => {
if (enableSharing) { if (enableSharing) {
@ -154,9 +154,9 @@ export class BenchmarkService {
} }
let benchmarks = let benchmarks =
((await this.propertyService.getByKey( (await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? []; )) ?? [];
benchmarks.push({ symbolProfileId: assetProfile.id }); benchmarks.push({ symbolProfileId: assetProfile.id });
@ -191,9 +191,9 @@ export class BenchmarkService {
} }
let benchmarks = let benchmarks =
((await this.propertyService.getByKey( (await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? []; )) ?? [];
benchmarks = benchmarks.filter(({ symbolProfileId }) => { benchmarks = benchmarks.filter(({ symbolProfileId }) => {
return symbolProfileId !== assetProfile.id; return symbolProfileId !== assetProfile.id;

23
apps/api/src/services/cron/cron.module.ts

@ -0,0 +1,23 @@
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { Module } from '@nestjs/common';
import { CronService } from './cron.service';
@Module({
imports: [
ConfigurationModule,
DataGatheringModule,
ExchangeRateDataModule,
PropertyModule,
TwitterBotModule,
UserModule
],
providers: [CronService]
})
export class CronModule {}

15
apps/api/src/services/cron.service.ts → apps/api/src/services/cron/cron.service.ts

@ -1,4 +1,9 @@
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
@ -10,12 +15,6 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { ConfigurationService } from './configuration/configuration.service';
import { ExchangeRateDataService } from './exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from './property/property.service';
import { DataGatheringService } from './queues/data-gathering/data-gathering.service';
import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable() @Injectable()
export class CronService { export class CronService {
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0'; private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
@ -43,7 +42,9 @@ export class CronService {
@Cron(CronExpression.EVERY_DAY_AT_5PM) @Cron(CronExpression.EVERY_DAY_AT_5PM)
public async runEveryDayAtFivePm() { public async runEveryDayAtFivePm() {
this.twitterBotService.tweetFearAndGreedIndex(); if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.twitterBotService.tweetFearAndGreedIndex();
}
} }
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)

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

@ -23,7 +23,9 @@ import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface';
@Injectable() @Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
private readonly yahooFinance = new YahooFinance(); private readonly yahooFinance = new YahooFinance({
suppressNotices: ['yahooSurvey']
});
public constructor( public constructor(
private readonly cryptocurrencyService: CryptocurrencyService private readonly cryptocurrencyService: CryptocurrencyService

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

@ -29,7 +29,7 @@ import {
import { hasRole } from '@ghostfolio/common/permissions'; import { hasRole } from '@ghostfolio/common/permissions';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { eachDayOfInterval, format, isBefore, isValid } from 'date-fns'; import { eachDayOfInterval, format, isBefore, isValid } from 'date-fns';
@ -37,7 +37,7 @@ import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@Injectable() @Injectable()
export class DataProviderService { export class DataProviderService implements OnModuleInit {
private dataProviderMapping: { [dataProviderName: string]: string }; private dataProviderMapping: { [dataProviderName: string]: string };
public constructor( public constructor(
@ -48,15 +48,13 @@ export class DataProviderService {
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService private readonly redisCacheService: RedisCacheService
) { ) {}
this.initialize();
}
public async initialize() { public async onModuleInit() {
this.dataProviderMapping = this.dataProviderMapping =
((await this.propertyService.getByKey(PROPERTY_DATA_SOURCE_MAPPING)) as { (await this.propertyService.getByKey<{
[dataProviderName: string]: string; [dataProviderName: string]: string;
}) ?? {}; }>(PROPERTY_DATA_SOURCE_MAPPING)) ?? {};
} }
public async checkQuote(dataSource: DataSource) { public async checkQuote(dataSource: DataSource) {
@ -163,8 +161,10 @@ export class DataProviderService {
} }
public async getDataSources({ public async getDataSources({
includeGhostfolio = false,
user user
}: { }: {
includeGhostfolio?: boolean;
user: UserWithSettings; user: UserWithSettings;
}): Promise<DataSource[]> { }): Promise<DataSource[]> {
let dataSourcesKey: 'DATA_SOURCES' | 'DATA_SOURCES_LEGACY' = 'DATA_SOURCES'; let dataSourcesKey: 'DATA_SOURCES' | 'DATA_SOURCES_LEGACY' = 'DATA_SOURCES';
@ -183,11 +183,11 @@ export class DataProviderService {
return DataSource[dataSource]; return DataSource[dataSource];
}); });
const ghostfolioApiKey = (await this.propertyService.getByKey( const ghostfolioApiKey = await this.propertyService.getByKey<string>(
PROPERTY_API_KEY_GHOSTFOLIO PROPERTY_API_KEY_GHOSTFOLIO
)) as string; );
if (ghostfolioApiKey || hasRole(user, 'ADMIN')) { if (includeGhostfolio || ghostfolioApiKey) {
dataSources.push('GHOSTFOLIO'); dataSources.push('GHOSTFOLIO');
} }
@ -663,9 +663,6 @@ export class DataProviderService {
// Only allow symbols with supported currency // Only allow symbols with supported currency
return currency ? true : false; return currency ? true : false;
}) })
.sort(({ name: name1 }, { name: name2 }) => {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
})
.map((lookupItem) => { .map((lookupItem) => {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (user.subscription.type === 'Premium') { if (user.subscription.type === 'Premium') {
@ -679,7 +676,21 @@ export class DataProviderService {
lookupItem.dataProviderInfo.isPremium = false; lookupItem.dataProviderInfo.isPremium = false;
} }
if (
lookupItem.assetSubClass === 'CRYPTOCURRENCY' &&
user?.Settings?.settings.isExperimentalFeatures
) {
// Remove DEFAULT_CURRENCY at the end of cryptocurrency names
lookupItem.name = lookupItem.name.replace(
new RegExp(` ${DEFAULT_CURRENCY}$`),
''
);
}
return lookupItem; return lookupItem;
})
.sort(({ name: name1 }, { name: name2 }) => {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
}); });
return { return {

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

@ -22,6 +22,7 @@ import {
LookupItem, LookupItem,
LookupResponse LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
@ -378,12 +379,22 @@ export class FinancialModelingPrepService implements DataProviderInterface {
); );
for (const { price, symbol } of quotes) { for (const { price, symbol } of quotes) {
let marketState: MarketState = 'delayed';
if (
isCurrency(
symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length)
)
) {
marketState = 'open';
}
response[symbol] = { response[symbol] = {
marketState,
currency: currencyBySymbolMap[symbol]?.currency, currency: currencyBySymbolMap[symbol]?.currency,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.FINANCIAL_MODELING_PREP, dataSource: DataSource.FINANCIAL_MODELING_PREP,
marketPrice: price, marketPrice: price
marketState: 'delayed'
}; };
} }
} catch (error) { } catch (error) {

4
apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts

@ -295,9 +295,9 @@ export class GhostfolioService implements DataProviderInterface {
} }
private async getRequestHeaders() { private async getRequestHeaders() {
const apiKey = (await this.propertyService.getByKey( const apiKey = await this.propertyService.getByKey<string>(
PROPERTY_API_KEY_GHOSTFOLIO PROPERTY_API_KEY_GHOSTFOLIO
)) as string; );
return { return {
[HEADER_KEY_TOKEN]: `Api-Key ${apiKey}` [HEADER_KEY_TOKEN]: `Api-Key ${apiKey}`

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

Loading…
Cancel
Save