Browse Source

Merge branch 'main' into 2292-scraper-configuration

pull/2777/head
Hugo Persson 2 years ago
committed by GitHub
parent
commit
10658e7ada
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 11
      .github/ISSUE_TEMPLATE/bug_report.md
  2. 7
      .github/workflows/build-code.yml
  3. 3
      .github/workflows/docker-image.yml
  4. 1
      .gitignore
  5. 1
      .prettierignore
  6. 276
      CHANGELOG.md
  7. 8
      DEVELOPMENT.md
  8. 27
      README.md
  9. 10
      apps/api/project.json
  10. 30
      apps/api/src/app/access/access.controller.ts
  11. 48
      apps/api/src/app/account-balance/account-balance.controller.ts
  12. 14
      apps/api/src/app/account-balance/account-balance.module.ts
  13. 91
      apps/api/src/app/account-balance/account-balance.service.ts
  14. 102
      apps/api/src/app/account/account.controller.ts
  15. 2
      apps/api/src/app/account/account.module.ts
  16. 2
      apps/api/src/app/account/account.service.ts
  17. 5
      apps/api/src/app/account/create-account.dto.ts
  18. 3
      apps/api/src/app/account/transfer-balance.dto.ts
  19. 5
      apps/api/src/app/account/update-account.dto.ts
  20. 233
      apps/api/src/app/admin/admin.controller.ts
  21. 23
      apps/api/src/app/admin/admin.service.ts
  22. 59
      apps/api/src/app/admin/queue/queue.controller.ts
  23. 4
      apps/api/src/app/admin/update-asset-profile.dto.ts
  24. 6
      apps/api/src/app/admin/update-market-data.dto.ts
  25. 36
      apps/api/src/app/auth-device/auth-device.controller.ts
  26. 5
      apps/api/src/app/auth/auth.controller.ts
  27. 43
      apps/api/src/app/benchmark/benchmark.controller.ts
  28. 100
      apps/api/src/app/benchmark/benchmark.service.ts
  29. 35
      apps/api/src/app/cache/cache.controller.ts
  30. 3
      apps/api/src/app/exchange-rate/exchange-rate.controller.ts
  31. 4
      apps/api/src/app/export/export.controller.ts
  32. 13
      apps/api/src/app/export/export.service.ts
  33. 13
      apps/api/src/app/import/import.controller.ts
  34. 33
      apps/api/src/app/import/import.service.ts
  35. 16
      apps/api/src/app/info/info.service.ts
  36. 5
      apps/api/src/app/logo/logo.service.ts
  37. 5
      apps/api/src/app/order/create-order.dto.ts
  38. 1
      apps/api/src/app/order/interfaces/activities.interface.ts
  39. 49
      apps/api/src/app/order/order.controller.ts
  40. 2
      apps/api/src/app/order/order.module.ts
  41. 88
      apps/api/src/app/order/order.service.ts
  42. 6
      apps/api/src/app/order/update-order.dto.ts
  43. 50
      apps/api/src/app/platform/platform.controller.ts
  44. 2
      apps/api/src/app/portfolio/current-rate.service.mock.ts
  45. 14
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  46. 33
      apps/api/src/app/portfolio/current-rate.service.ts
  47. 5
      apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts
  48. 1
      apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts
  49. 1
      apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts
  50. 9
      apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  51. 9
      apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  52. 1
      apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts
  53. 271
      apps/api/src/app/portfolio/portfolio-calculator.ts
  54. 48
      apps/api/src/app/portfolio/portfolio.controller.ts
  55. 2
      apps/api/src/app/portfolio/portfolio.module.ts
  56. 359
      apps/api/src/app/portfolio/portfolio.service.ts
  57. 5
      apps/api/src/app/subscription/subscription.controller.ts
  58. 8
      apps/api/src/app/subscription/subscription.service.ts
  59. 5
      apps/api/src/app/symbol/symbol.controller.ts
  60. 7
      apps/api/src/app/symbol/symbol.service.ts
  61. 44
      apps/api/src/app/tag/tag.controller.ts
  62. 14
      apps/api/src/app/user/user.controller.ts
  63. 37
      apps/api/src/app/user/user.service.ts
  64. 302
      apps/api/src/assets/sitemap.xml
  65. 6
      apps/api/src/decorators/has-permission.decorator.ts
  66. 50
      apps/api/src/guards/has-permission.guard.spec.ts
  67. 37
      apps/api/src/guards/has-permission.guard.ts
  68. 14
      apps/api/src/helper/object.helper.ts
  69. 49
      apps/api/src/middlewares/html-template.middleware.ts
  70. 10
      apps/api/src/services/account-balance/account-balance.module.ts
  71. 42
      apps/api/src/services/account-balance/account-balance.service.ts
  72. 1
      apps/api/src/services/configuration/configuration.service.ts
  73. 10
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  74. 88
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  75. 2
      apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts
  76. 5
      apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts
  77. 21
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  78. 4
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts
  79. 22
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  80. 10
      apps/api/src/services/data-provider/data-provider.service.ts
  81. 80
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  82. 57
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  83. 22
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  84. 2
      apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts
  85. 10
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  86. 32
      apps/api/src/services/data-provider/manual/manual.service.ts
  87. 31
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  88. 28
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  89. 24
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  90. 1
      apps/api/src/services/interfaces/environment.interface.ts
  91. 19
      apps/api/src/services/market-data/market-data.service.ts
  92. 2
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  93. 31
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  94. 6
      apps/api/webpack.config.js
  95. 43
      apps/client/project.json
  96. 6
      apps/client/src/app/adapter/custom-date-adapter.ts
  97. 20
      apps/client/src/app/app.component.html
  98. 17
      apps/client/src/app/app.component.ts
  99. 2
      apps/client/src/app/components/access-table/access-table.component.html
  100. 177
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

11
.github/ISSUE_TEMPLATE/bug_report.md

@ -6,7 +6,13 @@ labels: ''
assignees: '' assignees: ''
--- ---
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions). **Important Notice**
The issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
Incomplete or non-reproducible issues may be closed, but we are here to help! If you encounter difficulties reproducing the bug or need assistance, please reach out to our community channels mentioned above.
Thank you for your understanding and cooperation!
**Bug Description** **Bug Description**
@ -36,8 +42,9 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
<!-- Please complete the following information --> <!-- Please complete the following information -->
- Cloud or Self-hosted
- Ghostfolio Version X.Y.Z - Ghostfolio Version X.Y.Z
- Cloud or Self-hosted
- Experimental Features enabled or disabled
- Browser - Browser
- OS - OS

7
.github/workflows/build-code.yml

@ -4,6 +4,9 @@ on:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -13,12 +16,12 @@ jobs:
- 18 - 18
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Use Node.js ${{ matrix.node_version }} - name: Use Node.js ${{ matrix.node_version }}
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node_version }} node-version: ${{ matrix.node_version }}
cache: 'yarn' cache: 'yarn'

3
.github/workflows/docker-image.yml

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Docker metadata - name: Docker metadata
id: meta id: meta
@ -21,6 +21,7 @@ jobs:
with: with:
images: ghostfolio/ghostfolio images: ghostfolio/ghostfolio
tags: | tags: |
type=semver,pattern={{major}}
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Set up QEMU - name: Set up QEMU

1
.gitignore

@ -27,6 +27,7 @@
/.angular/cache /.angular/cache
.env .env
.env.prod .env.prod
.nx/cache
/.sass-cache /.sass-cache
/connect.lock /connect.lock
/coverage /coverage

1
.prettierignore

@ -1,2 +1,3 @@
/.nx/cache
/dist /dist
/test/import /test/import

276
CHANGELOG.md

@ -9,13 +9,279 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added support to edit the currency of asset profiles with `MANUAL` data source in the asset profile details dialog of the admin control panel
### Changed
- Changed the performance calculation to a time-weighted approach
- Exposed the environment variable `REQUEST_TIMEOUT`
- Used the `HasPermission` annotation in endpoints
- Upgraded `Nx` from version `17.2.5` to `17.2.7`
### Fixed
- Improved the handling of derived currencies (`USX`)
## 2.32.0 - 2023-12-26
### Added
- Added support to search for an asset profile by `id` as an administrator
### Changed
- Set the select column of the lazy-loaded activities table to stick at the end (experimental)
- Dropped the activity id in the activities import
- Improved the validation of the currency management in the admin control panel
- Improved the performance of the value redaction interceptor for the impersonation mode by eliminating `cloneDeep`
- Modernized the `Nx` executors
- `@nx/eslint:lint`
- `@nx/webpack:webpack`
- Upgraded `prettier` from version `3.1.0` to `3.1.1`
- Upgraded `prisma` from version `5.7.0` to `5.7.1`
### Fixed
- Reset the letter spacing in buttons
## 2.31.0 - 2023-12-16
### Changed
- Introduced the lazy-loaded activities table to the account detail dialog (experimental)
- Introduced the lazy-loaded activities table to the import activities dialog (experimental)
- Introduced the lazy-loaded activities table to the position detail dialog (experimental)
- Improved the font weight in the value component
- Improved the language localization for Türkçe (`tr`)
- Upgraded `angular` from version `17.0.4` to `17.0.7`
- Upgraded to _Inter_ 4 font family
- Upgraded `Nx` from version `17.0.2` to `17.2.5`
### Fixed
- Fixed the loading state in the lazy-loaded activities table on the portfolio activities page (experimental)
- Fixed the edit of activity in the lazy-loaded activities table on the portfolio activities page (experimental)
## 2.30.0 - 2023-12-12
### Added
- Added support for column sorting to the lazy-loaded activities table on the portfolio activities page (experimental)
- Extended the benchmarks of the markets overview by the current market condition (all time high)
### Changed
- Adjusted the threshold to skip the data enhancement (_Trackinsight_) if data is inaccurate
- Upgraded `prisma` from version `5.6.0` to `5.7.0`
## 2.29.0 - 2023-12-09
### Added
- Introduced a lazy-loaded activities table on the portfolio activities page (experimental)
### Changed
- Set the actions columns of various tables to stick at the end
- Increased the height of the tabs on mobile
- Improved the language localization for German (`de`)
- Improved the language localization for Türkçe (`tr`)
- Upgraded `marked` from version `4.2.12` to `9.1.6`
- Upgraded `ngx-markdown` from version `15.1.0` to `17.1.1`
- Upgraded `ng-extract-i18n-merge` from version `2.8.3` to `2.9.0`
### Fixed
- Fixed an issue in the biometric authentication registration
## 2.28.0 - 2023-12-02
### Added
- Added a historical cash balances table to the account detail dialog
- Introduced a `HasPermission` annotation for endpoints
### Changed
- Relaxed the check for duplicates in the preview step of the activities import (allow same day)
- Respected the `withExcludedAccounts` flag in the account balance time series
### Fixed
- Changed the mechanism of the `INTRADAY` data gathering to operate synchronously avoiding database deadlocks
## 2.27.1 - 2023-11-28
### Changed
- Reverted `Nx` from version `17.1.3` to `17.0.2`
## 2.27.0 - 2023-11-26
### Changed
- Extended the chart in the account detail dialog by historical cash balances
- Improved the error log for a timeout in the data source request
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `16.2.12` to `17.0.4`
- Upgraded `Nx` from version `17.0.2` to `17.1.3`
## 2.26.0 - 2023-11-24
### Changed
- Upgraded `prisma` from version `5.5.2` to `5.6.0`
- Upgraded `yahoo-finance2` from version `2.8.1` to `2.9.0`
## 2.25.1 - 2023-11-19
### Added
- Added a blog post: _Black Friday 2023_
### Changed
- Upgraded `http-status-codes` from version `2.2.0` to `2.3.0`
### Fixed
- Handled reading items from missing transaction point while getting the position (`getPosition()`) in portfolio service
## 2.24.0 - 2023-11-16
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed the "too many bind variables in prepared statement" issue of the data range functionality (`getRange()`) in the market data service
## 2.23.0 - 2023-11-15
### Added
- Extended the benchmarks in the markets overview by 50-Day and 200-Day trends (experimental)
- Set up the language localization for Polski (`pl`)
### Changed
- Improved the data source validation in the activities import
- Changed _Twitter_ to _𝕏_
- Improved the selection in the twitter bot service
- Improved the language localization for German (`de`)
- Upgraded `ng-extract-i18n-merge` from version `2.7.0` to `2.8.3`
- Upgraded `prettier` from version `3.0.3` to `3.1.0`
## 2.22.0 - 2023-11-11
### Added
- Added the platform icon to the account selectors in the cash balance transfer from one to another account
- Added the platform icon to the account selector of the create or edit activity dialog
### Changed
- Optimized the style of the carousel component on mobile for the testimonial section on the landing page
- Introduced action menus in the overview of the admin control panel
- Harmonized the name column in the historical market data table of the admin control panel
- Refactored the implementation of the data range functionality (`getRange()`) in the market data service
## 2.21.0 - 2023-11-09
### Changed
- Extended the system message
### Fixed
- Fixed the unit for the _Zen Mode_ in the overview tab of the home page
- Fixed an issue to get quotes in the _Financial Modeling Prep_ service
## 2.20.0 - 2023-11-08
### Changed
- Removed the loading indicator of the unit in the overview tab of the home page
- Improved the import of historical market data in the admin control panel
- Increased the timeout in the health check endpoint for data enhancers
- Increased the timeout in the health check endpoint for data providers
- Removed the account type from the `Account` database schema
## 2.19.0 - 2023-11-06
### Added
- Added a data migration to set `accountType` to `NULL` in the account database table
### Changed
- Improved the language localization for the _Fear & Greed Index_ (market mood)
- Improved the language localization for German (`de`)
### Fixed
- Improved the handling of derived currencies (`GBp`, `ILA`, `ZAc`)
## 2.18.0 - 2023-11-05
### Added
- Added support to import activities by `isin` in the _Yahoo Finance_ service
- Added a new tag with the major version to the docker image on _Docker Hub_
- Added a blog post: _Hacktoberfest 2023 Debriefing_
### Changed
- Upgraded `angular` from version `16.2.1` to `16.2.12`
### Fixed
- Fixed an issue to get quotes in the _CoinGecko_ service
- Loosened the validation in the activities import (expects values greater than or equal to 0 for `fee`, `quantity` and `unitPrice`)
- Handled an issue with a failing database query (`account.findMany()`) related to activities without account
## 2.17.0 - 2023-11-02
### Added
- Added a button to edit the exchange rates in the admin control panel
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue in the biometric authentication
- Fixed the alignment of the icons in various menus
## 2.16.0 - 2023-10-29
### Changed
- Relaxed the check for duplicates in the preview step of the activities import (allow different accounts)
- Improved the usability and validation in the cash balance transfer from one to another account
- Changed the checkboxes to slide toggles in the overview of the admin control panel
- Switched from the deprecated (`PUT`) to the new endpoint (`POST`) to manage historical market data in the asset profile details dialog of the admin control panel
- Improved the date parsing in the import historical market data of the admin control panel
- Improved the localized meta data (keywords) in `html` files
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.4.2` to `5.5.2`
## 2.15.0 - 2023-10-26
### Added
- Added support to edit the name, asset class and asset sub class of asset profiles with `MANUAL` data source in the asset profile details dialog of the admin control panel - Added support to edit the name, asset class and asset sub class of asset profiles with `MANUAL` data source in the asset profile details dialog of the admin control panel
### Changed ### Changed
- Improved the style and wording of the position detail dialog - Improved the style and wording of the position detail dialog
- Improved the validation in the activities import (expects positive values for `fee`, `quantity` and `unitPrice`) - Improved the validation in the activities import (expects positive values for `fee`, `quantity` and `unitPrice`)
- Improved the validation in the cash balance transfer from one to another account (expects a positive value)
- Changed the currency selector in the create or update account dialog to `@angular/material/autocomplete` - Changed the currency selector in the create or update account dialog to `@angular/material/autocomplete`
- Upgraded `Nx` from version `16.7.4` to `17.0.2`
- Upgraded `uuid` from version `9.0.0` to `9.0.1` - Upgraded `uuid` from version `9.0.0` to `9.0.1`
- Upgraded `yahoo-finance2` from version `2.8.0` to `2.8.1` - Upgraded `yahoo-finance2` from version `2.8.0` to `2.8.1`
@ -86,7 +352,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added support to transfer a part of the cash balance from one to another account - Added support to transfer a part of the cash balance from one to another account
- Extended the markets overview by benchmarks (date of last all time high) - Extended the benchmarks in the markets overview by the date of the last all time high
- Added support to import historical market data in the admin control panel - Added support to import historical market data in the admin control panel
### Changed ### Changed
@ -298,7 +564,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added health check endpoints for data enhancers - Added a health check endpoint for data enhancers
### Changed ### Changed
@ -474,7 +740,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the usability of the login dialog - Improved the usability of the login dialog
- Disabled the caching in the health check endpoints for data providers - Disabled the caching in the health check endpoint for data providers
- Improved the content of the Frequently Asked Questions (FAQ) page - Improved the content of the Frequently Asked Questions (FAQ) page
- Upgraded `prisma` from version `4.15.0` to `4.16.2` - Upgraded `prisma` from version `4.15.0` to `4.16.2`
@ -862,7 +1128,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added a fallback to historical market data if a data provider does not provide live data - Added a fallback to historical market data if a data provider does not provide live data
- Added a general health check endpoint - Added a general health check endpoint
- Added health check endpoints for data providers - Added a health check endpoint for data providers
### Changed ### Changed
@ -2326,7 +2592,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the _Ghostfolio_ trailer to the landing page - Added the _Ghostfolio_ trailer to the landing page
- Extended the markets overview by benchmarks (current change to the all time high) - Extended the benchmarks in the markets overview by the current change to the all time high
## 1.151.0 - 24.05.2022 ## 1.151.0 - 24.05.2022

8
DEVELOPMENT.md

@ -20,13 +20,19 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
## Dependencies ## Dependencies
### Angular
#### Upgrade (minor versions)
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"`
### Nx ### Nx
#### Upgrade #### Upgrade
1. Run `yarn nx migrate latest` 1. Run `yarn nx migrate latest`
1. Make sure `package.json` changes make sense and then run `yarn install` 1. Make sure `package.json` changes make sense and then run `yarn install`
1. Run `yarn nx migrate --run-migrations` 1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
### Prisma ### Prisma

27
README.md

@ -100,6 +100,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
| `REDIS_HOST` | | The host where _Redis_ is running | | `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ | | `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running | | `REDIS_PORT` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose ### Run with Docker Compose
@ -230,18 +231,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 | `MANUAL` (for type `ITEM`) \| `YAHOO` | | dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `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` \| `ITEM` \| `SELL` | | type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| unitPrice | number | Price per unit of the activity | | unitPrice | number | Price per unit of the activity |
#### Response #### Response
@ -272,7 +273,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you. Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio). If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).

10
apps/api/project.json

@ -7,14 +7,15 @@
"generators": {}, "generators": {},
"targets": { "targets": {
"build": { "build": {
"executor": "@nrwl/webpack:webpack", "executor": "@nx/webpack:webpack",
"options": { "options": {
"outputPath": "dist/apps/api", "outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts", "main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json", "tsConfig": "apps/api/tsconfig.app.json",
"assets": ["apps/api/src/assets"], "assets": ["apps/api/src/assets"],
"target": "node", "target": "node",
"compiler": "tsc" "compiler": "tsc",
"webpackConfig": "apps/api/webpack.config.js"
}, },
"configurations": { "configurations": {
"production": { "production": {
@ -39,7 +40,7 @@
} }
}, },
"lint": { "lint": {
"executor": "@nrwl/linter:eslint", "executor": "@nx/eslint:lint",
"options": { "options": {
"lintFilePatterns": ["apps/api/**/*.ts"] "lintFilePatterns": ["apps/api/**/*.ts"]
} }
@ -47,8 +48,7 @@
"test": { "test": {
"executor": "@nx/jest:jest", "executor": "@nx/jest:jest",
"options": { "options": {
"jestConfig": "apps/api/jest.config.ts", "jestConfig": "apps/api/jest.config.ts"
"passWithNoTests": true
}, },
"outputs": ["{workspaceRoot}/coverage/apps/api"] "outputs": ["{workspaceRoot}/coverage/apps/api"]
} }

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

@ -1,5 +1,7 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -17,7 +19,6 @@ import { AuthGuard } from '@nestjs/passport';
import { Access as AccessModel } from '@prisma/client'; import { Access as AccessModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessModule } from './access.module';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto'; import { CreateAccessDto } from './create-access.dto';
@ -29,7 +30,7 @@ export class AccessController {
) {} ) {}
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAllAccesses(): Promise<Access[]> { public async getAllAccesses(): Promise<Access[]> {
const accessesWithGranteeUser = await this.accessService.accesses({ const accessesWithGranteeUser = await this.accessService.accesses({
include: { include: {
@ -58,20 +59,12 @@ export class AccessController {
}); });
} }
@HasPermission(permissions.createAccess)
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createAccess( public async createAccess(
@Body() data: CreateAccessDto @Body() data: CreateAccessDto
): Promise<AccessModel> { ): Promise<AccessModel> {
if (
!hasPermission(this.request.user.permissions, permissions.createAccess)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accessService.createAccess({ return this.accessService.createAccess({
alias: data.alias || undefined, alias: data.alias || undefined,
GranteeUser: data.granteeUserId GranteeUser: data.granteeUserId
@ -82,15 +75,12 @@ export class AccessController {
} }
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.deleteAccess)
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> { @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
const access = await this.accessService.access({ id }); const access = await this.accessService.access({ id });
if ( if (!access || access.userId !== this.request.user.id) {
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
!access ||
access.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN

48
apps/api/src/app/account-balance/account-balance.controller.ts

@ -0,0 +1,48 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AccountBalance } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service';
@Controller('account-balance')
export class AccountBalanceController {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@HasPermission(permissions.deleteAccountBalance)
@Delete(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccountBalance(
@Param('id') id: string
): Promise<AccountBalance> {
const accountBalance = await this.accountBalanceService.accountBalance({
id
});
if (!accountBalance || accountBalance.userId !== this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accountBalanceService.deleteAccountBalance({
id
});
}
}

14
apps/api/src/app/account-balance/account-balance.module.ts

@ -0,0 +1,14 @@
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { AccountBalanceController } from './account-balance.controller';
import { AccountBalanceService } from './account-balance.service';
@Module({
controllers: [AccountBalanceController],
exports: [AccountBalanceService],
imports: [ExchangeRateDataModule, PrismaModule],
providers: [AccountBalanceService]
})
export class AccountBalanceModule {}

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

@ -0,0 +1,91 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { AccountBalance, Prisma } from '@prisma/client';
@Injectable()
export class AccountBalanceService {
public constructor(
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
public async accountBalance(
accountBalanceWhereInput: Prisma.AccountBalanceWhereInput
): Promise<AccountBalance | null> {
return this.prismaService.accountBalance.findFirst({
include: {
Account: true
},
where: accountBalanceWhereInput
});
}
public async createAccountBalance(
data: Prisma.AccountBalanceCreateInput
): Promise<AccountBalance> {
return this.prismaService.accountBalance.create({
data
});
}
public async deleteAccountBalance(
where: Prisma.AccountBalanceWhereUniqueInput
): Promise<AccountBalance> {
return this.prismaService.accountBalance.delete({
where
});
}
public async getAccountBalances({
filters,
user,
withExcludedAccounts
}: {
filters?: Filter[];
user: UserWithSettings;
withExcludedAccounts?: boolean;
}): Promise<AccountBalancesResponse> {
const where: Prisma.AccountBalanceWhereInput = { userId: user.id };
const accountFilter = filters?.find(({ type }) => {
return type === 'ACCOUNT';
});
if (accountFilter) {
where.accountId = accountFilter.id;
}
if (withExcludedAccounts === false) {
where.Account = { isExcluded: false };
}
const balances = await this.prismaService.accountBalance.findMany({
where,
orderBy: {
date: 'asc'
},
select: {
Account: true,
date: true,
id: true,
value: true
}
});
return {
balances: balances.map((balance) => {
return {
...balance,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value,
balance.Account.currency,
user.Settings.settings.baseCurrency
)
};
})
};
}
}

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

@ -1,13 +1,15 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {
AccountBalancesResponse, AccountBalancesResponse,
Accounts Accounts
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type {
AccountWithValue, AccountWithValue,
RequestWithUser RequestWithUser
@ -47,17 +49,9 @@ export class AccountController {
) {} ) {}
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.deleteAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> { public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
if (
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const account = await this.accountService.accountWithOrders( const account = await this.accountService.accountWithOrders(
{ {
id_userId: { id_userId: {
@ -87,7 +81,7 @@ export class AccountController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAllAccounts( public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
@ -102,7 +96,7 @@ export class AccountController {
} }
@Get(':id') @Get(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById( public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@ -122,31 +116,23 @@ export class AccountController {
} }
@Get(':id/balances') @Get(':id/balances')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById( public async getAccountBalancesById(
@Param('id') id: string @Param('id') id: string
): Promise<AccountBalancesResponse> { ): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({ return this.accountBalanceService.getAccountBalances({
accountId: id, filters: [{ id, type: 'ACCOUNT' }],
userId: this.request.user.id user: this.request.user
}); });
} }
@HasPermission(permissions.createAccount)
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createAccount( public async createAccount(
@Body() data: CreateAccountDto @Body() data: CreateAccountDto
): Promise<AccountModel> { ): Promise<AccountModel> {
if (
!hasPermission(this.request.user.permissions, permissions.createAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (data.platformId) { if (data.platformId) {
const platformId = data.platformId; const platformId = data.platformId;
delete data.platformId; delete data.platformId;
@ -172,70 +158,64 @@ export class AccountController {
} }
} }
@HasPermission(permissions.updateAccount)
@Post('transfer-balance') @Post('transfer-balance')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async transferAccountBalance( public async transferAccountBalance(
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto @Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
) { ) {
if (
!hasPermission(this.request.user.permissions, permissions.updateAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const accountsOfUser = await this.accountService.getAccounts( const accountsOfUser = await this.accountService.getAccounts(
this.request.user.id this.request.user.id
); );
const currentAccountIds = accountsOfUser.map(({ id }) => { const accountFrom = accountsOfUser.find(({ id }) => {
return id; return id === accountIdFrom;
});
const accountTo = accountsOfUser.find(({ id }) => {
return id === accountIdTo;
}); });
if ( if (!accountFrom || !accountTo) {
![accountIdFrom, accountIdTo].every((accountId) => {
return currentAccountIds.includes(accountId);
})
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND StatusCodes.NOT_FOUND
); );
} }
const { currency } = accountsOfUser.find(({ id }) => { if (accountFrom.id === accountTo.id) {
return id === accountIdFrom; throw new HttpException(
}); getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
if (accountFrom.balance < balance) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
await this.accountService.updateAccountBalance({ await this.accountService.updateAccountBalance({
currency, accountId: accountFrom.id,
accountId: accountIdFrom,
amount: -balance, amount: -balance,
currency: accountFrom.currency,
userId: this.request.user.id userId: this.request.user.id
}); });
await this.accountService.updateAccountBalance({ await this.accountService.updateAccountBalance({
currency, accountId: accountTo.id,
accountId: accountIdTo,
amount: balance, amount: balance,
currency: accountFrom.currency,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@HasPermission(permissions.updateAccount)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) { public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
if (
!hasPermission(this.request.user.permissions, permissions.updateAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalAccount = await this.accountService.account({ const originalAccount = await this.accountService.account({
id_userId: { id_userId: {
id, id,

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

@ -1,7 +1,7 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.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 { 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';

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

@ -1,4 +1,4 @@
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';

5
apps/api/src/app/account/create-account.dto.ts

@ -1,4 +1,3 @@
import { AccountType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
@ -10,10 +9,6 @@ import {
import { isString } from 'lodash'; import { isString } from 'lodash';
export class CreateAccountDto { export class CreateAccountDto {
@IsOptional()
@IsString()
accountType?: AccountType;
@IsNumber() @IsNumber()
balance: number; balance: number;

3
apps/api/src/app/account/transfer-balance.dto.ts

@ -1,4 +1,4 @@
import { IsNumber, IsString } from 'class-validator'; import { IsNumber, IsPositive, IsString } from 'class-validator';
export class TransferBalanceDto { export class TransferBalanceDto {
@IsString() @IsString()
@ -8,5 +8,6 @@ export class TransferBalanceDto {
accountIdTo: string; accountIdTo: string;
@IsNumber() @IsNumber()
@IsPositive()
balance: number; balance: number;
} }

5
apps/api/src/app/account/update-account.dto.ts

@ -1,4 +1,3 @@
import { AccountType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
@ -10,10 +9,6 @@ import {
import { isString } from 'lodash'; import { isString } from 'lodash';
export class UpdateAccountDto { export class UpdateAccountDto {
@IsOptional()
@IsString()
accountType?: AccountType;
@IsNumber() @IsNumber()
balance: number; balance: number;

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

@ -1,3 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/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 { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
@ -7,7 +9,10 @@ import {
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import {
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -59,56 +64,23 @@ export class AdminController {
) {} ) {}
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAdminData(): Promise<AdminData> { public async getAdminData(): Promise<AdminData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.get(); return this.adminService.get();
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather') @Post('gather')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gather7Days(): Promise<void> { public async gather7Days(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gather7Days(); this.dataGatheringService.gather7Days();
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather/max') @Post('gather/max')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
@ -130,21 +102,10 @@ export class AdminController {
this.dataGatheringService.gatherMax(); this.dataGatheringService.gatherMax();
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather/profile-data') @Post('gather/profile-data')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> { public async gatherProfileData(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
@ -164,24 +125,13 @@ export class AdminController {
); );
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather/profile-data/:dataSource/:symbol') @Post('gather/profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileDataForSymbol( public async gatherProfileDataForSymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.dataGatheringService.addJobToQueue({ await this.dataGatheringService.addJobToQueue({
data: { data: {
dataSource, dataSource,
@ -196,47 +146,25 @@ export class AdminController {
} }
@Post('gather/:dataSource/:symbol') @Post('gather/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@HasPermission(permissions.accessAdminControl)
public async gatherSymbol( public async gatherSymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherSymbol({ dataSource, symbol }); this.dataGatheringService.gatherSymbol({ dataSource, symbol });
return; return;
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather/:dataSource/:symbol/:dateString') @Post('gather/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherSymbolForDate( public async gatherSymbolForDate(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
): Promise<MarketData> { ): Promise<MarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = parseISO(dateString); const date = parseISO(dateString);
if (!isDate(date)) { if (!isDate(date)) {
@ -266,7 +194,8 @@ export class AdminController {
} }
@Get('market-data') @Get('market-data')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@HasPermission(permissions.accessAdminControl)
public async getMarketData( public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string, @Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset, @Query('presetId') presetId?: MarketDataPreset,
@ -276,18 +205,6 @@ export class AdminController {
@Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take') take?: number @Query('take') take?: number
): Promise<AdminMarketData> { ): Promise<AdminMarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses, filterByAssetSubClasses,
filterBySearchQuery filterBySearchQuery
@ -304,51 +221,29 @@ export class AdminController {
} }
@Get('market-data/:dataSource/:symbol') @Get('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataBySymbol( public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> { ): Promise<AdminMarketDataDetails> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketDataBySymbol({ dataSource, symbol }); return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
} }
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol') @Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateMarketData( public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto, @Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
) { ) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map( const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({ ({ date, marketPrice }) => ({
dataSource, dataSource,
date,
marketPrice, marketPrice,
symbol, symbol,
date: resetHours(parseISO(date)),
state: 'CLOSE' state: 'CLOSE'
}) })
); );
@ -361,26 +256,15 @@ export class AdminController {
/** /**
* @deprecated * @deprecated
*/ */
@HasPermission(permissions.accessAdminControl)
@Put('market-data/:dataSource/:symbol/:dateString') @Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async update( public async update(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Body() data: UpdateMarketDataDto @Body() data: UpdateMarketDataDto
) { ) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = parseISO(dateString); const date = parseISO(dateString);
return this.marketDataService.updateMarketData({ return this.marketDataService.updateMarketData({
@ -395,24 +279,14 @@ export class AdminController {
}); });
} }
@HasPermission(permissions.accessAdminControl)
@Post('profile-data/:dataSource/:symbol') @Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async addProfileData( public async addProfileData(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<SymbolProfile | never> { ): Promise<SymbolProfile | never> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.addAssetProfile({ return this.adminService.addAssetProfile({
dataSource, dataSource,
symbol, symbol,
@ -421,45 +295,23 @@ export class AdminController {
} }
@Delete('profile-data/:dataSource/:symbol') @Delete('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteProfileData( public async deleteProfileData(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.deleteProfileData({ dataSource, symbol }); return this.adminService.deleteProfileData({ dataSource, symbol });
} }
@HasPermission(permissions.accessAdminControl)
@Patch('profile-data/:dataSource/:symbol') @Patch('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async patchAssetProfileData( public async patchAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDto, @Body() assetProfileData: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> { ): Promise<EnhancedSymbolProfile> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.patchAssetProfileData({ return this.adminService.patchAssetProfileData({
...assetProfileData, ...assetProfileData,
dataSource, dataSource,
@ -467,24 +319,13 @@ export class AdminController {
}); });
} }
@HasPermission(permissions.accessAdminControl)
@Put('settings/:key') @Put('settings/:key')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateProperty( public async updateProperty(
@Param('key') key: string, @Param('key') key: string,
@Body() data: PropertyDto @Body() data: PropertyDto
) { ) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return await this.adminService.putSetting(key, data.value); return await this.adminService.putSetting(key, data.value);
} }
} }

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

@ -23,7 +23,13 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client'; import {
AssetSubClass,
DataSource,
Prisma,
Property,
SymbolProfile
} from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@ -94,9 +100,17 @@ export class AdminService {
return currency !== DEFAULT_CURRENCY; return currency !== DEFAULT_CURRENCY;
}) })
.map((currency) => { .map((currency) => {
const label1 = DEFAULT_CURRENCY;
const label2 = currency;
return { return {
label1: DEFAULT_CURRENCY, label1,
label2: currency, label2,
dataSource:
DataSource[
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
],
symbol: `${label1}${label2}`,
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
1, 1,
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
@ -162,6 +176,7 @@ export class AdminService {
if (searchQuery) { if (searchQuery) {
where.OR = [ where.OR = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } }, { isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } }, { name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } } { symbol: { mode: 'insensitive', startsWith: searchQuery } }
@ -306,6 +321,7 @@ export class AdminService {
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
currency,
dataSource, dataSource,
name, name,
scraperConfiguration, scraperConfiguration,
@ -316,6 +332,7 @@ export class AdminService {
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
currency,
dataSource, dataSource,
name, name,
scraperConfiguration, scraperConfiguration,

59
apps/api/src/app/admin/queue/queue.controller.ts

@ -1,87 +1,48 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { AdminJobs } from '@ghostfolio/common/interfaces'; import { AdminJobs } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Delete, Delete,
Get, Get,
HttpException,
Inject,
Param, Param,
Query, Query,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { JobStatus } from 'bull'; import { JobStatus } from 'bull';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { QueueService } from './queue.service'; import { QueueService } from './queue.service';
@Controller('admin/queue') @Controller('admin/queue')
export class QueueController { export class QueueController {
public constructor( public constructor(private readonly queueService: QueueService) {}
private readonly queueService: QueueService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete('job') @Delete('job')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteJobs( public async deleteJobs(
@Query('status') filterByStatus?: string @Query('status') filterByStatus?: string
): Promise<void> { ): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined; const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
return this.queueService.deleteJobs({ status }); return this.queueService.deleteJobs({ status });
} }
@Get('job') @Get('job')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getJobs( public async getJobs(
@Query('status') filterByStatus?: string @Query('status') filterByStatus?: string
): Promise<AdminJobs> { ): Promise<AdminJobs> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined; const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
return this.queueService.getJobs({ status }); return this.queueService.getJobs({ status });
} }
@Delete('job/:id') @Delete('job/:id')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteJob(@Param('id') id: string): Promise<void> { public async deleteJob(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.queueService.deleteJob(id); return this.queueService.deleteJob(id);
} }
} }

4
apps/api/src/app/admin/update-asset-profile.dto.ts

@ -14,6 +14,10 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
comment?: string; comment?: string;
@IsString()
@IsOptional()
currency?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
name?: string; name?: string;

6
apps/api/src/app/admin/update-market-data.dto.ts

@ -1,9 +1,9 @@
import { IsDate, IsNumber, IsOptional } from 'class-validator'; import { IsISO8601, IsNumber, IsOptional } from 'class-validator';
export class UpdateMarketDataDto { export class UpdateMarketDataDto {
@IsDate() @IsISO8601()
@IsOptional() @IsOptional()
date?: Date; date?: string;
@IsNumber() @IsNumber()
marketPrice: number; marketPrice: number;

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

@ -1,40 +1,18 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import type { RequestWithUser } from '@ghostfolio/common/types'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import { permissions } from '@ghostfolio/common/permissions';
Controller, import { Controller, Delete, Param, UseGuards } from '@nestjs/common';
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('auth-device') @Controller('auth-device')
export class AuthDeviceController { export class AuthDeviceController {
public constructor( public constructor(private readonly authDeviceService: AuthDeviceService) {}
private readonly authDeviceService: AuthDeviceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.deleteAuthDevice)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAuthDevice(@Param('id') id: string): Promise<void> { public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.deleteAuthDevice
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.authDeviceService.deleteAuthDevice({ id }); await this.authDeviceService.deleteAuthDevice({ id });
} }
} }

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

@ -1,4 +1,5 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces'; import { OAuthResponse } from '@ghostfolio/common/interfaces';
@ -118,13 +119,13 @@ export class AuthController {
} }
@Get('webauthn/generate-registration-options') @Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async generateRegistrationOptions() { public async generateRegistrationOptions() {
return this.webAuthService.generateRegistrationOptions(); return this.webAuthService.generateRegistrationOptions();
} }
@Post('webauthn/verify-attestation') @Post('webauthn/verify-attestation')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async verifyAttestation( public async verifyAttestation(
@Body() body: { deviceName: string; credential: AttestationCredentialJSON } @Body() body: { deviceName: string; credential: AttestationCredentialJSON }
) { ) {

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

@ -1,3 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import type { import type {
@ -5,8 +7,7 @@ import type {
BenchmarkResponse, BenchmarkResponse,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -19,7 +20,6 @@ import {
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -28,26 +28,12 @@ import { BenchmarkService } from './benchmark.service';
@Controller('benchmark') @Controller('benchmark')
export class BenchmarkController { export class BenchmarkController {
public constructor( public constructor(private readonly benchmarkService: BenchmarkService) {}
private readonly benchmarkService: BenchmarkService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@HasPermission(permissions.accessAdminControl)
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) { public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try { try {
const benchmark = await this.benchmarkService.addBenchmark({ const benchmark = await this.benchmarkService.addBenchmark({
dataSource, dataSource,
@ -71,23 +57,12 @@ export class BenchmarkController {
} }
@Delete(':dataSource/:symbol') @Delete(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteBenchmark( public async deleteBenchmark(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
) { ) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try { try {
const benchmark = await this.benchmarkService.deleteBenchmark({ const benchmark = await this.benchmarkService.deleteBenchmark({
dataSource, dataSource,
@ -120,7 +95,7 @@ export class BenchmarkController {
} }
@Get(':dataSource/:symbol/:startDateString') @Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol( public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,

100
apps/api/src/app/benchmark/benchmark.service.ts

@ -9,17 +9,22 @@ import {
MAX_CHART_ITEMS, MAX_CHART_ITEMS,
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
DATE_FORMAT,
calculateBenchmarkTrend
} from '@ghostfolio/common/helper';
import {
Benchmark,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkProperty, BenchmarkProperty,
BenchmarkResponse, BenchmarkResponse,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { BenchmarkTrend } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { format } from 'date-fns'; import { format, subDays } from 'date-fns';
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@ -45,9 +50,34 @@ export class BenchmarkService {
return 0; return 0;
} }
public async getBenchmarks({ useCache = true } = {}): Promise< public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) {
BenchmarkResponse['benchmarks'] const historicalData = await this.marketDataService.marketDataItems({
> { orderBy: {
date: 'desc'
},
where: {
dataSource,
symbol,
date: { gte: subDays(new Date(), 400) }
}
});
const fiftyDayAverage = calculateBenchmarkTrend({
historicalData,
days: 50
});
const twoHundredDayAverage = calculateBenchmarkTrend({
historicalData,
days: 200
});
return { trend50d: fiftyDayAverage, trend200d: twoHundredDayAverage };
}
public async getBenchmarks({
enableSharing = false,
useCache = true
} = {}): Promise<BenchmarkResponse['benchmarks']> {
let benchmarks: BenchmarkResponse['benchmarks']; let benchmarks: BenchmarkResponse['benchmarks'];
if (useCache) { if (useCache) {
@ -62,9 +92,16 @@ export class BenchmarkService {
} catch {} } catch {}
} }
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles(); const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
enableSharing
});
const promises: Promise<{ date: Date; marketPrice: number }>[] = []; const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
[];
const promisesBenchmarkTrends: Promise<{
trend50d: BenchmarkTrend;
trend200d: BenchmarkTrend;
}>[] = [];
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
@ -73,10 +110,18 @@ export class BenchmarkService {
}); });
for (const { dataSource, symbol } of benchmarkAssetProfiles) { for (const { dataSource, symbol } of benchmarkAssetProfiles) {
promises.push(this.marketDataService.getMax({ dataSource, symbol })); promisesAllTimeHighs.push(
this.marketDataService.getMax({ dataSource, symbol })
);
promisesBenchmarkTrends.push(
this.getBenchmarkTrends({ dataSource, symbol })
);
} }
const allTimeHighs = await Promise.all(promises); const [allTimeHighs, benchmarkTrends] = await Promise.all([
Promise.all(promisesAllTimeHighs),
Promise.all(promisesBenchmarkTrends)
]);
let storeInCache = true; let storeInCache = true;
benchmarks = allTimeHighs.map((allTimeHigh, index) => { benchmarks = allTimeHighs.map((allTimeHigh, index) => {
@ -93,6 +138,7 @@ export class BenchmarkService {
} else { } else {
storeInCache = false; storeInCache = false;
} }
return { return {
marketCondition: this.getMarketCondition( marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh performancePercentFromAllTimeHigh
@ -100,10 +146,12 @@ export class BenchmarkService {
name: benchmarkAssetProfiles[index].name, name: benchmarkAssetProfiles[index].name,
performances: { performances: {
allTimeHigh: { allTimeHigh: {
date: allTimeHigh.date, date: allTimeHigh?.date,
performancePercent: performancePercentFromAllTimeHigh performancePercent: performancePercentFromAllTimeHigh
} }
} },
trend50d: benchmarkTrends[index].trend50d,
trend200d: benchmarkTrends[index].trend200d
}; };
}); });
@ -118,14 +166,24 @@ export class BenchmarkService {
return benchmarks; return benchmarks;
} }
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> { public async getBenchmarkAssetProfiles({
enableSharing = false
} = {}): Promise<Partial<SymbolProfile>[]> {
const symbolProfileIds: string[] = ( const symbolProfileIds: string[] = (
((await this.propertyService.getByKey( ((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? [] )) as BenchmarkProperty[]) ?? []
).map(({ symbolProfileId }) => { )
return symbolProfileId; .filter((benchmark) => {
}); if (enableSharing) {
return benchmark.enableSharing;
}
return true;
})
.map(({ symbolProfileId }) => {
return symbolProfileId;
});
const assetProfiles = const assetProfiles =
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds); await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
@ -282,7 +340,15 @@ export class BenchmarkService {
}; };
} }
private getMarketCondition(aPerformanceInPercent: number) { private getMarketCondition(
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET'; aPerformanceInPercent: number
): Benchmark['marketCondition'] {
if (aPerformanceInPercent === 0) {
return 'ALL_TIME_HIGH';
} else if (aPerformanceInPercent <= -0.2) {
return 'BEAR_MARKET';
} else {
return 'NEUTRAL_MARKET';
}
} }
} }

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

@ -1,39 +1,18 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import type { RequestWithUser } from '@ghostfolio/common/types'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import { permissions } from '@ghostfolio/common/permissions';
Controller, import { Controller, Post, UseGuards } from '@nestjs/common';
HttpException,
Inject,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('cache') @Controller('cache')
export class CacheController { export class CacheController {
public constructor( public constructor(private readonly redisCacheService: RedisCacheService) {}
private readonly redisCacheService: RedisCacheService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@HasPermission(permissions.accessAdminControl)
@Post('flush') @Post('flush')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async flushCache(): Promise<void> { public async flushCache(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.redisCacheService.reset(); return this.redisCacheService.reset();
} }
} }

3
apps/api/src/app/exchange-rate/exchange-rate.controller.ts

@ -1,3 +1,4 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { import {
Controller, Controller,
@ -19,7 +20,7 @@ export class ExchangeRateController {
) {} ) {}
@Get(':symbol/:dateString') @Get(':symbol/:dateString')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getExchangeRate( public async getExchangeRate(
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string

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

@ -1,3 +1,4 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
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 } from '@nestjs/common';
@ -14,12 +15,13 @@ export class ExportController {
) {} ) {}
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async export( public async export(
@Query('activityIds') activityIds?: string[] @Query('activityIds') activityIds?: string[]
): Promise<Export> { ): Promise<Export> {
return this.exportService.export({ return this.exportService.export({
activityIds, activityIds,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id
}); });
} }

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

@ -13,9 +13,11 @@ export class ExportService {
public async export({ public async export({
activityIds, activityIds,
userCurrency,
userId userId
}: { }: {
activityIds?: string[]; activityIds?: string[];
userCurrency: string;
userId: string; userId: string;
}): Promise<Export> { }): Promise<Export> {
const accounts = ( const accounts = (
@ -39,10 +41,13 @@ export class ExportService {
} }
); );
let activities = await this.orderService.orders({ let { activities } = await this.orderService.getOrders({
include: { SymbolProfile: true }, userCurrency,
orderBy: { date: 'desc' }, userId,
where: { userId } includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
withExcludedAccounts: true
}); });
if (activityIds) { if (activityIds) {

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

@ -1,3 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -34,7 +36,8 @@ export class ImportController {
) {} ) {}
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@HasPermission(permissions.createOrder)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async import( public async import(
@ -42,11 +45,7 @@ export class ImportController {
@Query('dryRun') isDryRun?: boolean @Query('dryRun') isDryRun?: boolean
): Promise<ImportResponse> { ): Promise<ImportResponse> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.createAccount)
this.request.user.permissions,
permissions.createAccount
) ||
!hasPermission(this.request.user.permissions, permissions.createOrder)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -92,7 +91,7 @@ export class ImportController {
} }
@Get('dividends/:dataSource/:symbol') @Get('dividends/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async gatherDividends( public async gatherDividends(

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

@ -8,6 +8,7 @@ import {
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.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';
@ -25,7 +26,7 @@ import {
import { Injectable } from '@nestjs/common'; 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, format, isAfter, isSameDay, parseISO } from 'date-fns'; import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -33,6 +34,7 @@ import { v4 as uuidv4 } from 'uuid';
export class ImportService { export class ImportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -81,11 +83,13 @@ export class ImportService {
const value = new Big(quantity).mul(marketPrice).toNumber(); const value = new Big(quantity).mul(marketPrice).toNumber();
const date = parseDate(dateString);
const isDuplicate = orders.some((activity) => { const isDuplicate = orders.some((activity) => {
return ( return (
activity.accountId === Account?.id &&
activity.SymbolProfile.currency === assetProfile.currency && activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource && activity.SymbolProfile.dataSource === assetProfile.dataSource &&
isSameDay(activity.date, parseDate(dateString)) && isSameSecond(activity.date, date) &&
activity.quantity === quantity && activity.quantity === quantity &&
activity.SymbolProfile.symbol === assetProfile.symbol && activity.SymbolProfile.symbol === assetProfile.symbol &&
activity.type === 'DIVIDEND' && activity.type === 'DIVIDEND' &&
@ -99,6 +103,7 @@ export class ImportService {
return { return {
Account, Account,
date,
error, error,
quantity, quantity,
value, value,
@ -106,7 +111,6 @@ export class ImportService {
accountUserId: undefined, accountUserId: undefined,
comment: undefined, comment: undefined,
createdAt: undefined, createdAt: undefined,
date: parseDate(dateString),
fee: 0, fee: 0,
feeInBaseCurrency: 0, feeInBaseCurrency: 0,
id: assetProfile.id, id: assetProfile.id,
@ -232,6 +236,7 @@ export class ImportService {
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
activitiesDto, activitiesDto,
userCurrency,
userId userId
}); });
@ -455,15 +460,18 @@ export class ImportService {
private async extendActivitiesWithErrors({ private async extendActivitiesWithErrors({
activitiesDto, activitiesDto,
userCurrency,
userId userId
}: { }: {
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
userCurrency: string;
userId: string; userId: string;
}): Promise<Partial<Activity>[]> { }): Promise<Partial<Activity>[]> {
const existingActivities = await this.orderService.orders({ let { activities: existingActivities } = await this.orderService.getOrders({
include: { SymbolProfile: true }, userCurrency,
orderBy: { date: 'desc' }, userId,
where: { userId } includeDrafts: true,
withExcludedAccounts: true
}); });
return activitiesDto.map( return activitiesDto.map(
@ -479,12 +487,13 @@ export class ImportService {
type, type,
unitPrice unitPrice
}) => { }) => {
const date = parseISO(<string>(<unknown>dateString)); const date = parseISO(dateString);
const isDuplicate = existingActivities.some((activity) => { const isDuplicate = existingActivities.some((activity) => {
return ( return (
activity.accountId === accountId &&
activity.SymbolProfile.currency === currency && activity.SymbolProfile.currency === currency &&
activity.SymbolProfile.dataSource === dataSource && activity.SymbolProfile.dataSource === dataSource &&
isSameDay(activity.date, date) && isSameSecond(activity.date, date) &&
activity.fee === fee && activity.fee === fee &&
activity.quantity === quantity && activity.quantity === quantity &&
activity.SymbolProfile.symbol === symbol && activity.SymbolProfile.symbol === symbol &&
@ -568,6 +577,12 @@ export class ImportService {
index, index,
{ currency, dataSource, symbol } { currency, dataSource, symbol }
] of uniqueActivitiesDto.entries()) { ] of uniqueActivitiesDto.entries()) {
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid`
);
}
if (dataSource !== 'MANUAL') { if (dataSource !== 'MANUAL') {
const assetProfile = ( const assetProfile = (
await this.dataProviderService.getAssetProfiles([ await this.dataProviderService.getAssetProfiles([

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

@ -8,14 +8,12 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT,
PROPERTY_BETTER_UPTIME_MONITOR_ID, PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
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,
PROPERTY_STRIPE_CONFIG, PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE,
ghostfolioFearAndGreedIndexDataSource ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
@ -58,7 +56,6 @@ export class InfoService {
const platforms = await this.platformService.getPlatforms({ const platforms = await this.platformService.getPlatforms({
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}); });
let systemMessage: string;
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
@ -104,10 +101,6 @@ export class InfoService {
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) { if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
globalPermissions.push(permissions.enableSystemMessage); globalPermissions.push(permissions.enableSystemMessage);
systemMessage = (await this.propertyService.getByKey(
PROPERTY_SYSTEM_MESSAGE
)) as string;
} }
const isUserSignupEnabled = const isUserSignupEnabled =
@ -135,7 +128,6 @@ export class InfoService {
platforms, platforms,
statistics, statistics,
subscriptions, subscriptions,
systemMessage,
tags, tags,
baseCurrency: DEFAULT_CURRENCY, baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies() currencies: this.exchangeRateDataService.getCurrencies()
@ -169,7 +161,7 @@ export class InfoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { pull_count } = await got( const { pull_count } = await got(
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`, `https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
@ -194,7 +186,7 @@ export class InfoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { body } = await got('https://github.com/ghostfolio/ghostfolio', { const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
// @ts-ignore // @ts-ignore
@ -221,7 +213,7 @@ export class InfoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { stargazers_count } = await got( const { stargazers_count } = await got(
`https://api.github.com/repos/ghostfolio/ghostfolio`, `https://api.github.com/repos/ghostfolio/ghostfolio`,
@ -349,7 +341,7 @@ export class InfoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { data } = await got( const { data } = await got(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(

5
apps/api/src/app/logo/logo.service.ts

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -9,6 +9,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable() @Injectable()
export class LogoService { export class LogoService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -46,7 +47,7 @@ export class LogoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
return got( return got(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,

5
apps/api/src/app/order/create-order.dto.ts

@ -13,7 +13,6 @@ import {
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsPositive,
IsString, IsString,
Min Min
} from 'class-validator'; } from 'class-validator';
@ -54,7 +53,7 @@ export class CreateOrderDto {
fee: number; fee: number;
@IsNumber() @IsNumber()
@IsPositive() @Min(0)
quantity: number; quantity: number;
@IsString() @IsString()
@ -68,7 +67,7 @@ export class CreateOrderDto {
type: Type; type: Type;
@IsNumber() @IsNumber()
@IsPositive() @Min(0)
unitPrice: number; unitPrice: number;
@IsBoolean() @IsBoolean()

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

@ -2,6 +2,7 @@ import { OrderWithAccount } from '@ghostfolio/common/types';
export interface Activities { export interface Activities {
activities: Activity[]; activities: Activity[];
count: number;
} }
export interface Activity extends OrderWithAccount { export interface Activity extends OrderWithAccount {

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

@ -1,3 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
@ -24,7 +26,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Order as OrderModel } from '@prisma/client'; import { Order as OrderModel, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -44,24 +46,16 @@ export class OrderController {
) {} ) {}
@Delete() @Delete()
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrders(): Promise<number> { public async deleteOrders(): Promise<number> {
if (
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.orderService.deleteOrders({ return this.orderService.deleteOrders({
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
const order = await this.orderService.order({ id }); const order = await this.orderService.order({ id });
@ -82,7 +76,7 @@ export class OrderController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders( public async getAllOrders(
@ -90,6 +84,8 @@ export class OrderController {
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('take') take?: number @Query('take') take?: number
): Promise<Activities> { ): Promise<Activities> {
@ -103,8 +99,10 @@ export class OrderController {
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
const activities = await this.orderService.getOrders({ const { activities, count } = await this.orderService.getOrders({
filters, filters,
sortColumn,
sortDirection,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
skip: isNaN(skip) ? undefined : skip, skip: isNaN(skip) ? undefined : skip,
@ -113,22 +111,14 @@ export class OrderController {
withExcludedAccounts: true withExcludedAccounts: true
}); });
return { activities }; return { activities, count };
} }
@HasPermission(permissions.createOrder)
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
if (
!hasPermission(this.request.user.permissions, permissions.createOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const order = await this.orderService.createOrder({ const order = await this.orderService.createOrder({
...data, ...data,
date: parseISO(data.date), date: parseISO(data.date),
@ -166,19 +156,16 @@ export class OrderController {
return order; return order;
} }
@HasPermission(permissions.updateOrder)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
const originalOrder = await this.orderService.order({ const originalOrder = await this.orderService.order({
id id
}); });
if ( if (!originalOrder || originalOrder.userId !== this.request.user.id) {
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
!originalOrder ||
originalOrder.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN

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

@ -1,8 +1,8 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';

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

@ -25,7 +25,7 @@ import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Activity } from './interfaces/activities.interface'; import { Activities } from './interfaces/activities.interface';
@Injectable() @Injectable()
export class OrderService { export class OrderService {
@ -37,34 +37,6 @@ export class OrderService {
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> {
return this.prismaService.order.findUnique({
where: orderWhereUniqueInput
});
}
public async orders(params: {
include?: Prisma.OrderInclude;
skip?: number;
take?: number;
cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput;
orderBy?: Prisma.OrderOrderByWithRelationInput;
}): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prismaService.order.findMany({
cursor,
include,
orderBy,
skip,
take,
where
});
}
public async createOrder( public async createOrder(
data: Prisma.OrderCreateInput & { data: Prisma.OrderCreateInput & {
accountId?: string; accountId?: string;
@ -231,6 +203,8 @@ export class OrderService {
filters, filters,
includeDrafts = false, includeDrafts = false,
skip, skip,
sortColumn,
sortDirection,
take = Number.MAX_SAFE_INTEGER, take = Number.MAX_SAFE_INTEGER,
types, types,
userCurrency, userCurrency,
@ -240,12 +214,17 @@ export class OrderService {
filters?: Filter[]; filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
skip?: number; skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number; take?: number;
types?: TypeOfOrder[]; types?: TypeOfOrder[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<Activity[]> { }): Promise<Activities> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' }
];
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
const { const {
@ -307,6 +286,10 @@ export class OrderService {
}; };
} }
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
}
if (types) { if (types) {
where.OR = types.map((type) => { where.OR = types.map((type) => {
return { return {
@ -317,8 +300,9 @@ export class OrderService {
}); });
} }
return ( const [orders, count] = await Promise.all([
await this.orders({ this.orders({
orderBy,
skip, skip,
take, take,
where, where,
@ -332,10 +316,12 @@ export class OrderService {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true, SymbolProfile: true,
tags: true tags: true
}, }
orderBy: { date: 'asc' } }),
}) this.prismaService.order.count({ where })
) ]);
const activities = orders
.filter((order) => { .filter((order) => {
return ( return (
withExcludedAccounts || withExcludedAccounts ||
@ -361,6 +347,16 @@ export class OrderService {
) )
}; };
}); });
return { activities, count };
}
public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> {
return this.prismaService.order.findUnique({
where: orderWhereUniqueInput
});
} }
public async updateOrder({ public async updateOrder({
@ -439,4 +435,24 @@ export class OrderService {
where where
}); });
} }
private async orders(params: {
include?: Prisma.OrderInclude;
skip?: number;
take?: number;
cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput;
orderBy?: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput>;
}): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prismaService.order.findMany({
cursor,
include,
orderBy,
skip,
take,
where
});
}
} }

6
apps/api/src/app/order/update-order.dto.ts

@ -8,12 +8,10 @@ import {
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsArray, IsArray,
IsBoolean,
IsEnum, IsEnum,
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsPositive,
IsString, IsString,
Min Min
} from 'class-validator'; } from 'class-validator';
@ -56,7 +54,7 @@ export class UpdateOrderDto {
id: string; id: string;
@IsNumber() @IsNumber()
@IsPositive() @Min(0)
quantity: number; quantity: number;
@IsString() @IsString()
@ -70,6 +68,6 @@ export class UpdateOrderDto {
type: Type; type: Type;
@IsNumber() @IsNumber()
@IsPositive() @Min(0)
unitPrice: number; unitPrice: number;
} }

50
apps/api/src/app/platform/platform.controller.ts

@ -1,18 +1,17 @@
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import type { RequestWithUser } from '@ghostfolio/common/types'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
HttpException, HttpException,
Inject,
Param, Param,
Post, Post,
Put, Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Platform } from '@prisma/client'; import { Platform } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -23,49 +22,30 @@ import { UpdatePlatformDto } from './update-platform.dto';
@Controller('platform') @Controller('platform')
export class PlatformController { export class PlatformController {
public constructor( public constructor(private readonly platformService: PlatformService) {}
private readonly platformService: PlatformService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPlatforms() { public async getPlatforms() {
return this.platformService.getPlatformsWithAccountCount(); return this.platformService.getPlatformsWithAccountCount();
} }
@HasPermission(permissions.createPlatform)
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createPlatform( public async createPlatform(
@Body() data: CreatePlatformDto @Body() data: CreatePlatformDto
): Promise<Platform> { ): Promise<Platform> {
if (
!hasPermission(this.request.user.permissions, permissions.createPlatform)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.platformService.createPlatform(data); return this.platformService.createPlatform(data);
} }
@HasPermission(permissions.updatePlatform)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updatePlatform( public async updatePlatform(
@Param('id') id: string, @Param('id') id: string,
@Body() data: UpdatePlatformDto @Body() data: UpdatePlatformDto
) { ) {
if (
!hasPermission(this.request.user.permissions, permissions.updatePlatform)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalPlatform = await this.platformService.getPlatform({ const originalPlatform = await this.platformService.getPlatform({
id id
}); });
@ -88,17 +68,9 @@ export class PlatformController {
} }
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.deletePlatform)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deletePlatform(@Param('id') id: string) { public async deletePlatform(@Param('id') id: string) {
if (
!hasPermission(this.request.user.permissions, permissions.deletePlatform)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalPlatform = await this.platformService.getPlatform({ const originalPlatform = await this.platformService.getPlatform({
id id
}); });

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

@ -61,6 +61,7 @@ export const CurrentRateServiceMock = {
for (const dataGatheringItem of dataGatheringItems) { for (const dataGatheringItem of dataGatheringItems) {
values.push({ values.push({
date, date,
dataSource: dataGatheringItem.dataSource,
marketPriceInBaseCurrency: mockGetValue( marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol, dataGatheringItem.symbol,
date date
@ -74,6 +75,7 @@ export const CurrentRateServiceMock = {
for (const dataGatheringItem of dataGatheringItems) { for (const dataGatheringItem of dataGatheringItems) {
values.push({ values.push({
date, date,
dataSource: dataGatheringItem.dataSource,
marketPriceInBaseCurrency: mockGetValue( marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol, dataGatheringItem.symbol,
date date

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

@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
@ -25,30 +26,30 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
getRange: ({ getRange: ({
dateRangeEnd, dateRangeEnd,
dateRangeStart, dateRangeStart,
symbols uniqueAssets
}: { }: {
dateRangeEnd: Date; dateRangeEnd: Date;
dateRangeStart: Date; dateRangeStart: Date;
symbols: string[]; uniqueAssets: UniqueAsset[];
}) => { }) => {
return Promise.resolve<MarketData[]>([ return Promise.resolve<MarketData[]>([
{ {
createdAt: dateRangeStart, createdAt: dateRangeStart,
dataSource: DataSource.YAHOO, dataSource: uniqueAssets[0].dataSource,
date: dateRangeStart, date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d', id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902, marketPrice: 1841.823902,
state: 'CLOSE', state: 'CLOSE',
symbol: symbols[0] symbol: uniqueAssets[0].symbol
}, },
{ {
createdAt: dateRangeEnd, createdAt: dateRangeEnd,
dataSource: DataSource.YAHOO, dataSource: uniqueAssets[0].dataSource,
date: dateRangeEnd, date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56', id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966, marketPrice: 1847.839966,
state: 'CLOSE', state: 'CLOSE',
symbol: symbols[0] symbol: uniqueAssets[0].symbol
} }
]); ]);
} }
@ -134,6 +135,7 @@ describe('CurrentRateService', () => {
errors: [], errors: [],
values: [ values: [
{ {
dataSource: 'YAHOO',
date: undefined, date: undefined,
marketPriceInBaseCurrency: 1841.823902, marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN' symbol: 'AMZN'

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

@ -2,7 +2,11 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
ResponseError,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns'; import { isBefore, isToday } from 'date-fns';
import { flatten, isEmpty, uniqBy } from 'lodash'; import { flatten, isEmpty, uniqBy } from 'lodash';
@ -52,6 +56,7 @@ export class CurrentRateService {
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) { if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
result.push({ result.push({
dataSource: dataGatheringItem.dataSource,
date: today, date: today,
marketPriceInBaseCurrency: marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
@ -75,27 +80,30 @@ export class CurrentRateService {
); );
} }
const symbols = dataGatheringItems.map((dataGatheringItem) => { const uniqueAssets: UniqueAsset[] = dataGatheringItems.map(
return dataGatheringItem.symbol; ({ dataSource, symbol }) => {
}); return { dataSource, symbol };
}
);
promises.push( promises.push(
this.marketDataService this.marketDataService
.getRange({ .getRange({
dateQuery, dateQuery,
symbols uniqueAssets
}) })
.then((data) => { .then((data) => {
return data.map((marketDataItem) => { return data.map(({ dataSource, date, marketPrice, symbol }) => {
return { return {
date: marketDataItem.date, dataSource,
date,
symbol,
marketPriceInBaseCurrency: marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
marketDataItem.marketPrice, marketPrice,
currencies[marketDataItem.symbol], currencies[symbol],
userCurrency userCurrency
), )
symbol: marketDataItem.symbol
}; };
}); });
}) })
@ -112,7 +120,7 @@ export class CurrentRateService {
}; };
if (!isEmpty(quoteErrors)) { if (!isEmpty(quoteErrors)) {
for (const { symbol } of quoteErrors) { for (const { dataSource, symbol } of quoteErrors) {
try { try {
// If missing quote, fallback to the latest available historical market price // If missing quote, fallback to the latest available historical market price
let value: GetValueObject = response.values.find((currentValue) => { let value: GetValueObject = response.values.find((currentValue) => {
@ -121,6 +129,7 @@ export class CurrentRateService {
if (!value) { if (!value) {
value = { value = {
dataSource,
symbol, symbol,
date: today, date: today,
marketPriceInBaseCurrency: 0 marketPriceInBaseCurrency: 0

5
apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts

@ -1,5 +1,6 @@
export interface GetValueObject { import { UniqueAsset } from '@ghostfolio/common/interfaces';
export interface GetValueObject extends UniqueAsset {
date: Date; date: Date;
marketPriceInBaseCurrency: number; marketPriceInBaseCurrency: number;
symbol: string;
} }

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

@ -92,6 +92,7 @@ describe('PortfolioCalculator', () => {
marketPrice: 148.9, marketPrice: 148.9,
quantity: new Big('0'), quantity: new Big('0'),
symbol: 'BALN.SW', symbol: 'BALN.SW',
timeWeightedInvestment: new Big('285.8'),
transactionCount: 2 transactionCount: 2
} }
], ],

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

@ -81,6 +81,7 @@ describe('PortfolioCalculator', () => {
marketPrice: 148.9, marketPrice: 148.9,
quantity: new Big('2'), quantity: new Big('2'),
symbol: 'BALN.SW', symbol: 'BALN.SW',
timeWeightedInvestment: new Big('273.2'),
transactionCount: 1 transactionCount: 1
} }
], ],

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

@ -73,10 +73,10 @@ describe('PortfolioCalculator', () => {
currentValue: new Big('13657.2'), currentValue: new Big('13657.2'),
errors: [], errors: [],
grossPerformance: new Big('27172.74'), grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'), grossPerformancePercentage: new Big('42.41978276196153750666'),
hasErrors: false, hasErrors: false,
netPerformance: new Big('27172.74'), netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.40043067128546016291'), netPerformancePercentage: new Big('42.41978276196153750666'),
positions: [ positions: [
{ {
averagePrice: new Big('320.43'), averagePrice: new Big('320.43'),
@ -85,13 +85,14 @@ describe('PortfolioCalculator', () => {
fee: new Big('0'), fee: new Big('0'),
firstBuyDate: '2015-01-01', firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'), grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'), grossPerformancePercentage: new Big('42.41978276196153750666'),
investment: new Big('320.43'), investment: new Big('320.43'),
netPerformance: new Big('27172.74'), netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.40043067128546016291'), netPerformancePercentage: new Big('42.41978276196153750666'),
marketPrice: 13657.2, marketPrice: 13657.2,
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'BTCUSD', symbol: 'BTCUSD',
timeWeightedInvestment: new Big('640.56763686131386861314'),
transactionCount: 2 transactionCount: 2
} }
], ],

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

@ -73,10 +73,10 @@ describe('PortfolioCalculator', () => {
currentValue: new Big('87.8'), currentValue: new Big('87.8'),
errors: [], errors: [],
grossPerformance: new Big('21.93'), grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'), grossPerformancePercentage: new Big('0.15113417083448194384'),
hasErrors: false, hasErrors: false,
netPerformance: new Big('17.68'), netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'), netPerformancePercentage: new Big('0.12184460284330327256'),
positions: [ positions: [
{ {
averagePrice: new Big('75.80'), averagePrice: new Big('75.80'),
@ -85,13 +85,14 @@ describe('PortfolioCalculator', () => {
fee: new Big('4.25'), fee: new Big('4.25'),
firstBuyDate: '2022-03-07', firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'), grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'), grossPerformancePercentage: new Big('0.15113417083448194384'),
investment: new Big('75.80'), investment: new Big('75.80'),
netPerformance: new Big('17.68'), netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'), netPerformancePercentage: new Big('0.12184460284330327256'),
marketPrice: 87.8, marketPrice: 87.8,
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'NOVN.SW', symbol: 'NOVN.SW',
timeWeightedInvestment: new Big('145.10285714285714285714'),
transactionCount: 2 transactionCount: 2
} }
], ],

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

@ -112,6 +112,7 @@ describe('PortfolioCalculator', () => {
marketPrice: 87.8, marketPrice: 87.8,
quantity: new Big('0'), quantity: new Big('0'),
symbol: 'NOVN.SW', symbol: 'NOVN.SW',
timeWeightedInvestment: new Big('151.6'),
transactionCount: 2 transactionCount: 2
} }
], ],

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

@ -15,6 +15,7 @@ import {
addMilliseconds, addMilliseconds,
addMonths, addMonths,
addYears, addYears,
differenceInDays,
endOfDay, endOfDay,
format, format,
isAfter, isAfter,
@ -43,7 +44,7 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in
import { TransactionPoint } from './interfaces/transaction-point.interface'; import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculator { export class PortfolioCalculator {
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT = private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT =
true; true;
private static readonly ENABLE_LOGGING = false; private static readonly ENABLE_LOGGING = false;
@ -238,12 +239,13 @@ export class PortfolioCalculator {
} }
} }
const valuesByDate: { const accumulatedValuesByDate: {
[date: string]: { [date: string]: {
maxTotalInvestmentValue: Big; maxTotalInvestmentValue: Big;
totalCurrentValue: Big; totalCurrentValue: Big;
totalInvestmentValue: Big; totalInvestmentValue: Big;
totalNetPerformanceValue: Big; totalNetPerformanceValue: Big;
totalTimeWeightedInvestmentValue: Big;
}; };
} = {}; } = {};
@ -253,6 +255,7 @@ export class PortfolioCalculator {
investmentValues: { [date: string]: Big }; investmentValues: { [date: string]: Big };
maxInvestmentValues: { [date: string]: Big }; maxInvestmentValues: { [date: string]: Big };
netPerformanceValues: { [date: string]: Big }; netPerformanceValues: { [date: string]: Big };
timeWeightedInvestmentValues: { [date: string]: Big };
}; };
} = {}; } = {};
@ -261,7 +264,8 @@ export class PortfolioCalculator {
currentValues, currentValues,
investmentValues, investmentValues,
maxInvestmentValues, maxInvestmentValues,
netPerformanceValues netPerformanceValues,
timeWeightedInvestmentValues
} = this.getSymbolMetrics({ } = this.getSymbolMetrics({
end, end,
marketSymbolMap, marketSymbolMap,
@ -275,7 +279,8 @@ export class PortfolioCalculator {
currentValues, currentValues,
investmentValues, investmentValues,
maxInvestmentValues, maxInvestmentValues,
netPerformanceValues netPerformanceValues,
timeWeightedInvestmentValues
}; };
} }
@ -293,38 +298,50 @@ export class PortfolioCalculator {
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0); symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
const netPerformanceValue = const netPerformanceValue =
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0); symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
const timeWeightedInvestmentValue =
symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0);
valuesByDate[dateString] = { accumulatedValuesByDate[dateString] = {
totalCurrentValue: ( totalCurrentValue: (
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0) accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue), ).add(currentValue),
totalInvestmentValue: ( totalInvestmentValue: (
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0) accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
new Big(0)
).add(investmentValue), ).add(investmentValue),
totalTimeWeightedInvestmentValue: (
accumulatedValuesByDate[dateString]
?.totalTimeWeightedInvestmentValue ?? new Big(0)
).add(timeWeightedInvestmentValue),
maxTotalInvestmentValue: ( maxTotalInvestmentValue: (
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0) accumulatedValuesByDate[dateString]?.maxTotalInvestmentValue ??
new Big(0)
).add(maxInvestmentValue), ).add(maxInvestmentValue),
totalNetPerformanceValue: ( totalNetPerformanceValue: (
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0) accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
new Big(0)
).add(netPerformanceValue) ).add(netPerformanceValue)
}; };
} }
} }
return Object.entries(valuesByDate).map(([date, values]) => { return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
const { const {
maxTotalInvestmentValue, maxTotalInvestmentValue,
totalCurrentValue, totalCurrentValue,
totalInvestmentValue, totalInvestmentValue,
totalNetPerformanceValue totalNetPerformanceValue,
totalTimeWeightedInvestmentValue
} = values; } = values;
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0) let investmentValue =
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
? totalTimeWeightedInvestmentValue
: maxTotalInvestmentValue;
const netPerformanceInPercentage = investmentValue.eq(0)
? 0 ? 0
: totalNetPerformanceValue : totalNetPerformanceValue.div(investmentValue).mul(100).toNumber();
.div(maxTotalInvestmentValue)
.mul(100)
.toNumber();
return { return {
date, date,
@ -447,7 +464,6 @@ export class PortfolioCalculator {
if (firstIndex > 0) { if (firstIndex > 0) {
firstIndex--; firstIndex--;
} }
const initialValues: { [symbol: string]: Big } = {};
const positions: TimelinePosition[] = []; const positions: TimelinePosition[] = [];
let hasAnySymbolMetricsErrors = false; let hasAnySymbolMetricsErrors = false;
@ -461,9 +477,9 @@ export class PortfolioCalculator {
grossPerformance, grossPerformance,
grossPerformancePercentage, grossPerformancePercentage,
hasErrors, hasErrors,
initialValue,
netPerformance, netPerformance,
netPerformancePercentage netPerformancePercentage,
timeWeightedInvestment
} = this.getSymbolMetrics({ } = this.getSymbolMetrics({
end, end,
marketSymbolMap, marketSymbolMap,
@ -472,9 +488,9 @@ export class PortfolioCalculator {
}); });
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
initialValues[item.symbol] = initialValue;
positions.push({ positions.push({
timeWeightedInvestment,
averagePrice: item.quantity.eq(0) averagePrice: item.quantity.eq(0)
? new Big(0) ? new Big(0)
: item.investment.div(item.quantity), : item.investment.div(item.quantity),
@ -509,7 +525,7 @@ export class PortfolioCalculator {
} }
} }
const overall = this.calculateOverallPerformance(positions, initialValues); const overall = this.calculateOverallPerformance(positions);
return { return {
...overall, ...overall,
@ -732,18 +748,13 @@ export class PortfolioCalculator {
}; };
} }
private calculateOverallPerformance( private calculateOverallPerformance(positions: TimelinePosition[]) {
positions: TimelinePosition[],
initialValues: { [symbol: string]: Big }
) {
let currentValue = new Big(0); let currentValue = new Big(0);
let grossPerformance = new Big(0); let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
let hasErrors = false; let hasErrors = false;
let netPerformance = new Big(0); let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
let sumOfWeights = new Big(0);
let totalInvestment = new Big(0); let totalInvestment = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
for (const currentPosition of positions) { for (const currentPosition of positions) {
if (currentPosition.marketPrice) { if (currentPosition.marketPrice) {
@ -766,21 +777,9 @@ export class PortfolioCalculator {
hasErrors = true; hasErrors = true;
} }
if (currentPosition.grossPerformancePercentage) { if (currentPosition.timeWeightedInvestment) {
// Use the average from the initial value and the current investment as totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
// a weight currentPosition.timeWeightedInvestment
const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
.plus(currentPosition.investment)
.div(2);
sumOfWeights = sumOfWeights.plus(weight);
grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(weight)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(weight)
); );
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
Logger.warn( Logger.warn(
@ -791,22 +790,18 @@ export class PortfolioCalculator {
} }
} }
if (sumOfWeights.gt(0)) {
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
} else {
grossPerformancePercentage = new Big(0);
netPerformancePercentage = new Big(0);
}
return { return {
currentValue, currentValue,
grossPerformance, grossPerformance,
grossPerformancePercentage,
hasErrors, hasErrors,
netPerformance, netPerformance,
netPerformancePercentage, totalInvestment,
totalInvestment netPerformancePercentage: totalTimeWeightedInvestment.eq(0)
? new Big(0)
: netPerformance.div(totalTimeWeightedInvestment),
grossPerformancePercentage: totalTimeWeightedInvestment.eq(0)
? new Big(0)
: grossPerformance.div(totalTimeWeightedInvestment)
}; };
} }
@ -1018,6 +1013,7 @@ export class PortfolioCalculator {
let averagePriceAtEndDate = new Big(0); let averagePriceAtEndDate = new Big(0);
let averagePriceAtStartDate = new Big(0); let averagePriceAtStartDate = new Big(0);
const currentValues: { [date: string]: Big } = {};
let feesAtStartDate = new Big(0); let feesAtStartDate = new Big(0);
let fees = new Big(0); let fees = new Big(0);
let grossPerformance = new Big(0); let grossPerformance = new Big(0);
@ -1025,12 +1021,12 @@ export class PortfolioCalculator {
let grossPerformanceFromSells = new Big(0); let grossPerformanceFromSells = new Big(0);
let initialValue: Big; let initialValue: Big;
let investmentAtStartDate: Big; let investmentAtStartDate: Big;
const currentValues: { [date: string]: Big } = {};
const investmentValues: { [date: string]: Big } = {}; const investmentValues: { [date: string]: Big } = {};
const maxInvestmentValues: { [date: string]: Big } = {}; const maxInvestmentValues: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0); let lastAveragePrice = new Big(0);
let maxTotalInvestment = new Big(0); let maxTotalInvestment = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {}; const netPerformanceValues: { [date: string]: Big } = {};
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
let totalInvestment = new Big(0); let totalInvestment = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0); let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
let totalUnits = new Big(0); let totalUnits = new Big(0);
@ -1122,6 +1118,9 @@ export class PortfolioCalculator {
return order.itemType === 'end'; return order.itemType === 'end';
}); });
let totalInvestmentDays = 0;
let sumOfTimeWeightedInvestments = new Big(0);
for (let i = 0; i < orders.length; i += 1) { for (let i = 0; i < orders.length; i += 1) {
const order = orders[i]; const order = orders[i];
@ -1162,11 +1161,11 @@ export class PortfolioCalculator {
order.type === 'BUY' order.type === 'BUY'
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) ? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
: totalUnits.gt(0) : totalUnits.gt(0)
? totalInvestment ? totalInvestment
.div(totalUnits) .div(totalUnits)
.mul(order.quantity) .mul(order.quantity)
.mul(this.getFactor(order.type)) .mul(this.getFactor(order.type))
: new Big(0); : new Big(0);
if (PortfolioCalculator.ENABLE_LOGGING) { if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber()); console.log('totalInvestment', totalInvestment.toNumber());
@ -1174,6 +1173,7 @@ export class PortfolioCalculator {
console.log('transactionInvestment', transactionInvestment.toNumber()); console.log('transactionInvestment', transactionInvestment.toNumber());
} }
const totalInvestmentBeforeTransaction = totalInvestment;
totalInvestment = totalInvestment.plus(transactionInvestment); totalInvestment = totalInvestment.plus(transactionInvestment);
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) { if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
@ -1243,14 +1243,48 @@ export class PortfolioCalculator {
grossPerformanceAtStartDate = grossPerformance; grossPerformanceAtStartDate = grossPerformance;
} }
if (isChartMode && i > indexOfStartOrder) { if (i > indexOfStartOrder) {
currentValues[order.date] = valueOfInvestment; // Only consider periods with an investment for the calculation of
netPerformanceValues[order.date] = grossPerformance // the time weighted investment
.minus(grossPerformanceAtStartDate) if (totalInvestmentBeforeTransaction.gt(0)) {
.minus(fees.minus(feesAtStartDate)); // Calculate the number of days since the previous order
const orderDate = new Date(order.date);
const previousOrderDate = new Date(orders[i - 1].date);
let daysSinceLastOrder = differenceInDays(
orderDate,
previousOrderDate
);
// Set to at least 1 day, otherwise the transactions on the same day
// would not be considered in the time weighted calculation
if (daysSinceLastOrder <= 0) {
daysSinceLastOrder = 1;
}
// Sum up the total investment days since the start date to calculate
// the time weighted investment
totalInvestmentDays += daysSinceLastOrder;
investmentValues[order.date] = totalInvestment; sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
maxInvestmentValues[order.date] = maxTotalInvestment; totalInvestmentBeforeTransaction.mul(daysSinceLastOrder)
);
}
if (isChartMode) {
currentValues[order.date] = valueOfInvestment;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
investmentValues[order.date] = totalInvestment;
maxInvestmentValues[order.date] = maxTotalInvestment;
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
}
} }
if (PortfolioCalculator.ENABLE_LOGGING) { if (PortfolioCalculator.ENABLE_LOGGING) {
@ -1274,50 +1308,79 @@ export class PortfolioCalculator {
.minus(grossPerformanceAtStartDate) .minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate)); .minus(fees.minus(feesAtStartDate));
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus( const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
maxTotalInvestment.minus(investmentAtStartDate) maxTotalInvestment.minus(investmentAtStartDate)
); );
const grossPerformancePercentage = let grossPerformancePercentage: Big;
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) || if (
averagePriceAtEndDate.eq(0) || PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
orders[indexOfStartOrder].unitPrice.eq(0) ) {
? maxInvestmentBetweenStartAndEndDate.gt(0) grossPerformancePercentage =
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate) timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
: new Big(0) ? totalGrossPerformance.div(
: // This formula has the issue that buying more units with a price timeWeightedAverageInvestmentBetweenStartAndEndDate
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
) )
.minus(1); : new Big(0);
} else {
grossPerformancePercentage =
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
}
const feesPerUnit = totalUnits.gt(0) const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits) ? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0); : new Big(0);
const netPerformancePercentage = let netPerformancePercentage: Big;
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) || if (
averagePriceAtEndDate.eq(0) || PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
orders[indexOfStartOrder].unitPrice.eq(0) ) {
? maxInvestmentBetweenStartAndEndDate.gt(0) netPerformancePercentage =
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate) timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
: new Big(0) ? totalNetPerformance.div(
: // This formula has the issue that buying more units with a price timeWeightedAverageInvestmentBetweenStartAndEndDate
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.minus(feesPerUnit)
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
) )
.minus(1); : new Big(0);
} else {
netPerformancePercentage =
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.minus(feesPerUnit)
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
}
if (PortfolioCalculator.ENABLE_LOGGING) { if (PortfolioCalculator.ENABLE_LOGGING) {
console.log( console.log(
@ -1330,6 +1393,9 @@ export class PortfolioCalculator {
2 2
)} -> ${averagePriceAtEndDate.toFixed(2)} )} -> ${averagePriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)} Total investment: ${totalInvestment.toFixed(2)}
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
2
)}
Max. total investment: ${maxTotalInvestment.toFixed(2)} Max. total investment: ${maxTotalInvestment.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed( Gross performance: ${totalGrossPerformance.toFixed(
2 2
@ -1349,9 +1415,12 @@ export class PortfolioCalculator {
maxInvestmentValues, maxInvestmentValues,
netPerformancePercentage, netPerformancePercentage,
netPerformanceValues, netPerformanceValues,
timeWeightedInvestmentValues,
grossPerformance: totalGrossPerformance, grossPerformance: totalGrossPerformance,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance netPerformance: totalNetPerformance,
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate
}; };
} }

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

@ -1,5 +1,6 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
@ -61,7 +62,7 @@ export class PortfolioController {
) {} ) {}
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@ -204,7 +205,7 @@ export class PortfolioController {
} }
@Get('dividends') @Get('dividends')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getDividends( public async getDividends(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@ -254,7 +255,7 @@ export class PortfolioController {
} }
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getInvestments( public async getInvestments(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@ -315,7 +316,7 @@ export class PortfolioController {
} }
@Get('performance') @Get('performance')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2') @Version('2')
public async getPerformanceV2( public async getPerformanceV2(
@ -346,16 +347,34 @@ export class PortfolioController {
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
performanceInformation.chart = performanceInformation.chart.map( performanceInformation.chart = performanceInformation.chart.map(
({ date, netPerformanceInPercentage, totalInvestment, value }) => { ({
date,
netPerformanceInPercentage,
netWorth,
totalInvestment,
value
}) => {
return { return {
date, date,
netPerformanceInPercentage, netPerformanceInPercentage,
totalInvestment: new Big(totalInvestment) netWorthInPercentage:
.div(performanceInformation.performance.totalInvestment) performanceInformation.performance.currentNetWorth === 0
.toNumber(), ? 0
valueInPercentage: new Big(value) : new Big(netWorth)
.div(performanceInformation.performance.currentValue) .div(performanceInformation.performance.currentNetWorth)
.toNumber() .toNumber(),
totalInvestment:
performanceInformation.performance.totalInvestment === 0
? 0
: new Big(totalInvestment)
.div(performanceInformation.performance.totalInvestment)
.toNumber(),
valueInPercentage:
performanceInformation.performance.currentValue === 0
? 0
: new Big(value)
.div(performanceInformation.performance.currentValue)
.toNumber()
}; };
} }
); );
@ -365,6 +384,7 @@ export class PortfolioController {
[ [
'currentGrossPerformance', 'currentGrossPerformance',
'currentNetPerformance', 'currentNetPerformance',
'currentNetWorth',
'currentValue', 'currentValue',
'totalInvestment' 'totalInvestment'
] ]
@ -386,7 +406,7 @@ export class PortfolioController {
} }
@Get('positions') @Get('positions')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions( public async getPositions(
@ -481,7 +501,7 @@ export class PortfolioController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPosition( public async getPosition(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource, @Param('dataSource') dataSource,
@ -504,7 +524,7 @@ export class PortfolioController {
} }
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport( public async getReport(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<PortfolioReport> { ): Promise<PortfolioReport> {

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

@ -1,8 +1,8 @@
import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';

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

@ -1,3 +1,4 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
@ -67,14 +68,16 @@ import {
isBefore, isBefore,
isSameMonth, isSameMonth,
isSameYear, isSameYear,
isValid,
max, max,
min,
parseISO, parseISO,
set, set,
setDayOfYear, setDayOfYear,
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty, sortBy, uniq, uniqBy } from 'lodash'; import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash';
import { import {
HistoricalDataContainer, HistoricalDataContainer,
@ -91,6 +94,7 @@ const europeMarkets = require('../../assets/countries/europe-markets.json');
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly currentRateService: CurrentRateService, private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -114,8 +118,12 @@ export class PortfolioService {
}): Promise<AccountWithValue[]> { }): Promise<AccountWithValue[]> {
const where: Prisma.AccountWhereInput = { userId: userId }; const where: Prisma.AccountWhereInput = { userId: userId };
if (filters?.[0].id && filters?.[0].type === 'ACCOUNT') { const accountFilter = filters?.find(({ type }) => {
where.id = filters[0].id; return type === 'ACCOUNT';
});
if (accountFilter) {
where.id = accountFilter.id;
} }
const [accounts, details] = await Promise.all([ const [accounts, details] = await Promise.all([
@ -217,7 +225,7 @@ export class PortfolioService {
}): Promise<InvestmentItem[]> { }): Promise<InvestmentItem[]> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const activities = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
filters, filters,
userId, userId,
types: ['DIVIDEND'], types: ['DIVIDEND'],
@ -267,6 +275,13 @@ export class PortfolioService {
includeDrafts: true includeDrafts: true
}); });
if (transactionPoints.length === 0) {
return {
investments: [],
streaks: { currentStreak: 0, longestStreak: 0 }
};
}
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency, currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
@ -274,12 +289,6 @@ export class PortfolioService {
}); });
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return {
investments: [],
streaks: { currentStreak: 0, longestStreak: 0 }
};
}
let investments: InvestmentItem[]; let investments: InvestmentItem[];
@ -367,67 +376,6 @@ export class PortfolioService {
}; };
} }
public async getChart({
dateRange = 'max',
filters,
impersonationId,
userCurrency,
userId,
withExcludedAccounts = false
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
userCurrency: string;
userId: string;
withExcludedAccounts?: boolean;
}): Promise<HistoricalDataContainer> {
userId = await this.getUserId(impersonationId, userId);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
filters,
userId,
withExcludedAccounts
});
const portfolioCalculator = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return {
isAllTimeHigh: false,
isAllTimeLow: false,
items: []
};
}
const endDate = new Date();
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(dateRange, portfolioStart);
const daysInMarket = differenceInDays(new Date(), startDate);
const step = Math.round(
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)
);
const items = await portfolioCalculator.getChartData(
startDate,
endDate,
step
);
return {
items,
isAllTimeHigh: false,
isAllTimeLow: false
};
}
public async getDetails({ public async getDetails({
dateRange = 'max', dateRange = 'max',
filters, filters,
@ -731,13 +679,13 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const orders = ( const { activities } = await this.orderService.getOrders({
await this.orderService.getOrders({ userCurrency,
userCurrency, userId,
userId, withExcludedAccounts: true
withExcludedAccounts: true });
})
).filter(({ SymbolProfile }) => { const orders = activities.filter(({ SymbolProfile }) => {
return ( return (
SymbolProfile.dataSource === aDataSource && SymbolProfile.dataSource === aDataSource &&
SymbolProfile.symbol === aSymbol SymbolProfile.symbol === aSymbol
@ -879,7 +827,7 @@ export class PortfolioService {
let currentAveragePrice = 0; let currentAveragePrice = 0;
let currentQuantity = 0; let currentQuantity = 0;
const currentSymbol = transactionPoints[j].items.find( const currentSymbol = transactionPoints[j]?.items.find(
({ symbol }) => { ({ symbol }) => {
return symbol === aSymbol; return symbol === aSymbol;
} }
@ -1028,12 +976,6 @@ export class PortfolioService {
userId userId
}); });
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
if (transactionPoints?.length <= 0) { if (transactionPoints?.length <= 0) {
return { return {
hasErrors: false, hasErrors: false,
@ -1041,6 +983,12 @@ export class PortfolioService {
}; };
} }
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
@ -1126,6 +1074,31 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const accountBalances = await this.accountBalanceService.getAccountBalances(
{ filters, user, withExcludedAccounts }
);
let accountBalanceItems: HistoricalDataItem[] = Object.values(
// Reduce the array to a map with unique dates as keys
accountBalances.balances.reduce(
(
map: { [date: string]: HistoricalDataItem },
{ date, valueInBaseCurrency }
) => {
const formattedDate = format(date, DATE_FORMAT);
// Store the item in the map, overwriting if the date already exists
map[formattedDate] = {
date: formattedDate,
value: valueInBaseCurrency
};
return map;
},
{}
)
);
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
@ -1139,7 +1112,7 @@ export class PortfolioService {
orders: portfolioOrders orders: portfolioOrders
}); });
if (transactionPoints?.length <= 0) { if (accountBalanceItems?.length <= 0 && transactionPoints?.length <= 0) {
return { return {
chart: [], chart: [],
firstOrderDate: undefined, firstOrderDate: undefined,
@ -1149,6 +1122,7 @@ export class PortfolioService {
currentGrossPerformancePercent: 0, currentGrossPerformancePercent: 0,
currentNetPerformance: 0, currentNetPerformance: 0,
currentNetPerformancePercent: 0, currentNetPerformancePercent: 0,
currentNetWorth: 0,
currentValue: 0, currentValue: 0,
totalInvestment: 0 totalInvestment: 0
} }
@ -1157,7 +1131,15 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = min(
[
parseDate(accountBalanceItems[0]?.date),
parseDate(transactionPoints[0]?.date)
].filter((date) => {
return isValid(date);
})
);
const startDate = this.getStartDate(dateRange, portfolioStart); const startDate = this.getStartDate(dateRange, portfolioStart);
const { const {
currentValue, currentValue,
@ -1175,17 +1157,17 @@ export class PortfolioService {
let currentNetPerformance = netPerformance; let currentNetPerformance = netPerformance;
let currentNetPerformancePercent = netPerformancePercentage; let currentNetPerformancePercent = netPerformancePercentage;
const historicalDataContainer = await this.getChart({ const { items } = await this.getChart({
dateRange, dateRange,
filters,
impersonationId, impersonationId,
portfolioOrders,
transactionPoints,
userCurrency, userCurrency,
userId, userId
withExcludedAccounts
}); });
const itemOfToday = historicalDataContainer.items.find((item) => { const itemOfToday = items.find(({ date }) => {
return item.date === format(new Date(), DATE_FORMAT); return date === format(new Date(), DATE_FORMAT);
}); });
if (itemOfToday) { if (itemOfToday) {
@ -1195,34 +1177,42 @@ export class PortfolioService {
).div(100); ).div(100);
} }
accountBalanceItems = accountBalanceItems.filter(({ date }) => {
return !isBefore(parseDate(date), startDate);
});
const accountBalanceItemOfToday = accountBalanceItems.find(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
});
if (!accountBalanceItemOfToday) {
accountBalanceItems.push({
date: format(new Date(), DATE_FORMAT),
value: last(accountBalanceItems)?.value ?? 0
});
}
const mergedHistoricalDataItems = this.mergeHistoricalDataItems(
accountBalanceItems,
items
);
const currentHistoricalDataItem = last(mergedHistoricalDataItems);
const currentNetWorth = currentHistoricalDataItem?.netWorth ?? 0;
return { return {
errors, errors,
hasErrors, hasErrors,
chart: historicalDataContainer.items.map( chart: mergedHistoricalDataItems,
({ firstOrderDate: parseDate(items[0]?.date),
date,
netPerformance: netPerformanceOfItem,
netPerformanceInPercentage,
totalInvestment: totalInvestmentOfItem,
value
}) => {
return {
date,
netPerformanceInPercentage,
value,
netPerformance: netPerformanceOfItem,
totalInvestment: totalInvestmentOfItem
};
}
),
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date),
performance: { performance: {
currentValue: currentValue.toNumber(), currentNetWorth,
currentGrossPerformance: currentGrossPerformance.toNumber(), currentGrossPerformance: currentGrossPerformance.toNumber(),
currentGrossPerformancePercent: currentGrossPerformancePercent:
currentGrossPerformancePercent.toNumber(), currentGrossPerformancePercent.toNumber(),
currentNetPerformance: currentNetPerformance.toNumber(), currentNetPerformance: currentNetPerformance.toNumber(),
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(), currentNetPerformancePercent: currentNetPerformancePercent.toNumber(),
currentValue: currentValue.toNumber(),
totalInvestment: totalInvestment.toNumber() totalInvestment: totalInvestment.toNumber()
} }
}; };
@ -1376,6 +1366,62 @@ export class PortfolioService {
return cashPositions; return cashPositions;
} }
private async getChart({
dateRange = 'max',
impersonationId,
portfolioOrders,
transactionPoints,
userCurrency,
userId
}: {
dateRange?: DateRange;
impersonationId: string;
portfolioOrders: PortfolioOrder[];
transactionPoints: TransactionPoint[];
userCurrency: string;
userId: string;
}): Promise<HistoricalDataContainer> {
if (transactionPoints.length === 0) {
return {
isAllTimeHigh: false,
isAllTimeLow: false,
items: []
};
}
userId = await this.getUserId(impersonationId, userId);
const portfolioCalculator = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints);
const endDate = new Date();
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(dateRange, portfolioStart);
const daysInMarket = differenceInDays(new Date(), startDate);
const step = Math.round(
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)
);
const items = await portfolioCalculator.getChartData(
startDate,
endDate,
step
);
return {
items,
isAllTimeHigh: false,
isAllTimeLow: false
};
}
private getDividendsByGroup({ private getDividendsByGroup({
dividends, dividends,
groupBy groupBy
@ -1593,18 +1639,18 @@ export class PortfolioService {
userId userId
}); });
const activities = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
userCurrency, userCurrency,
userId userId
}); });
const excludedActivities = ( let { activities: excludedActivities } = await this.orderService.getOrders({
await this.orderService.getOrders({ userCurrency,
userCurrency, userId,
userId, withExcludedAccounts: true
withExcludedAccounts: true });
})
).filter(({ Account: account }) => { excludedActivities = excludedActivities.filter(({ Account: account }) => {
return account?.isExcluded ?? false; return account?.isExcluded ?? false;
}); });
@ -1784,7 +1830,7 @@ export class PortfolioService {
const userCurrency = const userCurrency =
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
const orders = await this.orderService.getOrders({ const { activities, count } = await this.orderService.getOrders({
filters, filters,
includeDrafts, includeDrafts,
userCurrency, userCurrency,
@ -1793,11 +1839,11 @@ export class PortfolioService {
types: ['BUY', 'SELL'] types: ['BUY', 'SELL']
}); });
if (orders.length <= 0) { if (count <= 0) {
return { transactionPoints: [], orders: [], portfolioOrders: [] }; return { transactionPoints: [], orders: [], portfolioOrders: [] };
} }
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = activities.map((order) => ({
currency: order.SymbolProfile.currency, currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource, dataSource: order.SymbolProfile.dataSource,
date: format(order.date, DATE_FORMAT), date: format(order.date, DATE_FORMAT),
@ -1831,8 +1877,8 @@ export class PortfolioService {
portfolioCalculator.computeTransactionPoints(); portfolioCalculator.computeTransactionPoints();
return { return {
orders,
portfolioOrders, portfolioOrders,
orders: activities,
transactionPoints: portfolioCalculator.getTransactionPoints() transactionPoints: portfolioCalculator.getTransactionPoints()
}; };
} }
@ -1867,13 +1913,14 @@ export class PortfolioService {
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}) { }) {
const ordersOfTypeItemOrLiability = await this.orderService.getOrders({ const { activities: ordersOfTypeItemOrLiability } =
filters, await this.orderService.getOrders({
userCurrency, filters,
userId, userCurrency,
withExcludedAccounts, userId,
types: ['ITEM', 'LIABILITY'] withExcludedAccounts,
}); types: ['ITEM', 'LIABILITY']
});
const accounts: PortfolioDetails['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
const platforms: PortfolioDetails['platforms'] = {}; const platforms: PortfolioDetails['platforms'] = {};
@ -1892,9 +1939,13 @@ export class PortfolioService {
}); });
} else { } else {
const accountIds = uniq( const accountIds = uniq(
orders.map(({ accountId }) => { orders
return accountId; .filter(({ accountId }) => {
}) return accountId;
})
.map(({ accountId }) => {
return accountId;
})
); );
currentAccounts = await this.accountService.accounts({ currentAccounts = await this.accountService.accounts({
@ -1995,4 +2046,44 @@ export class PortfolioService {
return { accounts, platforms }; return { accounts, platforms };
} }
private mergeHistoricalDataItems(
accountBalanceItems: HistoricalDataItem[],
performanceChartItems: HistoricalDataItem[]
): HistoricalDataItem[] {
const historicalDataItemsMap: { [date: string]: HistoricalDataItem } = {};
let latestAccountBalance = 0;
for (const item of accountBalanceItems.concat(performanceChartItems)) {
const isAccountBalanceItem = accountBalanceItems.includes(item);
const totalAccountBalance = isAccountBalanceItem
? item.value
: latestAccountBalance;
if (isAccountBalanceItem && performanceChartItems.length > 0) {
latestAccountBalance = item.value;
} else {
historicalDataItemsMap[item.date] = {
...item,
totalAccountBalance,
netWorth:
(isAccountBalanceItem ? 0 : item.value) + totalAccountBalance
};
}
}
// Convert to an array and sort by date in ascending order
const historicalDataItems = Object.keys(historicalDataItemsMap).map(
(date) => {
return historicalDataItemsMap[date];
}
);
historicalDataItems.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
return historicalDataItems;
}
} }

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

@ -1,3 +1,4 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
@ -37,7 +38,7 @@ export class SubscriptionController {
@Post('redeem-coupon') @Post('redeem-coupon')
@HttpCode(StatusCodes.OK) @HttpCode(StatusCodes.OK)
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) { public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
if (!this.request.user) { if (!this.request.user) {
throw new HttpException( throw new HttpException(
@ -109,7 +110,7 @@ export class SubscriptionController {
} }
@Post('stripe/checkout-session') @Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createCheckoutSession( public async createCheckoutSession(
@Body() { couponId, priceId }: { couponId: string; priceId: string } @Body() { couponId, priceId }: { couponId: string; priceId: string }
) { ) {

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

@ -111,14 +111,14 @@ export class SubscriptionService {
aSubscriptions: Subscription[] aSubscriptions: Subscription[]
): UserWithSettings['subscription'] { ): UserWithSettings['subscription'] {
if (aSubscriptions.length > 0) { if (aSubscriptions.length > 0) {
const latestSubscription = aSubscriptions.reduce((a, b) => { const { expiresAt, price } = aSubscriptions.reduce((a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b; return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
}); });
return { return {
expiresAt: latestSubscription.expiresAt, expiresAt,
offer: latestSubscription.price === 0 ? 'default' : 'renewal', offer: price ? 'renewal' : 'default',
type: isBefore(new Date(), latestSubscription.expiresAt) type: isBefore(new Date(), expiresAt)
? SubscriptionType.Premium ? SubscriptionType.Premium
: SubscriptionType.Basic : SubscriptionType.Basic
}; };

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

@ -1,3 +1,4 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
@ -34,7 +35,7 @@ export class SymbolController {
* Must be before /:symbol * Must be before /:symbol
*/ */
@Get('lookup') @Get('lookup')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol( public async lookupSymbol(
@Query('includeIndices') includeIndices: boolean = false, @Query('includeIndices') includeIndices: boolean = false,
@ -88,7 +89,7 @@ export class SymbolController {
} }
@Get(':dataSource/:symbol/:dateString') @Get(':dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherSymbolForDate( public async gatherSymbolForDate(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string, @Param('dateString') dateString: string,

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

@ -40,7 +40,12 @@ export class SymbolService {
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) }, dateQuery: { gte: subDays(new Date(), days) },
symbols: [dataGatheringItem.symbol] uniqueAssets: [
{
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
}
]
}); });
historicalData = marketData.map(({ date, marketPrice: value }) => { historicalData = marketData.map(({ date, marketPrice: value }) => {

44
apps/api/src/app/tag/tag.controller.ts

@ -1,18 +1,17 @@
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import type { RequestWithUser } from '@ghostfolio/common/types'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
HttpException, HttpException,
Inject,
Param, Param,
Post, Post,
Put, Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -23,40 +22,25 @@ import { UpdateTagDto } from './update-tag.dto';
@Controller('tag') @Controller('tag')
export class TagController { export class TagController {
public constructor( public constructor(private readonly tagService: TagService) {}
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly tagService: TagService
) {}
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() { public async getTags() {
return this.tagService.getTagsWithActivityCount(); return this.tagService.getTagsWithActivityCount();
} }
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.createTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createTag(@Body() data: CreateTagDto): Promise<Tag> { public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
if (!hasPermission(this.request.user.permissions, permissions.createTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.createTag(data); return this.tagService.createTag(data);
} }
@HasPermission(permissions.updateTag)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) { public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
if (!hasPermission(this.request.user.permissions, permissions.updateTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({ const originalTag = await this.tagService.getTag({
id id
}); });
@ -79,15 +63,9 @@ export class TagController {
} }
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.deleteTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteTag(@Param('id') id: string) { public async deleteTag(@Param('id') id: string) {
if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({ const originalTag = await this.tagService.getTag({
id id
}); });

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

@ -1,3 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { User, UserSettings } from '@ghostfolio/common/interfaces'; import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -36,12 +38,10 @@ export class UserController {
) {} ) {}
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.deleteUser)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteUser(@Param('id') id: string): Promise<UserModel> { public async deleteUser(@Param('id') id: string): Promise<UserModel> {
if ( if (id === this.request.user.id) {
!hasPermission(this.request.user.permissions, permissions.deleteUser) ||
id === this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -54,7 +54,7 @@ export class UserController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string @Headers('accept-language') acceptLanguage: string
): Promise<User> { ): Promise<User> {
@ -92,7 +92,7 @@ export class UserController {
} }
@Put('setting') @Put('setting')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateUserSetting(@Body() data: UpdateUserSettingDto) { public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if ( if (
size(data) === 1 && size(data) === 1 &&

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

@ -7,9 +7,14 @@ import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SYSTEM_MESSAGE,
locale locale
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces'; import {
User as IUser,
SystemMessage,
UserSettings
} from '@ghostfolio/common/interfaces';
import { import {
getPermissions, getPermissions,
hasRole, hasRole,
@ -48,6 +53,17 @@ export class UserService {
orderBy: { alias: 'asc' }, orderBy: { alias: 'asc' },
where: { GranteeUser: { id } } where: { GranteeUser: { id } }
}); });
let systemMessage: SystemMessage;
const systemMessageProperty = (await this.propertyService.getByKey(
PROPERTY_SYSTEM_MESSAGE
)) as SystemMessage;
if (systemMessageProperty?.targetGroups?.includes(subscription?.type)) {
systemMessage = systemMessageProperty;
}
let tags = await this.tagService.getByUser(id); let tags = await this.tagService.getByUser(id);
if ( if (
@ -61,6 +77,7 @@ export class UserService {
id, id,
permissions, permissions,
subscription, subscription,
systemMessage,
tags, tags,
access: access.map((accessItem) => { access: access.map((accessItem) => {
return { return {
@ -110,7 +127,9 @@ export class UserService {
updatedAt updatedAt
} = await this.prismaService.user.findUnique({ } = await this.prismaService.user.findUnique({
include: { include: {
Account: true, Account: {
include: { Platform: true }
},
Analytics: true, Analytics: true,
Settings: true, Settings: true,
Subscription: true Subscription: true
@ -179,16 +198,18 @@ export class UserService {
new Date(), new Date(),
user.createdAt user.createdAt
); );
let frequency = 20; let frequency = 15;
if (daysSinceRegistration > 180) { if (daysSinceRegistration > 365) {
frequency = 2;
} else if (daysSinceRegistration > 180) {
frequency = 3; frequency = 3;
} else if (daysSinceRegistration > 60) { } else if (daysSinceRegistration > 60) {
frequency = 5; frequency = 5;
} else if (daysSinceRegistration > 30) { } else if (daysSinceRegistration > 30) {
frequency = 10; frequency = 8;
} else if (daysSinceRegistration > 15) { } else if (daysSinceRegistration > 15) {
frequency = 15; frequency = 12;
} }
if (Analytics?.activityCount % frequency === 1) { if (Analytics?.activityCount % frequency === 1) {
@ -233,8 +254,8 @@ export class UserService {
currentPermissions.push(permissions.impersonateAllUsers); currentPermissions.push(permissions.impersonateAllUsers);
} }
user.Account = sortBy(user.Account, (account) => { user.Account = sortBy(user.Account, ({ name }) => {
return account.name; return name.toLowerCase();
}); });
user.permissions = currentPermissions.sort(); user.permissions = currentPermissions.sort();

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

@ -54,18 +54,46 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-compound-planning</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-de.fi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -74,6 +102,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-empower</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -82,6 +114,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -94,6 +130,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -102,6 +142,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -110,6 +154,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -134,6 +182,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -162,14 +214,34 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-tiller</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-whal</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ueber-uns</loc> <loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -274,6 +346,14 @@
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc> <loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </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> <url>
<loc>https://ghostfol.io/en/faq</loc> <loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -308,18 +388,46 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-compound-planning</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-de.fi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -328,6 +436,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-empower</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -336,6 +448,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -348,6 +464,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -356,6 +476,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -364,6 +488,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -388,6 +516,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -416,14 +548,34 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-tiller</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-whal</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/es</loc> <loc>https://ghostfol.io/es</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -590,18 +742,46 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-campmon</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-compound-planning</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-de.fi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -610,6 +790,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-empower</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -618,6 +802,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -630,6 +818,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -638,6 +830,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -646,6 +842,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -670,6 +870,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -698,14 +902,34 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-tiller</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-whal</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl</loc> <loc>https://ghostfol.io/nl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -718,18 +942,46 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-compound-planning</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-de.fi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -738,6 +990,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-empower</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -746,6 +1002,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -758,6 +1018,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -766,6 +1030,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-markets.sh</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -774,6 +1042,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-maybe-finance</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -798,6 +1070,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -826,14 +1102,34 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-tiller</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-whal</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/functionaliteiten</loc> <loc>https://ghostfol.io/nl/functionaliteiten</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -880,6 +1176,10 @@
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc> <loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/pl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/pt</loc> <loc>https://ghostfol.io/pt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>

6
apps/api/src/decorators/has-permission.decorator.ts

@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const HAS_PERMISSION_KEY = 'has_permission';
export function HasPermission(permission: string) {
return SetMetadata(HAS_PERMISSION_KEY, permission);
}

50
apps/api/src/guards/has-permission.guard.spec.ts

@ -0,0 +1,50 @@
import { HttpException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host';
import { HasPermissionGuard } from './has-permission.guard';
describe('HasPermissionGuard', () => {
let guard: HasPermissionGuard;
let reflector: Reflector;
beforeEach(async () => {
reflector = new Reflector();
guard = new HasPermissionGuard(reflector);
});
function setupReflectorSpy(returnValue: string) {
jest.spyOn(reflector, 'get').mockReturnValue(returnValue);
}
function createMockExecutionContext(permissions: string[]) {
return new ExecutionContextHost([
{
user: {
permissions // Set user permissions based on the argument
}
}
]);
}
it('should deny access if the user does not have any permission', () => {
setupReflectorSpy('required-permission');
const noPermissions = createMockExecutionContext([]);
expect(() => guard.canActivate(noPermissions)).toThrow(HttpException);
});
it('should deny access if the user has the wrong permission', () => {
setupReflectorSpy('required-permission');
const wrongPermission = createMockExecutionContext(['wrong-permission']);
expect(() => guard.canActivate(wrongPermission)).toThrow(HttpException);
});
it('should allow access if the user has the required permission', () => {
setupReflectorSpy('required-permission');
const rightPermission = createMockExecutionContext(['required-permission']);
expect(guard.canActivate(rightPermission)).toBe(true);
});
});

37
apps/api/src/guards/has-permission.guard.ts

@ -0,0 +1,37 @@
import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator';
import { hasPermission } from '@ghostfolio/common/permissions';
import {
CanActivate,
ExecutionContext,
HttpException,
Injectable
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
export class HasPermissionGuard implements CanActivate {
public constructor(private reflector: Reflector) {}
public canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
const requiredPermission = this.reflector.get<string>(
HAS_PERMISSION_KEY,
context.getHandler()
);
if (!requiredPermission) {
// No specific permissions required
return true;
}
if (!user || !hasPermission(user.permissions, requiredPermission)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return true;
}
}

14
apps/api/src/helper/object.helper.ts

@ -32,9 +32,11 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
} }
export function redactAttributes({ export function redactAttributes({
isFirstRun = true,
object, object,
options options
}: { }: {
isFirstRun?: boolean;
object: any; object: any;
options: { attribute: string; valueMap: { [key: string]: any } }[]; options: { attribute: string; valueMap: { [key: string]: any } }[];
}): any { }): any {
@ -42,7 +44,10 @@ export function redactAttributes({
return object; return object;
} }
const redactedObject = cloneDeep(object); // Create deep clone
const redactedObject = isFirstRun
? JSON.parse(JSON.stringify(object))
: object;
for (const option of options) { for (const option of options) {
if (redactedObject.hasOwnProperty(option.attribute)) { if (redactedObject.hasOwnProperty(option.attribute)) {
@ -59,7 +64,11 @@ export function redactAttributes({
if (isArray(redactedObject[property])) { if (isArray(redactedObject[property])) {
redactedObject[property] = redactedObject[property].map( redactedObject[property] = redactedObject[property].map(
(currentObject) => { (currentObject) => {
return redactAttributes({ options, object: currentObject }); return redactAttributes({
options,
isFirstRun: false,
object: currentObject
});
} }
); );
} else if ( } else if (
@ -69,6 +78,7 @@ export function redactAttributes({
// Recursively call the function on the nested object // Recursively call the function on the nested object
redactedObject[property] = redactAttributes({ redactedObject[property] = redactAttributes({
options, options,
isFirstRun: false,
object: redactedObject[property] object: redactedObject[property]
}); });
} }

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

@ -12,13 +12,12 @@ import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
const title = 'Ghostfolio – Open Source Wealth Management Software';
const titleShort = 'Ghostfolio';
const i18nService = new I18nService(); const i18nService = new I18nService();
let indexHtmlMap: { [languageCode: string]: string } = {}; let indexHtmlMap: { [languageCode: string]: string } = {};
const title = 'Ghostfolio';
try { try {
indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce( indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({ (map, languageCode) => ({
@ -35,47 +34,55 @@ try {
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',
title: `Ghostfolio auf Sackgeld.com vorgestellt - ${titleShort}` title: `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`
}, },
'/en/blog/2022/08/500-stars-on-github': { '/en/blog/2022/08/500-stars-on-github': {
featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg', featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg',
title: `500 Stars - ${titleShort}` title: `500 Stars - ${title}`
}, },
'/en/blog/2022/10/hacktoberfest-2022': { '/en/blog/2022/10/hacktoberfest-2022': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png', featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png',
title: `Hacktoberfest 2022 - ${titleShort}` title: `Hacktoberfest 2022 - ${title}`
}, },
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': { '/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': {
featureGraphicPath: 'assets/images/blog/20221226.jpg', featureGraphicPath: 'assets/images/blog/20221226.jpg',
title: `The importance of tracking your personal finances - ${titleShort}` title: `The importance of tracking your personal finances - ${title}`
}, },
'/en/blog/2023/02/ghostfolio-meets-umbrel': { '/en/blog/2023/02/ghostfolio-meets-umbrel': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png', featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png',
title: `Ghostfolio meets Umbrel - ${titleShort}` title: `Ghostfolio meets Umbrel - ${title}`
}, },
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': { '/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': {
featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg', featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg',
title: `Ghostfolio reaches 1’000 Stars on GitHub - ${titleShort}` title: `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`
}, },
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': { '/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': {
featureGraphicPath: 'assets/images/blog/20230520.jpg', featureGraphicPath: 'assets/images/blog/20230520.jpg',
title: `Unlock your Financial Potential with Ghostfolio - ${titleShort}` title: `Unlock your Financial Potential with Ghostfolio - ${title}`
}, },
'/en/blog/2023/07/exploring-the-path-to-fire': { '/en/blog/2023/07/exploring-the-path-to-fire': {
featureGraphicPath: 'assets/images/blog/20230701.jpg', featureGraphicPath: 'assets/images/blog/20230701.jpg',
title: `Exploring the Path to FIRE - ${titleShort}` title: `Exploring the Path to FIRE - ${title}`
}, },
'/en/blog/2023/08/ghostfolio-joins-oss-friends': { '/en/blog/2023/08/ghostfolio-joins-oss-friends': {
featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png', featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png',
title: `Ghostfolio joins OSS Friends - ${titleShort}` title: `Ghostfolio joins OSS Friends - ${title}`
}, },
'/en/blog/2023/09/ghostfolio-2': { '/en/blog/2023/09/ghostfolio-2': {
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg', featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
title: `Announcing Ghostfolio 2.0 - ${titleShort}` title: `Announcing Ghostfolio 2.0 - ${title}`
}, },
'/en/blog/2023/09/hacktoberfest-2023': { '/en/blog/2023/09/hacktoberfest-2023': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png', featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 - ${titleShort}` title: `Hacktoberfest 2023 - ${title}`
},
'/en/blog/2023/11/black-week-2023': {
featureGraphicPath: 'assets/images/blog/black-week-2023.jpg',
title: `Black Week 2023 - ${title}`
},
'/en/blog/2023/11/hacktoberfest-2023-debriefing': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 Debriefing - ${title}`
} }
}; };
@ -84,6 +91,9 @@ const isFileRequest = (filename: string) => {
return true; return true;
} else if ( } else if (
filename.includes('auth/ey') || filename.includes('auth/ey') ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-de.fi'
) ||
filename.includes( filename.includes(
'personal-finance-tools/open-source-alternative-to-markets.sh' 'personal-finance-tools/open-source-alternative-to-markets.sh'
) )
@ -128,7 +138,16 @@ export const HtmlTemplateMiddleware = async (
}), }),
featureGraphicPath: featureGraphicPath:
locales[path]?.featureGraphicPath ?? 'assets/cover.png', locales[path]?.featureGraphicPath ?? 'assets/cover.png',
title: locales[path]?.title ?? title keywords: i18nService.getTranslation({
languageCode,
id: 'metaKeywords'
}),
title:
locales[path]?.title ??
`${title}${i18nService.getTranslation({
languageCode,
id: 'slogan'
})}`
}); });
return response.send(indexHtml); return response.send(indexHtml);

10
apps/api/src/services/account-balance/account-balance.module.ts

@ -1,10 +0,0 @@
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
@Module({
exports: [AccountBalanceService],
imports: [PrismaModule],
providers: [AccountBalanceService]
})
export class AccountBalanceModule {}

42
apps/api/src/services/account-balance/account-balance.service.ts

@ -1,42 +0,0 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccountBalancesResponse } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { AccountBalance, Prisma } from '@prisma/client';
@Injectable()
export class AccountBalanceService {
public constructor(private readonly prismaService: PrismaService) {}
public async createAccountBalance(
data: Prisma.AccountBalanceCreateInput
): Promise<AccountBalance> {
return this.prismaService.accountBalance.create({
data
});
}
public async getAccountBalances({
accountId,
userId
}: {
accountId: string;
userId: string;
}): Promise<AccountBalancesResponse> {
const balances = await this.prismaService.accountBalance.findMany({
orderBy: {
date: 'asc'
},
select: {
date: true,
id: true,
value: true
},
where: {
accountId,
userId
}
});
return { balances };
}
}

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

@ -44,6 +44,7 @@ export class ConfigurationService {
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }), REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
REQUEST_TIMEOUT: num({ default: 2000 }),
ROOT_URL: str({ default: DEFAULT_ROOT_URL }), ROOT_URL: str({ default: DEFAULT_ROOT_URL }),
STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }),

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

@ -105,9 +105,13 @@ export class AlphaVantageService implements DataProviderInterface {
return DataSource.ALPHA_VANTAGE; return DataSource.ALPHA_VANTAGE;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
return {}; return {};
} }

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

@ -1,13 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
@ -25,7 +23,9 @@ import got from 'got';
export class CoinGeckoService implements DataProviderInterface { export class CoinGeckoService implements DataProviderInterface {
private readonly URL = 'https://api.coingecko.com/api/v3'; private readonly URL = 'https://api.coingecko.com/api/v3';
public constructor() {} public constructor(
private readonly configurationService: ConfigurationService
) {}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return true; return true;
@ -47,7 +47,7 @@ export class CoinGeckoService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { name } = await got(`${this.URL}/coins/${aSymbol}`, { const { name } = await got(`${this.URL}/coins/${aSymbol}`, {
// @ts-ignore // @ts-ignore
@ -56,7 +56,15 @@ export class CoinGeckoService implements DataProviderInterface {
response.name = name; response.name = name;
} catch (error) { } catch (error) {
Logger.error(error, 'CoinGeckoService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'CoinGeckoService');
} }
return response; return response;
@ -89,7 +97,7 @@ export class CoinGeckoService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { prices } = await got( const { prices } = await got(
`${ `${
@ -134,13 +142,17 @@ export class CoinGeckoService implements DataProviderInterface {
return DataSource.COINGECKO; return DataSource.COINGECKO;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
const results: { [symbol: string]: IDataProviderResponse } = {}; }: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) { if (symbols.length <= 0) {
return {}; return response;
} }
try { try {
@ -148,10 +160,10 @@ export class CoinGeckoService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, requestTimeout);
const response = await got( const quotes = await got(
`${this.URL}/simple/price?ids=${aSymbols.join( `${this.URL}/simple/price?ids=${symbols.join(
',' ','
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`, )}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
{ {
@ -160,22 +172,28 @@ export class CoinGeckoService implements DataProviderInterface {
} }
).json<any>(); ).json<any>();
for (const symbol in response) { for (const symbol in quotes) {
if (Object.prototype.hasOwnProperty.call(response, symbol)) { response[symbol] = {
results[symbol] = { currency: DEFAULT_CURRENCY,
currency: DEFAULT_CURRENCY, dataProviderInfo: this.getDataProviderInfo(),
dataProviderInfo: this.getDataProviderInfo(), dataSource: DataSource.COINGECKO,
dataSource: DataSource.COINGECKO, marketPrice: quotes[symbol][DEFAULT_CURRENCY.toLowerCase()],
marketPrice: response[symbol][DEFAULT_CURRENCY.toLowerCase()], marketState: 'open'
marketState: 'open' };
};
}
} }
} catch (error) { } catch (error) {
Logger.error(error, 'CoinGeckoService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'CoinGeckoService');
} }
return results; return response;
} }
public getTestSymbol() { public getTestSymbol() {
@ -196,7 +214,7 @@ export class CoinGeckoService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { coins } = await got(`${this.URL}/search?query=${query}`, { const { coins } = await got(`${this.URL}/search?query=${query}`, {
// @ts-ignore // @ts-ignore
@ -214,7 +232,15 @@ export class CoinGeckoService implements DataProviderInterface {
}; };
}); });
} catch (error) { } catch (error) {
Logger.error(error, 'CoinGeckoService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'CoinGeckoService');
} }
return { items }; return { items };

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

@ -2,6 +2,7 @@ import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/in
import { HttpException, Inject, Injectable } from '@nestjs/common'; import { HttpException, Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import ms from 'ms';
@Injectable() @Injectable()
export class DataEnhancerService { export class DataEnhancerService {
@ -24,6 +25,7 @@ export class DataEnhancerService {
try { try {
const assetProfile = await dataEnhancer.enhance({ const assetProfile = await dataEnhancer.enhance({
requestTimeout: ms('30 seconds'),
response: { response: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF' assetSubClass: 'ETF'

5
apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts

@ -1,6 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { parseSymbol } from '@ghostfolio/common/helper'; import { parseSymbol } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
@ -15,9 +14,11 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
) {} ) {}
public async enhance({ public async enhance({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
response, response,
symbol symbol
}: { }: {
requestTimeout?: number;
response: Partial<SymbolProfile>; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<Partial<SymbolProfile>> { }): Promise<Partial<SymbolProfile>> {
@ -45,7 +46,7 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, requestTimeout);
const mappings = await got const mappings = await got
.post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, { .post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {

21
apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -13,6 +13,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static countriesMapping = { private static countriesMapping = {
'Russian Federation': 'Russia' 'Russian Federation': 'Russia'
}; };
private static holdingsWeightTreshold = 0.85;
private static sectorsMapping = { private static sectorsMapping = {
'Consumer Discretionary': 'Consumer Cyclical', 'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Staples', 'Consumer Defensive': 'Consumer Staples',
@ -20,10 +21,16 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
'Information Technology': 'Technology' 'Information Technology': 'Technology'
}; };
public constructor(
private readonly configurationService: ConfigurationService
) {}
public async enhance({ public async enhance({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
response, response,
symbol symbol
}: { }: {
requestTimeout?: number;
response: Partial<SymbolProfile>; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<Partial<SymbolProfile>> { }): Promise<Partial<SymbolProfile>> {
@ -37,7 +44,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, requestTimeout);
const profile = await got( const profile = await got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`, `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`,
@ -52,7 +59,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
return got( return got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split( `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
@ -79,7 +86,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const holdings = await got( const holdings = await got(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`, `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`,
@ -94,7 +101,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
return got( return got(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split( `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(
@ -111,7 +118,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}); });
}); });
if (holdings?.weight < 0.95) { if (
holdings?.weight < TrackinsightDataEnhancerService.holdingsWeightTreshold
) {
// Skip if data is inaccurate // Skip if data is inaccurate
return response; return response;
} }

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

@ -1,3 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
@ -25,13 +26,16 @@ jest.mock(
); );
describe('YahooFinanceDataEnhancerService', () => { describe('YahooFinanceDataEnhancerService', () => {
let configurationService: ConfigurationService;
let cryptocurrencyService: CryptocurrencyService; let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService; let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
beforeAll(async () => { beforeAll(async () => {
configurationService = new ConfigurationService();
cryptocurrencyService = new CryptocurrencyService(); cryptocurrencyService = new CryptocurrencyService();
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService( yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
configurationService,
cryptocurrencyService cryptocurrencyService
); );
}); });

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

@ -1,3 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config';
@ -10,6 +11,7 @@ import {
Prisma, Prisma,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { isISIN } from 'class-validator';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
import yahooFinance from 'yahoo-finance2'; import yahooFinance from 'yahoo-finance2';
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface'; import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
@ -17,6 +19,7 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable() @Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService private readonly cryptocurrencyService: CryptocurrencyService
) {} ) {}
@ -71,9 +74,11 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
} }
public async enhance({ public async enhance({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
response, response,
symbol symbol
}: { }: {
requestTimeout?: number;
response: Partial<SymbolProfile>; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<Partial<SymbolProfile>> { }): Promise<Partial<SymbolProfile>> {
@ -156,7 +161,20 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
const response: Partial<SymbolProfile> = {}; const response: Partial<SymbolProfile> = {};
try { try {
const symbol = this.convertToYahooFinanceSymbol(aSymbol); let symbol = aSymbol;
if (isISIN(symbol)) {
try {
const { quotes } = await yahooFinance.search(symbol);
if (quotes.length === 1) {
symbol = quotes[0].symbol;
}
} catch {}
} else {
symbol = this.convertToYahooFinanceSymbol(symbol);
}
const assetProfile = await yahooFinance.quoteSummary(symbol, { const assetProfile = await yahooFinance.quoteSummary(symbol, {
modules: ['price', 'summaryProfile', 'topHoldings'] modules: ['price', 'summaryProfile', 'topHoldings']
}); });
@ -176,7 +194,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
shortName: assetProfile.price.shortName, shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol symbol: assetProfile.price.symbol
}); });
response.symbol = aSymbol; response.symbol = assetProfile.price.symbol;
if (assetSubClass === AssetSubClass.MUTUALFUND) { if (assetSubClass === AssetSubClass.MUTUALFUND) {
response.sectors = []; response.sectors = [];

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

@ -17,6 +17,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { format, isValid } from 'date-fns'; import { format, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber } from 'lodash'; import { groupBy, isEmpty, isNumber } from 'lodash';
import ms from 'ms';
@Injectable() @Injectable()
export class DataProviderService { export class DataProviderService {
@ -52,6 +53,7 @@ export class DataProviderService {
symbol symbol
} }
], ],
requestTimeout: ms('30 seconds'),
useCache: false useCache: false
}); });
@ -236,9 +238,11 @@ export class DataProviderService {
public async getQuotes({ public async getQuotes({
items, items,
requestTimeout,
useCache = true useCache = true
}: { }: {
items: UniqueAsset[]; items: UniqueAsset[];
requestTimeout?: number;
useCache?: boolean; useCache?: boolean;
}): Promise<{ }): Promise<{
[symbol: string]: IDataProviderResponse; [symbol: string]: IDataProviderResponse;
@ -311,7 +315,9 @@ export class DataProviderService {
i + maximumNumberOfSymbolsPerRequest i + maximumNumberOfSymbolsPerRequest
); );
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk)); const promise = Promise.resolve(
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk })
);
promises.push( promises.push(
promise.then(async (result) => { promise.then(async (result) => {
@ -340,7 +346,7 @@ export class DataProviderService {
); );
try { try {
this.marketDataService.updateMany({ await this.marketDataService.updateMany({
data: Object.keys(response) data: Object.keys(response)
.filter((symbol) => { .filter((symbol) => {
return ( return (

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

@ -5,10 +5,7 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -82,7 +79,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const response = await got( const response = await got(
`${this.URL}/eod/${symbol}?api_token=${ `${this.URL}/eod/${symbol}?api_token=${
@ -131,28 +128,34 @@ export class EodHistoricalDataService implements DataProviderInterface {
return DataSource.EOD_HISTORICAL_DATA; return DataSource.EOD_HISTORICAL_DATA;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
const symbols = aSymbols.map((symbol) => { }: {
return this.convertToEodSymbol(symbol); requestTimeout?: number;
}); symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
let response: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) { if (symbols.length <= 0) {
return {}; return response;
} }
const eodHistoricalDataSymbols = symbols.map((symbol) => {
return this.convertToEodSymbol(symbol);
});
try { try {
const abortController = new AbortController(); const abortController = new AbortController();
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, requestTimeout);
const realTimeResponse = await got( const realTimeResponse = await got(
`${this.URL}/real-time/${symbols[0]}?api_token=${ `${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?api_token=${
this.apiKey this.apiKey
}&fmt=json&s=${symbols.join(',')}`, }&fmt=json&s=${eodHistoricalDataSymbols.join(',')}`,
{ {
// @ts-ignore // @ts-ignore
signal: abortController.signal signal: abortController.signal
@ -160,10 +163,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
).json<any>(); ).json<any>();
const quotes = const quotes =
symbols.length === 1 ? [realTimeResponse] : realTimeResponse; eodHistoricalDataSymbols.length === 1
? [realTimeResponse]
: realTimeResponse;
const searchResponse = await Promise.all( const searchResponse = await Promise.all(
symbols eodHistoricalDataSymbols
.filter((symbol) => { .filter((symbol) => {
return !symbol.endsWith('.FOREX'); return !symbol.endsWith('.FOREX');
}) })
@ -176,7 +181,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
return items[0]; return items[0];
}); });
const response = quotes.reduce( response = quotes.reduce(
( (
result: { [symbol: string]: IDataProviderResponse }, result: { [symbol: string]: IDataProviderResponse },
{ close, code, timestamp } { close, code, timestamp }
@ -186,7 +191,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
})?.currency; })?.currency;
result[this.convertFromEodSymbol(code)] = { result[this.convertFromEodSymbol(code)] = {
currency: currency ?? DEFAULT_CURRENCY, currency:
currency ??
this.convertFromEodSymbol(code)?.replace(DEFAULT_CURRENCY, ''),
dataSource: DataSource.EOD_HISTORICAL_DATA, dataSource: DataSource.EOD_HISTORICAL_DATA,
marketPrice: close, marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed' marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
@ -200,7 +207,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
if (response[`${DEFAULT_CURRENCY}GBP`]) { if (response[`${DEFAULT_CURRENCY}GBP`]) {
response[`${DEFAULT_CURRENCY}GBp`] = { response[`${DEFAULT_CURRENCY}GBp`] = {
...response[`${DEFAULT_CURRENCY}GBP`], ...response[`${DEFAULT_CURRENCY}GBP`],
currency: `${DEFAULT_CURRENCY}GBp`, currency: 'GBp',
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${DEFAULT_CURRENCY}GBp`, symbol: `${DEFAULT_CURRENCY}GBp`,
value: response[`${DEFAULT_CURRENCY}GBP`].marketPrice value: response[`${DEFAULT_CURRENCY}GBP`].marketPrice
@ -211,7 +218,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
if (response[`${DEFAULT_CURRENCY}ILS`]) { if (response[`${DEFAULT_CURRENCY}ILS`]) {
response[`${DEFAULT_CURRENCY}ILA`] = { response[`${DEFAULT_CURRENCY}ILA`] = {
...response[`${DEFAULT_CURRENCY}ILS`], ...response[`${DEFAULT_CURRENCY}ILS`],
currency: `${DEFAULT_CURRENCY}ILA`, currency: 'ILA',
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${DEFAULT_CURRENCY}ILA`, symbol: `${DEFAULT_CURRENCY}ILA`,
value: response[`${DEFAULT_CURRENCY}ILS`].marketPrice value: response[`${DEFAULT_CURRENCY}ILS`].marketPrice
@ -219,9 +226,26 @@ export class EodHistoricalDataService implements DataProviderInterface {
}; };
} }
if (response[`${DEFAULT_CURRENCY}USX`]) {
response[`${DEFAULT_CURRENCY}USX`] = {
currency: 'USX',
dataSource: this.getName(),
marketPrice: new Big(1).mul(100).toNumber(),
marketState: 'open'
};
}
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, 'EodHistoricalDataService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'EodHistoricalDataService');
} }
return {}; return {};
@ -345,7 +369,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const response = await got( const response = await got(
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`, `${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
@ -374,7 +398,15 @@ export class EodHistoricalDataService implements DataProviderInterface {
} }
); );
} catch (error) { } catch (error) {
Logger.error(error, 'EodHistoricalDataService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'EodHistoricalDataService');
} }
return searchResult; return searchResult;

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

@ -5,10 +5,7 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
@ -70,7 +67,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { historical } = await got( const { historical } = await got(
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`, `${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`,
@ -113,13 +110,17 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return DataSource.FINANCIAL_MODELING_PREP; return DataSource.FINANCIAL_MODELING_PREP;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
const results: { [symbol: string]: IDataProviderResponse } = {}; }: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) { if (symbols.length <= 0) {
return {}; return response;
} }
try { try {
@ -127,18 +128,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, requestTimeout);
const response = await got( const quotes = await got(
`${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`, `${this.URL}/quote/${symbols.join(',')}?apikey=${this.apiKey}`,
{ {
// @ts-ignore // @ts-ignore
signal: abortController.signal signal: abortController.signal
} }
).json<any>(); ).json<any>();
for (const { price, symbol } of response) { for (const { price, symbol } of quotes) {
results[symbol] = { response[symbol] = {
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.FINANCIAL_MODELING_PREP, dataSource: DataSource.FINANCIAL_MODELING_PREP,
@ -147,10 +148,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}; };
} }
} catch (error) { } catch (error) {
Logger.error(error, 'FinancialModelingPrepService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'FinancialModelingPrepService');
} }
return results; return response;
} }
public getTestSymbol() { public getTestSymbol() {
@ -171,7 +180,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const result = await got( const result = await got(
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`, `${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
@ -192,7 +201,15 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}; };
}); });
} catch (error) { } catch (error) {
Logger.error(error, 'FinancialModelingPrepService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'FinancialModelingPrepService');
} }
return { items }; return { items };

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

@ -99,18 +99,22 @@ export class GoogleSheetsService implements DataProviderInterface {
return DataSource.GOOGLE_SHEETS; return DataSource.GOOGLE_SHEETS;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
if (aSymbols.length <= 0) { }: {
return {}; requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) {
return response;
} }
try { try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols.map((symbol) => { symbols.map((symbol) => {
return { return {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()
@ -129,7 +133,7 @@ export class GoogleSheetsService implements DataProviderInterface {
const marketPrice = parseFloat(row['marketPrice']); const marketPrice = parseFloat(row['marketPrice']);
const symbol = row['symbol']; const symbol = row['symbol'];
if (aSymbols.includes(symbol)) { if (symbols.includes(symbol)) {
response[symbol] = { response[symbol] = {
marketPrice, marketPrice,
currency: symbolProfiles.find((symbolProfile) => { currency: symbolProfiles.find((symbolProfile) => {

2
apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts

@ -2,9 +2,11 @@ import { SymbolProfile } from '@prisma/client';
export interface DataEnhancerInterface { export interface DataEnhancerInterface {
enhance({ enhance({
requestTimeout,
response, response,
symbol symbol
}: { }: {
requestTimeout?: number;
response: Partial<SymbolProfile>; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<Partial<SymbolProfile>>; }): Promise<Partial<SymbolProfile>>;

10
apps/api/src/services/data-provider/interfaces/data-provider.interface.ts

@ -36,9 +36,13 @@ export interface DataProviderInterface {
getName(): DataSource; getName(): DataSource;
getQuotes( getQuotes({
aSymbols: string[] requestTimeout,
): Promise<{ [symbol: string]: IDataProviderResponse }>; symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }>;
getTestSymbol(): string; getTestSymbol(): string;

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

@ -1,10 +1,15 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { DATE_FORMAT, extractNumberFromString, getYesterday } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
extractNumberFromString,
getYesterday
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@ -16,6 +21,7 @@ import got from 'got';
@Injectable() @Injectable()
export class ManualService implements DataProviderInterface { export class ManualService implements DataProviderInterface {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -90,7 +96,6 @@ export class ManualService implements DataProviderInterface {
return {}; return {};
} }
const value = await this.scrape(url, selector, headers); const value = await this.scrape(url, selector, headers);
return { return {
[symbol]: { [symbol]: {
@ -120,9 +125,8 @@ export class ManualService implements DataProviderInterface {
}); });
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const $ = cheerio.load(body);
return extractNumberFromString($(selector).first().text()); return extractNumberFromString($(selector).first().text());
@ -133,18 +137,22 @@ export class ManualService implements DataProviderInterface {
return DataSource.MANUAL; return DataSource.MANUAL;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {}; const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) { if (symbols.length <= 0) {
return response; return response;
} }
try { try {
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols.map((symbol) => { symbols.map((symbol) => {
return { symbol, dataSource: this.getName() }; return { symbol, dataSource: this.getName() };
}) })
); );
@ -154,10 +162,10 @@ export class ManualService implements DataProviderInterface {
orderBy: { orderBy: {
date: 'desc' date: 'desc'
}, },
take: aSymbols.length, take: symbols.length,
where: { where: {
symbol: { symbol: {
in: aSymbols in: symbols
} }
} }
}); });

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

@ -5,10 +5,7 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
DEFAULT_REQUEST_TIMEOUT,
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -87,15 +84,19 @@ export class RapidApiService implements DataProviderInterface {
return DataSource.RAPID_API; return DataSource.RAPID_API;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
if (aSymbols.length <= 0) { }: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (symbols.length <= 0) {
return {}; return {};
} }
try { try {
const symbol = aSymbols[0]; const symbol = symbols[0];
if (symbol === ghostfolioFearAndGreedIndexSymbol) { if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
@ -142,7 +143,7 @@ export class RapidApiService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { fgi } = await got( const { fgi } = await got(
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, `https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
@ -159,7 +160,15 @@ export class RapidApiService implements DataProviderInterface {
return fgi; return fgi;
} catch (error) { } catch (error) {
Logger.error(error, 'RapidApiService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
}
Logger.error(message, 'RapidApiService');
return undefined; return undefined;
} }

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

@ -1,4 +1,5 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -19,6 +20,7 @@ import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote';
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService, private readonly cryptocurrencyService: CryptocurrencyService,
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
) {} ) {}
@ -30,7 +32,7 @@ export class YahooFinanceService implements DataProviderInterface {
public async getAssetProfile( public async getAssetProfile(
aSymbol: string aSymbol: string
): Promise<Partial<SymbolProfile>> { ): Promise<Partial<SymbolProfile>> {
const { assetClass, assetSubClass, currency, name } = const { assetClass, assetSubClass, currency, name, symbol } =
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol); await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol);
return { return {
@ -38,8 +40,8 @@ export class YahooFinanceService implements DataProviderInterface {
assetSubClass, assetSubClass,
currency, currency,
name, name,
dataSource: this.getName(), symbol,
symbol: aSymbol dataSource: this.getName()
}; };
} }
@ -156,20 +158,24 @@ export class YahooFinanceService implements DataProviderInterface {
return DataSource.YAHOO; return DataSource.YAHOO;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
if (aSymbols.length <= 0) { }: {
return {}; requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) {
return response;
} }
const yahooFinanceSymbols = aSymbols.map((symbol) => const yahooFinanceSymbols = symbols.map((symbol) =>
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol) this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol)
); );
try { try {
const response: { [symbol: string]: IDataProviderResponse } = {};
let quotes: Pick< let quotes: Pick<
Quote, Quote,
'currency' | 'marketState' | 'regularMarketPrice' | 'symbol' 'currency' | 'marketState' | 'regularMarketPrice' | 'symbol'

24
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -95,6 +95,30 @@ export class ExchangeRateDataService {
const [currency1, currency2] = symbol.match(/.{1,3}/g); const [currency1, currency2] = symbol.match(/.{1,3}/g);
const [date] = Object.keys(result[symbol]); const [date] = Object.keys(result[symbol]);
// Add derived currencies
if (currency2 === 'GBP') {
resultExtended[`${currency1}GBp`] = {
[date]: {
marketPrice:
result[`${currency1}${currency2}`][date].marketPrice * 100
}
};
} else if (currency2 === 'ILS') {
resultExtended[`${currency1}ILA`] = {
[date]: {
marketPrice:
result[`${currency1}${currency2}`][date].marketPrice * 100
}
};
} else if (currency2 === 'ZAR') {
resultExtended[`${currency1}ZAc`] = {
[date]: {
marketPrice:
result[`${currency1}${currency2}`][date].marketPrice * 100
}
};
}
// Calculate the opposite direction // Calculate the opposite direction
resultExtended[`${currency2}${currency1}`] = { resultExtended[`${currency2}${currency1}`] = {
[date]: { [date]: {

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

@ -32,6 +32,7 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_HOST: string; REDIS_HOST: string;
REDIS_PASSWORD: string; REDIS_PASSWORD: string;
REDIS_PORT: number; REDIS_PORT: number;
REQUEST_TIMEOUT: number;
ROOT_URL: string; ROOT_URL: string;
STRIPE_PUBLIC_KEY: string; STRIPE_PUBLIC_KEY: string;
STRIPE_SECRET_KEY: string; STRIPE_SECRET_KEY: string;

19
apps/api/src/services/market-data/market-data.service.ts

@ -59,12 +59,12 @@ export class MarketDataService {
public async getRange({ public async getRange({
dateQuery, dateQuery,
symbols uniqueAssets
}: { }: {
dateQuery: DateQuery; dateQuery: DateQuery;
symbols: string[]; uniqueAssets: UniqueAsset[];
}): Promise<MarketData[]> { }): Promise<MarketData[]> {
return await this.prismaService.marketData.findMany({ return this.prismaService.marketData.findMany({
orderBy: [ orderBy: [
{ {
date: 'asc' date: 'asc'
@ -74,24 +74,33 @@ export class MarketDataService {
} }
], ],
where: { where: {
dataSource: {
in: uniqueAssets.map(({ dataSource }) => {
return dataSource;
})
},
date: dateQuery, date: dateQuery,
symbol: { symbol: {
in: symbols in: uniqueAssets.map(({ symbol }) => {
return symbol;
})
} }
} }
}); });
} }
public async marketDataItems(params: { public async marketDataItems(params: {
select?: Prisma.MarketDataSelectScalar;
skip?: number; skip?: number;
take?: number; take?: number;
cursor?: Prisma.MarketDataWhereUniqueInput; cursor?: Prisma.MarketDataWhereUniqueInput;
where?: Prisma.MarketDataWhereInput; where?: Prisma.MarketDataWhereInput;
orderBy?: Prisma.MarketDataOrderByWithRelationInput; orderBy?: Prisma.MarketDataOrderByWithRelationInput;
}): Promise<MarketData[]> { }): Promise<MarketData[]> {
const { skip, take, cursor, where, orderBy } = params; const { select, skip, take, cursor, where, orderBy } = params;
return this.prismaService.marketData.findMany({ return this.prismaService.marketData.findMany({
select,
cursor, cursor,
orderBy, orderBy,
skip, skip,

2
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -89,6 +89,7 @@ export class SymbolProfileService {
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
currency,
dataSource, dataSource,
name, name,
scraperConfiguration, scraperConfiguration,
@ -100,6 +101,7 @@ export class SymbolProfileService {
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
currency,
name, name,
scraperConfiguration, scraperConfiguration,
symbolMapping symbolMapping

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

@ -57,7 +57,7 @@ export class TwitterBotService {
symbolItem.marketPrice symbolItem.marketPrice
}/100)`; }/100)`;
const benchmarkListing = await this.getBenchmarkListing(3); const benchmarkListing = await this.getBenchmarkListing();
if (benchmarkListing?.length > 1) { if (benchmarkListing?.length > 1) {
status += '\n\n'; status += '\n\n';
@ -78,29 +78,22 @@ export class TwitterBotService {
} }
} }
private async getBenchmarkListing(aMax: number) { private async getBenchmarkListing() {
const benchmarks = await this.benchmarkService.getBenchmarks({ const benchmarks = await this.benchmarkService.getBenchmarks({
enableSharing: true,
useCache: false useCache: false
}); });
const benchmarkListing: string[] = []; return benchmarks
.map(({ marketCondition, name, performances }) => {
for (const [index, benchmark] of benchmarks.entries()) { return `${name} ${(
if (index > aMax - 1) { performances.allTimeHigh.performancePercent * 100
break;
}
benchmarkListing.push(
`${benchmark.name} ${(
benchmark.performances.allTimeHigh.performancePercent * 100
).toFixed(1)}%${ ).toFixed(1)}%${
benchmark.marketCondition !== 'NEUTRAL_MARKET' marketCondition !== 'NEUTRAL_MARKET'
? ' ' + resolveMarketCondition(benchmark.marketCondition).emoji ? ' ' + resolveMarketCondition(marketCondition).emoji
: '' : ''
}` }`;
); })
} .join('\n');
return benchmarkListing.join('\n');
} }
} }

6
apps/api/webpack.config.js

@ -0,0 +1,6 @@
const { composePlugins, withNx } = require('@nx/webpack');
module.exports = composePlugins(withNx(), (config, { options, context }) => {
// Customize webpack config here
return config;
});

43
apps/client/project.json

@ -60,6 +60,10 @@
"baseHref": "/nl/", "baseHref": "/nl/",
"localize": ["nl"] "localize": ["nl"]
}, },
"development-pl": {
"baseHref": "/pl/",
"localize": ["pl"]
},
"development-pt": { "development-pt": {
"baseHref": "/pt/", "baseHref": "/pt/",
"localize": ["pt"] "localize": ["pt"]
@ -146,45 +150,48 @@
} }
}, },
"serve": { "serve": {
"executor": "@nx/angular:webpack-dev-server", "executor": "@nx/angular:dev-server",
"options": { "options": {
"browserTarget": "client:build", "proxyConfig": "apps/client/proxy.conf.json",
"proxyConfig": "apps/client/proxy.conf.json" "buildTarget": "client:build"
}, },
"configurations": { "configurations": {
"development-de": { "development-de": {
"browserTarget": "client:build:development-de" "buildTarget": "client:build:development-de"
}, },
"development-en": { "development-en": {
"browserTarget": "client:build:development-en" "buildTarget": "client:build:development-en"
}, },
"development-es": { "development-es": {
"browserTarget": "client:build:development-es" "buildTarget": "client:build:development-es"
}, },
"development-fr": { "development-fr": {
"browserTarget": "client:build:development-fr" "buildTarget": "client:build:development-fr"
}, },
"development-it": { "development-it": {
"browserTarget": "client:build:development-it" "buildTarget": "client:build:development-it"
}, },
"development-nl": { "development-nl": {
"browserTarget": "client:build:development-nl" "buildTarget": "client:build:development-nl"
},
"development-pl": {
"buildTarget": "client:build:development-pl"
}, },
"development-pt": { "development-pt": {
"browserTarget": "client:build:development-pt" "buildTarget": "client:build:development-pt"
}, },
"development-tr": { "development-tr": {
"browserTarget": "client:build:development-tr" "buildTarget": "client:build:development-tr"
}, },
"production": { "production": {
"browserTarget": "client:build:production" "buildTarget": "client:build:production"
} }
} }
}, },
"extract-i18n": { "extract-i18n": {
"executor": "ng-extract-i18n-merge:ng-extract-i18n-merge", "executor": "ng-extract-i18n-merge:ng-extract-i18n-merge",
"options": { "options": {
"browserTarget": "client:build", "buildTarget": "client:build",
"includeContext": true, "includeContext": true,
"outputPath": "src/locales", "outputPath": "src/locales",
"targetFiles": [ "targetFiles": [
@ -193,13 +200,14 @@
"messages.fr.xlf", "messages.fr.xlf",
"messages.it.xlf", "messages.it.xlf",
"messages.nl.xlf", "messages.nl.xlf",
"messages.pl.xlf",
"messages.pt.xlf", "messages.pt.xlf",
"messages.tr.xlf" "messages.tr.xlf"
] ]
} }
}, },
"lint": { "lint": {
"executor": "@nrwl/linter:eslint", "executor": "@nx/eslint:lint",
"options": { "options": {
"lintFilePatterns": ["apps/client/**/*.ts"] "lintFilePatterns": ["apps/client/**/*.ts"]
} }
@ -207,8 +215,7 @@
"test": { "test": {
"executor": "@nx/jest:jest", "executor": "@nx/jest:jest",
"options": { "options": {
"jestConfig": "apps/client/jest.config.ts", "jestConfig": "apps/client/jest.config.ts"
"passWithNoTests": true
}, },
"outputs": ["{workspaceRoot}/coverage/apps/client"] "outputs": ["{workspaceRoot}/coverage/apps/client"]
} }
@ -235,6 +242,10 @@
"baseHref": "/nl/", "baseHref": "/nl/",
"translation": "apps/client/src/locales/messages.nl.xlf" "translation": "apps/client/src/locales/messages.nl.xlf"
}, },
"pl": {
"baseHref": "/pl/",
"translation": "apps/client/src/locales/messages.pl.xlf"
},
"pt": { "pt": {
"baseHref": "/pt/", "baseHref": "/pt/",
"translation": "apps/client/src/locales/messages.pt.xlf" "translation": "apps/client/src/locales/messages.pt.xlf"

6
apps/client/src/app/adapter/custom-date-adapter.ts

@ -1,4 +1,3 @@
import { Platform } from '@angular/cdk/platform';
import { Inject, forwardRef } from '@angular/core'; import { Inject, forwardRef } from '@angular/core';
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core'; import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
@ -7,10 +6,9 @@ import { addYears, format, getYear, parse } from 'date-fns';
export class CustomDateAdapter extends NativeDateAdapter { export class CustomDateAdapter extends NativeDateAdapter {
public constructor( public constructor(
@Inject(MAT_DATE_LOCALE) public locale: string, @Inject(MAT_DATE_LOCALE) public locale: string,
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string, @Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string
platform: Platform
) { ) {
super(matDateLocale, platform); super(matDateLocale);
} }
/** /**

20
apps/client/src/app/app.component.html

@ -1,6 +1,6 @@
<header> <header>
<div <div
*ngIf="canCreateAccount || (info?.systemMessage && user)" *ngIf="canCreateAccount || user?.systemMessage"
class="info-message-container" class="info-message-container"
> >
<div class="info-message-inner-container position-fixed w-100"> <div class="info-message-inner-container position-fixed w-100">
@ -19,11 +19,11 @@
</div></a </div></a
> >
<div <div
*ngIf="!canCreateAccount && info?.systemMessage && user" *ngIf="!canCreateAccount && user?.systemMessage"
class="cursor-pointer d-inline-block info-message text-truncate" class="cursor-pointer d-inline-block info-message text-truncate"
(click)="onShowSystemMessage()" (click)="onClickSystemMessage()"
> >
{{ info.systemMessage }} {{ user.systemMessage.message }}
</div> </div>
</div> </div>
</div> </div>
@ -127,8 +127,11 @@
class="align-items-baseline d-flex" class="align-items-baseline d-flex"
href="https://twitter.com/ghostfolio_" href="https://twitter.com/ghostfolio_"
target="_blank" target="_blank"
title="Follow Ghostfolio on Twitter" title="Follow Ghostfolio on X (formerly Twitter)"
>Twitter<ion-icon class="ml-1" name="open-outline"></ion-icon >X (formerly Twitter)<ion-icon
class="ml-1"
name="open-outline"
></ion-icon
></a> ></a>
</li> </li>
<li>&nbsp;</li> <li>&nbsp;</li>
@ -150,6 +153,11 @@
<li> <li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a> <a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li> </li>
<!--
<li>
<a href="../pl" title="Ghostfolio in Polski">Polski</a>
</li>
-->
<li> <li>
<a href="../pt" title="Ghostfolio in Português">Português</a> <a href="../pt" title="Ghostfolio in Português">Português</a>
</li> </li>

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

@ -155,10 +155,7 @@ export class AppComponent implements OnDestroy, OnInit {
); );
this.hasInfoMessage = this.hasInfoMessage =
hasPermission( this.canCreateAccount || !!this.user?.systemMessage;
this.user?.permissions,
permissions.createUserAccount
) || !!this.info.systemMessage;
this.initializeTheme(this.user?.settings.colorScheme); this.initializeTheme(this.user?.settings.colorScheme);
@ -166,12 +163,16 @@ export class AppComponent implements OnDestroy, OnInit {
}); });
} }
public onCreateAccount() { public onClickSystemMessage() {
this.tokenStorageService.signOut(); if (this.user.systemMessage.routerLink) {
this.router.navigate(this.user.systemMessage.routerLink);
} else {
alert(this.user.systemMessage.message);
}
} }
public onShowSystemMessage() { public onCreateAccount() {
alert(this.info.systemMessage); this.tokenStorageService.signOut();
} }
public onSignOut() { public onSignOut() {

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

@ -37,7 +37,7 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th> <th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell> <td *matCellDef="let element" class="px-1 text-center" mat-cell>

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

@ -7,11 +7,18 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper'; import { downloadAsFile } from '@ghostfolio/common/helper';
import { HistoricalDataItem, User } from '@ghostfolio/common/interfaces'; import {
AccountBalancesResponse,
HistoricalDataItem,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import Big from 'big.js'; import Big from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
@ -29,15 +36,22 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./account-detail-dialog.component.scss'] styleUrls: ['./account-detail-dialog.component.scss']
}) })
export class AccountDetailDialog implements OnDestroy, OnInit { export class AccountDetailDialog implements OnDestroy, OnInit {
public accountBalances: AccountBalancesResponse['balances'];
public activities: OrderWithAccount[];
public balance: number; public balance: number;
public currency: string; public currency: string;
public dataSource: MatTableDataSource<OrderWithAccount>;
public equity: number; public equity: number;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToDeleteAccountBalance: boolean;
public historicalDataItems: HistoricalDataItem[]; public historicalDataItems: HistoricalDataItem[];
public isLoadingActivities: boolean;
public isLoadingChart: boolean; public isLoadingChart: boolean;
public name: string; public name: string;
public orders: OrderWithAccount[];
public platformName: string; public platformName: string;
public sortColumn = 'date';
public sortDirection: SortDirection = 'desc';
public totalItems: number;
public transactionCount: number; public transactionCount: number;
public user: User; public user: User;
public valueInBaseCurrency: number; public valueInBaseCurrency: number;
@ -58,14 +72,17 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToDeleteAccountBalance = hasPermission(
this.user.permissions,
permissions.deleteAccountBalance
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
} }
public ngOnInit() { public ngOnInit() {
this.isLoadingChart = true;
this.dataService this.dataService
.fetchAccount(this.data.accountId) .fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -97,16 +114,119 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
} }
); );
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.fetchAccountBalances();
this.fetchActivities();
this.fetchPortfolioPerformance();
}
public onClose() {
this.dialogRef.close();
}
public onDeleteAccountBalance(aId: string) {
this.dataService this.dataService
.fetchActivities({ .deleteAccountBalance(aId)
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => { .subscribe({
this.orders = activities; next: () => {
this.fetchAccountBalances();
this.fetchPortfolioPerformance();
}
});
}
public onExport() {
let activityIds = [];
if (this.user?.settings?.isExperimentalFeatures === true) {
activityIds = this.dataSource.data.map(({ id }) => {
return id;
});
} else {
activityIds = this.activities.map(({ id }) => {
return id;
});
}
this.dataService
.fetchExport(activityIds)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${this.name
.replace(/\s+/g, '-')
.toLowerCase()}-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
format: 'json'
});
});
}
public onSortChanged({ active, direction }: Sort) {
this.sortColumn = active;
this.sortDirection = direction;
this.fetchActivities();
}
private fetchAccountBalances() {
this.dataService
.fetchAccountBalances(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ balances }) => {
this.accountBalances = balances;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
}
private fetchActivities() {
this.isLoadingActivities = true;
if (this.user?.settings?.isExperimentalFeatures === true) {
this.dataService
.fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }],
sortColumn: this.sortColumn,
sortDirection: this.sortDirection
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities, count }) => {
this.dataSource = new MatTableDataSource(activities);
this.totalItems = count;
this.isLoadingActivities = false;
this.changeDetectorRef.markForCheck();
});
} else {
this.dataService
.fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.activities = activities;
this.isLoadingActivities = false;
this.changeDetectorRef.markForCheck();
});
}
}
private fetchPortfolioPerformance() {
this.isLoadingChart = true;
this.dataService this.dataService
.fetchPortfolioPerformance({ .fetchPortfolioPerformance({
@ -122,13 +242,13 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => { .subscribe(({ chart }) => {
this.historicalDataItems = chart.map( this.historicalDataItems = chart.map(
({ date, value, valueInPercentage }) => { ({ date, netWorth, netWorthInPercentage }) => {
return { return {
date, date,
value: value:
this.hasImpersonationId || this.user.settings.isRestrictedView this.hasImpersonationId || this.user.settings.isRestrictedView
? valueInPercentage ? netWorthInPercentage
: value : netWorth
}; };
} }
); );
@ -137,39 +257,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
}
public onClose() {
this.dialogRef.close();
}
public onExport() {
this.dataService
.fetchExport(
this.orders.map((order) => {
return order.id;
})
)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${this.name
.replace(/\s+/g, '-')
.toLowerCase()}-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
format: 'json'
});
});
} }
public ngOnDestroy() { public ngOnDestroy() {

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

Loading…
Cancel
Save