Compare commits

...

64 Commits

Author SHA1 Message Date
dfb3365efb Release 2.84.0 (#3447) 2024-06-01 11:16:13 +02:00
0e08d8830e Handle reduce of empty array (#3446) 2024-06-01 11:14:34 +02:00
69d85eadfd Feature/setup cascading on delete for various relations in database schema (#3445)
* Setup cascading on delete

* Update changelog
2024-06-01 11:08:36 +02:00
c009f8c12f Feature/add data provider info to asset profile details dialog (#3434)
* Add data provider info to asset profile details dialog

* Update changelog
2024-06-01 10:55:42 +02:00
60ef46accf Bugfix/fix state handling of currency selector component (#2795) (#3429)
* Fix state handling of currency selector component

* Update changelog
2024-06-01 10:53:02 +02:00
b12ac1fe84 Feature/simplify module imports of api (#3443)
* Simplify module imports
2024-06-01 10:02:43 +02:00
4355c96ab6 Bugfix/fix initial annual interest rate in fire calculator (#3437)
* Fix initial annual interest rate

* Update changelog
2024-05-31 17:29:19 +02:00
fb326fe0cc Release 2.83.0 (#3442) 2024-05-30 20:47:43 +02:00
02cfebd98c Feature/upgrade yahoo finance2 to version 2.11.3 (#3441)
* Upgrade yahoo-finance2 to version 2.11.3

* Update changelog
2024-05-30 20:45:32 +02:00
dd2936d703 Feature/upgrade countup.js to version 2.8.0 (#3436)
* Upgrade countup.js to version 2.8.0

* Update changelog
2024-05-29 16:06:58 +02:00
918d0b85d4 Feature/update passport dependencies (#3433)
* Update passport dependencies

* Refactor Google strategy

* Update changelog
2024-05-28 13:47:45 +02:00
a061595101 Feature/upgrade class validator to version 0.14.1 (#3431)
* Upgrade class-validator to version 0.14.1

* Update changelog
2024-05-27 13:55:24 +02:00
dcd496ac50 Feature/upgrade angular to version 17.3.10 (#3430)
* Upgrade angular to version 17.3.10

* Update changelog
2024-05-26 14:13:43 +02:00
6b9ec549da Feature/upgrade prisma to version 5.14.0 (#3423)
* Upgrade prisma to version 5.14.0

* Update changelog
2024-05-24 15:36:36 +02:00
b5bd4df483 Feature/upgrade nx to version 19.0.5 (#3422)
* Upgrade Nx to version 19.0.5

* Update changelog
2024-05-23 20:48:26 +02:00
766a2d7c2f Release 2.82.0 (#3425) 2024-05-22 19:53:20 +02:00
6dabf7516a Feature/preselect account in create or update activity dialog (#3413)
* Preselect account if there is only one

* Update changelog
2024-05-22 18:15:08 +02:00
5d49ff7a4a Feature/improve usability of date range selector in assistant (#3409)
* Improve usability of date range selector

* Update changelog
2024-05-22 16:39:39 +02:00
8998c18836 Feature/upgrade internationalized number to version 3.5.2 (#3412)
* Upgrade @internationalized/number to version 3.5.2

* Update changelog
2024-05-22 16:36:50 +02:00
741a0e36d2 Add links to tagged issues (#3405)
* help wanted
* good first issue
2024-05-20 22:28:43 +03:00
e31b4c64cb Feature/refactor holding detail dialog to standalone (#3407)
* Refactor holding detail dialog to standalone

* Update changelog
2024-05-19 19:13:14 +03:00
812ff5cbdc Feature/refresh cryptocurrencies list 20240514 (#3411)
* Refresh cryptocurrencies list

* Update changelog
2024-05-18 17:40:25 +03:00
035b90a689 Feature/upgrade zone.js to version 0.14.5 (#3410)
* Upgrade zone.js to version 0.14.5

* Update changelog
2024-05-17 18:08:37 +03:00
5ea3a187f4 Feature/upgrade body parser to version 1.20.2 (#3406)
* Upgrade body-parser to version 1.20.2

* Update changelog
2024-05-16 17:26:27 +02:00
5d9c38663d Feature/migrate various pages to standalone components (#3404)
* Migrate to standalone components

* Update changelog
2024-05-15 12:04:07 +02:00
5616bc4956 Feature/validate account balance creation/update using DTO (#3400)
* Validate create account balance using DTO
2024-05-13 13:47:33 +02:00
9ad1c2177c Release 2.81.0 (#3403) 2024-05-12 10:42:04 +02:00
15bf9f2f9c Feature/add Türkçe to footer (#3401) 2024-05-12 10:34:33 +02:00
ebc5008569 Feature/improve language localization for de 20240512 (#3402)
* Update translations

* Update changelog
2024-05-12 10:25:59 +02:00
37759ba03f Feature/improve language localization for tr 20240501 (#3355)
* Improve translations

* Update changelog

---------

Co-authored-by: sadmimye <134071831+sadmimye@users.noreply.github.com>
2024-05-12 09:58:11 +02:00
8319b216bb Feature/support delete activities with filtering (#3394)
* Support delete activities with filtering

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-05-12 09:56:07 +02:00
782d131b0d Feature/add indicator for active filters (#3398)
* Add indicator for active filters

* Update changelog
2024-05-12 09:42:27 +02:00
72e75208df Bugfix/fix position detail dialog close functionality (#3396)
* Handle holding detail dialog open functionality in a single place (AppComponent)

* Update changelog
2024-05-11 20:27:18 +02:00
4b1c27c245 Feature/upgrade nx to version 19.0.2 (#3391)
* Upgrade Nx dependencies to version 19.0.2

* Update changelog
2024-05-11 08:33:33 +02:00
61f0da35bc Feature/disable delete all activities if filters are active (#3389)
* Disable delete all activities button if filters are active

* Update changelog
2024-05-10 08:51:34 +02:00
80464c7846 Release 2.80.0 (#3386) 2024-05-08 20:55:39 +02:00
74f4323903 Feature/increase spacing around floating action buttons (#3385)
* Increase spacing around floating action buttons

* Update changelog
2024-05-08 20:54:13 +02:00
127dbf9dcd Update translations (#3384) 2024-05-08 20:37:53 +02:00
66bdb374e8 Feature/set icon columns of tables to stick at beginning (#3377)
* Set icon columns to stick at the beginning

* Update changelog
2024-05-08 20:04:58 +02:00
4ad4fa2b30 Feature/clean up deprecated GET api/portfolio/positions endpoint (#3373) 2024-05-08 20:04:32 +02:00
1fd836194f Feature/add absolute change column to holdings table (#3378)
* Add absolute change column

* Update changelog
2024-05-08 20:02:50 +02:00
2090db1199 Feature/increase number of attempts of queue jobs (#3376)
* Increase number of attempts

* Update changelog
2024-05-07 20:48:02 +02:00
053c7e591e Feature/upgrade ionicons to version 7.4.0 (#3356)
* Upgrade ionicons to version 7.4.0

* Update changelog
2024-05-07 19:00:55 +02:00
9b5e350e3b Feature/harmonize log message (#3343) 2024-05-06 17:03:58 +02:00
378e57c3bc Feature/add links to Home Assistant add-on (#3367)
* Add links to Home Assistant add-on
2024-05-06 17:02:18 +02:00
6765191a8c Bugfix/fix position detail dialog open in holding search of assistant (#3374)
* Open position detail dialog (via holding search of assistant)

* Update changelog
2024-05-05 09:09:57 +02:00
8438a45bcf Update changelog (#3372) 2024-05-04 16:32:32 +02:00
30a64e7fc1 Release 2.79.0 (#3371) 2024-05-04 15:54:29 +02:00
f2cb671c7f Feature/optimize get porfolio details endpoint (#3366)
* Eliminate getPerformance() from getSummary() function

* Disable cache for getDetails()

* Add hint to portfolio summary

* Update changelog
2024-05-04 15:53:02 +02:00
3f41e5c5de Bugfix/fix locale in markets overview (#3369)
* Fix locale if no user is logged in

* Update changelog
2024-05-04 15:51:09 +02:00
c1ad483f33 Improve alignment (#3370) 2024-05-04 15:50:47 +02:00
f3d961bc16 Feature/move holdings table to holdings tab of home page (#3368)
* Move holdings table to holdings tab of home page

* Deprecate api/v1/portfolio/positions endpoint

* Update changelog
2024-05-04 14:11:37 +02:00
42b70ef568 Feature/improve performance labels in position detail dialog (#3363)
* Improve performance labels (with and without currency effects)

* Update changelog
2024-05-04 07:49:37 +02:00
77beaaba08 Refactoring portfolio service (#3365) 2024-05-03 21:48:46 +02:00
d9c07456cd Release 2.78.0 (#3361) 2024-05-02 20:32:47 +02:00
0a53df4293 Feature/improve inactive user role (#3360)
* Improve inactive role

* Update changelog
2024-05-02 20:31:20 +02:00
4416ba0c88 Feature/set performance column of holdings table to stick at end (#3353)
* Set up stickyEnd in performance column

* Update changelog
2024-05-02 19:16:59 +02:00
486de968a2 Bugfix/fix division by zero error in dividend yield calculation (#3354)
* Handle division by zero

* Update changelog
2024-05-02 17:53:34 +02:00
a5833566a8 Feature/skip caching in portfolio calculator if active filters (#3348)
* Skip caching if active filters

* Update changelog
2024-05-02 17:52:39 +02:00
261f5844dd Add type column to README.md (#3295) 2024-04-30 14:08:14 +02:00
2173c418a7 Feature/validate forms using DTO for access, asset profile, tag and platform management (#3337)
* Validate forms using DTO for access, asset profile, tag and platform management

* Update changelog
2024-04-30 08:04:45 +02:00
4efd5cefd8 Bugfix/calculation of portfolio summary caused by future liabilities (#3342)
* Adapt date of future activities

* Update changelog
2024-04-29 20:12:12 +02:00
d735e4db75 Release 2.77.1 (#3340) 2024-04-27 19:25:23 +02:00
e10707fde4 Add missing guard to fix public page (#3339) 2024-04-27 19:24:08 +02:00
188 changed files with 7872 additions and 5755 deletions

View File

@ -5,7 +5,115 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.77.0 - 2024-04-27
## 2.84.0 - 2024-06-01
### Added
- Added the data provider information to the asset profile details dialog of the admin control
- Added the cascading on delete for various relations in the database schema
### Fixed
- Fixed an issue with the initial annual interest rate in the _FIRE_ calculator
- Fixed the state handling in the currency selector
- Fixed the deletion of an asset profile with symbol profile overrides in the asset profile details dialog of the admin control
## 2.83.0 - 2024-05-30
### Changed
- Upgraded `@nestjs/passport` from version `10.0.0` to `10.0.3`
- Upgraded `angular` from version `17.3.5` to `17.3.10`
- Upgraded `class-validator` from version `0.14.0` to `0.14.1`
- Upgraded `countup.js` from version `2.3.2` to `2.8.0`
- Upgraded `Nx` from version `19.0.2` to `19.0.5`
- Upgraded `passport` from version `0.6.0` to `0.7.0`
- Upgraded `passport-jwt` from version `4.0.0` to `4.0.1`
- Upgraded `prisma` from version `5.13.0` to `5.14.0`
- Upgraded `yahoo-finance2` from version `2.11.2` to `2.11.3`
## 2.82.0 - 2024-05-22
### Changed
- Improved the usability of the create or update activity dialog by preselecting the (only) account
- Improved the usability of the date range selector in the assistant
- Refactored the holding detail dialog to a standalone component
- Refreshed the cryptocurrencies list
- Refactored various pages to standalone components
- Upgraded `@internationalized/number` from version `3.5.0` to `3.5.2`
- Upgraded `body-parser` from version `1.20.1` to `1.20.2`
- Upgraded `zone.js` from version `0.14.4` to `0.14.5`
## 2.81.0 - 2024-05-12
### Added
- Added an indicator for active filters (experimental)
### Changed
- Improved the delete all activities functionality on the portfolio activities page to work with the filters of the assistant
- Improved the language localization for German (`de`)
- Improved the language localization for Türkçe (`tr`)
- Upgraded `Nx` from version `18.3.3` to `19.0.2`
### Fixed
- Fixed the position detail dialog close functionality
## 2.80.0 - 2024-05-08
### Added
- Added the absolute change column to the holdings table on the home page
### Changed
- Increased the spacing around the floating action buttons (FAB)
- Set the icon column of the activities table to stick at the beginning
- Set the icon column of the holdings table to stick at the beginning
- Increased the number of attempts of queue jobs from `10` to `12` (fail later)
- Upgraded `ionicons` from version `7.3.0` to `7.4.0`
### Fixed
- Fixed the position detail dialog open functionality when searching for a holding in the assistant
## 2.79.0 - 2024-05-04
### Changed
- Moved the holdings table to the holdings tab of the home page
- Improved the performance labels (with and without currency effects) in the position detail dialog
- Optimized the calculations of the portfolio details endpoint
### Fixed
- Fixed an issue with the benchmarks in the markets overview
- Fixed an issue with the _Fear & Greed Index_ (market mood) in the markets overview
## 2.78.0 - 2024-05-02
### Added
- Added a form validation against the DTO in the create or update access dialog
- Added a form validation against the DTO in the asset profile details dialog of the admin control
- Added a form validation against the DTO in the platform management of the admin control panel
- Added a form validation against the DTO in the tag management of the admin control panel
### Changed
- Set the performance column of the holdings table to stick at the end
- Skipped the caching in the portfolio calculator if there are active filters (experimental)
- Improved the `INACTIVE` user role
### Fixed
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
- Fixed a division by zero error in the dividend yield calculation (experimental)
## 2.77.1 - 2024-04-27
### Added

View File

@ -85,23 +85,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables
| Name | Default Value | Description |
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` |   | The _CoinGecko_ Pro API |
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `0` | The database index of _Redis_ |
| `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
| Name | Type | Default Value | Description |
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | string (`optional`) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API |
| `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | string | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | string | | The password of _Redis_ |
| `REDIS_PORT` | number | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose
@ -142,7 +142,7 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
### Home Server Systems (Community)
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), Home Assistant, [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
## Development
@ -275,7 +275,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.
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.
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). 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).

View File

@ -2,7 +2,7 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
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/redact-values-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {

View File

@ -1,9 +1,7 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -19,13 +17,11 @@ import { AccountService } from './account.service';
imports: [
AccountBalanceModule,
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
PortfolioModule,
PrismaModule,
RedisCacheModule,
UserModule
RedactValuesInResponseModule
],
providers: [AccountService]
})

View File

@ -1,6 +1,6 @@
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/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';

View File

@ -1,4 +1,5 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -27,7 +28,8 @@ import { QueueModule } from './queue/queue.module';
PropertyModule,
QueueModule,
SubscriptionModule,
SymbolProfileModule
SymbolProfileModule,
TransformDataSourceInRequestModule
],
controllers: [AdminController],
providers: [AdminService],

View File

@ -313,6 +313,12 @@ export class AdminService {
})
]);
if (assetProfile) {
assetProfile.dataProviderInfo = this.dataProviderService
.getDataProvider(assetProfile.dataSource)
.getDataProviderInfo();
}
return {
marketData,
assetProfile: assetProfile ?? {

View File

@ -1,6 +1,5 @@
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
@ -9,7 +8,6 @@ import { JwtModule } from '@nestjs/jwt';
@Module({
controllers: [AuthDeviceController],
imports: [
ConfigurationModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }

View File

@ -1,4 +1,3 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
@ -6,10 +5,7 @@ import { AuthDevice, Prisma } from '@prisma/client';
@Injectable()
export class AuthDeviceService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService
) {}
public constructor(private readonly prismaService: PrismaService) {}
public async authDevice(
where: Prisma.AuthDeviceWhereUniqueInput

View File

@ -3,7 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client';
import { Strategy } from 'passport-google-oauth20';
import { Profile, Strategy } from 'passport-google-oauth20';
import { AuthService } from './auth.service';
@ -11,7 +11,7 @@ import { AuthService } from './auth.service';
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
public constructor(
private readonly authService: AuthService,
readonly configurationService: ConfigurationService
private readonly configurationService: ConfigurationService
) {
super({
callbackURL: `${configurationService.get(
@ -20,7 +20,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
clientID: configurationService.get('GOOGLE_CLIENT_ID'),
clientSecret: configurationService.get('GOOGLE_SECRET'),
passReqToCallback: true,
scope: ['email', 'profile']
scope: ['profile']
});
}
@ -28,20 +28,17 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
request: any,
token: string,
refreshToken: string,
profile,
profile: Profile,
done: Function,
done2: Function
) {
try {
const jwt: string = await this.authService.validateOAuthLogin({
const jwt = await this.authService.validateOAuthLogin({
provider: Provider.GOOGLE,
thirdPartyId: profile.id
});
const user = {
jwt
};
done(null, user);
done(null, { jwt });
} catch (error) {
Logger.error(error, 'GoogleStrategy');
done(error, false);

View File

@ -2,10 +2,12 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { HttpException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import * as countriesAndTimezones from 'countries-and-timezones';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
@ -29,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
if (user) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
@ -45,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return user;
} else {
throw '';
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
} catch (error) {
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) {
throw error;
} else {
throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED
);
}
} catch (err) {
throw new UnauthorizedException('unauthorized', err.message);
}
}
}

View File

@ -1,8 +1,8 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import type {
BenchmarkMarketDataDetails,
BenchmarkResponse,

View File

@ -1,6 +1,7 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
@ -17,7 +18,6 @@ import { BenchmarkService } from './benchmark.service';
controllers: [BenchmarkController],
exports: [BenchmarkService],
imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
@ -25,7 +25,9 @@ import { BenchmarkService } from './benchmark.service';
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [BenchmarkService]
})

View File

@ -1,10 +1,4 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
@ -12,14 +6,6 @@ import { CacheController } from './cache.controller';
@Module({
controllers: [CacheController],
imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule
]
imports: [RedisCacheModule]
})
export class CacheModule {}

View File

@ -1,10 +1,6 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { Module } from '@nestjs/common';
@ -12,15 +8,7 @@ import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [
AccountModule,
ApiModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
OrderModule,
RedisCacheModule
],
imports: [AccountModule, ApiModule, OrderModule],
controllers: [ExportController],
providers: [ExportService]
})

View File

@ -1,4 +1,4 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import {
Controller,

View File

@ -1,4 +1,4 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -9,7 +9,11 @@ import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
imports: [
DataEnhancerModule,
DataProviderModule,
TransformDataSourceInRequestModule
],
providers: [HealthService]
})
export class HealthModule {}

View File

@ -1,7 +1,7 @@
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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';

View File

@ -4,6 +4,8 @@ import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -30,7 +32,9 @@ import { ImportService } from './import.service';
PortfolioModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [ImportService]
})

View File

@ -1,4 +1,4 @@
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseInterceptors } from '@nestjs/common';

View File

@ -2,6 +2,7 @@ import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -34,6 +35,7 @@ import { InfoService } from './info.service';
RedisCacheModule,
SymbolProfileModule,
TagModule,
TransformDataSourceInResponseModule,
UserModule
],
providers: [InfoService]

View File

@ -1,4 +1,4 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import {
Controller,

View File

@ -1,3 +1,4 @@
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -8,7 +9,11 @@ import { LogoService } from './logo.service';
@Module({
controllers: [LogoController],
imports: [ConfigurationModule, SymbolProfileModule],
imports: [
ConfigurationModule,
SymbolProfileModule,
TransformDataSourceInRequestModule
],
providers: [LogoService]
})
export class LogoModule {}

View File

@ -1,9 +1,9 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.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 { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
@ -11,7 +11,7 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import {
@ -53,8 +53,20 @@ export class OrderController {
@Delete()
@HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrders(): Promise<number> {
public async deleteOrders(
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<number> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
return this.orderService.deleteOrders({
filters,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id
});
}
@ -88,21 +100,26 @@ export class OrderController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('range') dateRange?: DateRange,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('tags') filterByTags?: string,
@Query('take') take?: number
): Promise<Activities> {
let endDate: Date;
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getInterval(dateRange));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
const { endDate, startDate } = getInterval(dateRange);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency;

View File

@ -2,9 +2,10 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.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';
@ -23,15 +24,16 @@ import { OrderService } from './order.service';
imports: [
ApiModule,
CacheModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
PrismaModule,
RedactValuesInResponseModule,
RedisCacheModule,
SymbolProfileModule,
UserModule
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [AccountBalanceService, AccountService, OrderService]
})

View File

@ -194,16 +194,36 @@ export class OrderService {
return order;
}
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> {
public async deleteOrders({
filters,
userCurrency,
userId
}: {
filters?: Filter[];
userCurrency: string;
userId: string;
}): Promise<number> {
const { activities } = await this.getOrders({
filters,
userId,
userCurrency,
includeDrafts: true,
withExcludedAccounts: true
});
const { count } = await this.prismaService.order.deleteMany({
where
where: {
id: {
in: activities.map(({ id }) => {
return id;
})
}
}
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: <string>where.userId
})
new PortfolioChangedEvent({ userId })
);
return count;

View File

@ -32,6 +32,7 @@ export class PortfolioCalculatorFactory {
calculationType,
currency,
dateRange = 'max',
hasFilters,
isExperimentalFeatures = false,
userId
}: {
@ -40,9 +41,12 @@ export class PortfolioCalculatorFactory {
calculationType: PerformanceCalculationType;
currency: string;
dateRange?: DateRange;
hasFilters: boolean;
isExperimentalFeatures?: boolean;
userId: string;
}): PortfolioCalculator {
const useCache = !hasFilters && isExperimentalFeatures;
switch (calculationType) {
case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({
@ -50,7 +54,7 @@ export class PortfolioCalculatorFactory {
activities,
currency,
dateRange,
isExperimentalFeatures,
useCache,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
@ -64,7 +68,7 @@ export class PortfolioCalculatorFactory {
currency,
currentRateService: this.currentRateService,
dateRange,
isExperimentalFeatures,
useCache,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,

View File

@ -37,6 +37,7 @@ import {
eachDayOfInterval,
endOfDay,
format,
isAfter,
isBefore,
isSameDay,
max,
@ -44,26 +45,26 @@ import {
subDays
} from 'date-fns';
import { first, last, uniq, uniqBy } from 'lodash';
import ms from 'ms';
export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false;
protected accountBalanceItems: HistoricalDataItem[];
protected orders: PortfolioOrder[];
protected activities: PortfolioOrder[];
private configurationService: ConfigurationService;
private currency: string;
private currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[];
private dateRange: DateRange;
private endDate: Date;
private exchangeRateDataService: ExchangeRateDataService;
private isExperimentalFeatures: boolean;
private redisCacheService: RedisCacheService;
private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>;
private startDate: Date;
private transactionPoints: TransactionPoint[];
private useCache: boolean;
private userId: string;
public constructor({
@ -74,8 +75,8 @@ export abstract class PortfolioCalculator {
currentRateService,
dateRange,
exchangeRateDataService,
isExperimentalFeatures,
redisCacheService,
useCache,
userId
}: {
accountBalanceItems: HistoricalDataItem[];
@ -85,18 +86,18 @@ export abstract class PortfolioCalculator {
currentRateService: CurrentRateService;
dateRange: DateRange;
exchangeRateDataService: ExchangeRateDataService;
isExperimentalFeatures: boolean;
redisCacheService: RedisCacheService;
useCache: boolean;
userId: string;
}) {
this.accountBalanceItems = accountBalanceItems;
this.configurationService = configurationService;
this.currency = currency;
this.currentRateService = currentRateService;
this.dateRange = dateRange;
this.exchangeRateDataService = exchangeRateDataService;
this.isExperimentalFeatures = isExperimentalFeatures;
this.orders = activities
this.activities = activities
.map(
({
date,
@ -107,6 +108,12 @@ export abstract class PortfolioCalculator {
type,
unitPrice
}) => {
if (isAfter(date, new Date(Date.now()))) {
// Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval
date = endOfDay(new Date(Date.now()));
}
return {
SymbolProfile,
tags,
@ -123,6 +130,7 @@ export abstract class PortfolioCalculator {
});
this.redisCacheService = redisCacheService;
this.useCache = useCache;
this.userId = userId;
const { endDate, startDate } = getInterval(dateRange);
@ -917,7 +925,7 @@ export abstract class PortfolioCalculator {
tags,
type,
unitPrice
} of this.orders) {
} of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[SymbolProfile.symbol];
@ -1041,11 +1049,13 @@ export abstract class PortfolioCalculator {
}
private async initialize() {
if (this.isExperimentalFeatures) {
if (this.useCache) {
const startTimeTotal = performance.now();
const cachedSnapshot = await this.redisCacheService.get(
this.redisCacheService.getPortfolioSnapshotKey(this.userId)
this.redisCacheService.getPortfolioSnapshotKey({
userId: this.userId
})
);
if (cachedSnapshot) {
@ -1068,7 +1078,9 @@ export abstract class PortfolioCalculator {
);
this.redisCacheService.set(
this.redisCacheService.getPortfolioSnapshotKey(this.userId),
this.redisCacheService.getPortfolioSnapshotKey({
userId: this.userId
}),
JSON.stringify(this.snapshot),
this.configurationService.get('CACHE_QUOTES_TTL')
);

View File

@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});

View File

@ -108,6 +108,7 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});

View File

@ -93,6 +93,7 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});

View File

@ -121,6 +121,7 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});

View File

@ -93,6 +93,7 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});

View File

@ -106,6 +106,7 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});

View File

@ -93,6 +93,7 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});

View File

@ -74,7 +74,7 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2022-01-01'),
date: new Date('2023-01-01'), // Date in future
fee: 0,
quantity: 1,
SymbolProfile: {
@ -93,64 +93,16 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-01-01')
);
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [
{
averagePrice: new Big('3000'),
currency: 'USD',
dataSource: 'MANUAL',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
firstBuyDate: '2022-01-01',
grossPerformance: null,
grossPerformancePercentage: null,
grossPerformancePercentageWithCurrencyEffect: null,
grossPerformanceWithCurrencyEffect: null,
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
marketPrice: null,
marketPriceInBaseCurrency: 3000,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
quantity: new Big('0'),
symbol: '55196015-1365-4560-aa60-8751ae6d18f8',
tags: [],
timeWeightedInvestment: new Big('0'),
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
transactionCount: 1,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
const liabilitiesInBaseCurrency =
await portfolioCalculator.getLiabilitiesInBaseCurrency();
expect(liabilitiesInBaseCurrency).toEqual(new Big(3000));
});
});
});

View File

@ -121,6 +121,7 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});

View File

@ -71,6 +71,7 @@ describe('PortfolioCalculator', () => {
activities: [],
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});

View File

@ -108,6 +108,7 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});

View File

@ -108,6 +108,7 @@ describe('PortfolioCalculator', () => {
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});

View File

@ -203,7 +203,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(this.orders).filter(
let orders: PortfolioOrderItem[] = cloneDeep(this.activities).filter(
({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
}

View File

@ -8,6 +8,13 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) {
switch (symbol) {
case '55196015-1365-4560-aa60-8751ae6d18f8':
if (isSameDay(parseDate('2022-01-31'), date)) {
return { marketPrice: 3000 };
}
return { marketPrice: 0 };
case 'BALN.SW':
if (isSameDay(parseDate('2021-11-12'), date)) {
return { marketPrice: 146 };

View File

@ -7,7 +7,7 @@ import {
import { Account, Tag } from '@prisma/client';
export interface PortfolioPositionDetail {
export interface PortfolioHoldingDetail {
accounts: Account[];
averagePrice: number;
dataProviderInfo: DataProviderInfo;

View File

@ -1,5 +0,0 @@
import { Position } from '@ghostfolio/common/interfaces';
export interface PortfolioPositions {
positions: Position[];
}

View File

@ -7,9 +7,9 @@ import {
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.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 { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -27,6 +27,10 @@ import {
PortfolioPublicDetails,
PortfolioReport
} from '@ghostfolio/common/interfaces';
import {
hasReadRestrictedAccessPermission,
isRestrictedView
} from '@ghostfolio/common/permissions';
import type {
DateRange,
GroupBy,
@ -51,8 +55,7 @@ import { AssetClass, AssetSubClass } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { PortfolioService } from './portfolio.service';
@Controller('portfolio')
@ -85,11 +88,6 @@ export class PortfolioController {
let hasDetails = true;
let hasError = false;
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = this.request.user.subscription.type === 'Premium';
@ -118,8 +116,11 @@ export class PortfolioController {
let portfolioSummary = summary;
if (
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
}) ||
isRestrictedView(this.request.user)
) {
const totalInvestment = Object.values(holdings)
.map(({ investment }) => {
@ -159,27 +160,30 @@ export class PortfolioController {
if (
hasDetails === false ||
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
}) ||
isRestrictedView(this.request.user)
) {
portfolioSummary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentGrossPerformance',
'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth',
'currentValue',
'currentValueInBaseCurrency',
'dividendInBaseCurrency',
'emergencyFund',
'excludedAccountsAndActivities',
'fees',
'filteredValueInBaseCurrency',
'fireWealth',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'interest',
'items',
'liabilities',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalBuy',
'totalInvestment',
'totalSell',
@ -227,12 +231,6 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioDividends> {
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -260,8 +258,11 @@ export class PortfolioController {
});
if (
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
}) ||
isRestrictedView(this.request.user)
) {
const maxDividend = dividends.reduce(
(investment, item) => Math.max(investment, item.investment),
@ -327,12 +328,6 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> {
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -348,8 +343,11 @@ export class PortfolioController {
});
if (
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
}) ||
isRestrictedView(this.request.user)
) {
const maxInvestment = investments.reduce(
(investment, item) => Math.max(investment, item.investment),
@ -398,12 +396,6 @@ export class PortfolioController {
): Promise<PortfolioPerformanceResponse> {
const withExcludedAccounts = withExcludedAccountsParam === 'true';
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -419,9 +411,12 @@ export class PortfolioController {
});
if (
hasReadRestrictedAccessPermission ||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
this.userService.isRestrictedView(this.request.user)
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
}) ||
isRestrictedView(this.request.user) ||
this.request.user.Settings.settings.viewMode === 'ZEN'
) {
performanceInformation.chart = performanceInformation.chart.map(
({
@ -449,10 +444,14 @@ export class PortfolioController {
.div(performanceInformation.performance.totalInvestment)
.toNumber(),
valueInPercentage:
performanceInformation.performance.currentValue === 0
performanceInformation.performance.currentValueInBaseCurrency ===
0
? 0
: new Big(value)
.div(performanceInformation.performance.currentValue)
.div(
performanceInformation.performance
.currentValueInBaseCurrency
)
.toNumber()
};
}
@ -461,12 +460,12 @@ export class PortfolioController {
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,
[
'currentGrossPerformance',
'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth',
'currentValue',
'currentValueInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalInvestment'
]
);
@ -483,39 +482,13 @@ export class PortfolioController {
);
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,
['currentNetPerformance', 'currentNetPerformancePercent']
['netPerformance']
);
}
return performanceInformation;
}
@Get('positions')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioPositions> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterBySearchQuery,
filterByTags
});
return this.portfolioService.getPositions({
dateRange,
filters,
impersonationId
});
}
@Get('public/:accessId')
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublic(
@ -592,7 +565,7 @@ export class PortfolioController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource,
@Param('symbol') symbol
): Promise<PortfolioPositionDetail> {
): Promise<PortfolioHoldingDetail> {
const position = await this.portfolioService.getPosition(
dataSource,
impersonationId,

View File

@ -4,6 +4,9 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -36,8 +39,11 @@ import { RulesService } from './rules.service';
MarketDataModule,
OrderModule,
PrismaModule,
RedactValuesInResponseModule,
RedisCacheModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule,
UserModule
],
providers: [

View File

@ -27,7 +27,7 @@ describe('PortfolioService', () => {
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercent: new Big(0)
netPerformancePercentage: new Big(0)
})
.toNumber()
).toEqual(0);
@ -36,7 +36,7 @@ describe('PortfolioService', () => {
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 0,
netPerformancePercent: new Big(0)
netPerformancePercentage: new Big(0)
})
.toNumber()
).toEqual(0);
@ -48,7 +48,7 @@ describe('PortfolioService', () => {
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year
netPerformancePercent: new Big(0.1025)
netPerformancePercentage: new Big(0.1025)
})
.toNumber()
).toBeCloseTo(0.729705);
@ -57,7 +57,7 @@ describe('PortfolioService', () => {
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year
netPerformancePercent: new Big(0.05)
netPerformancePercentage: new Big(0.05)
})
.toNumber()
).toBeCloseTo(0.05);
@ -69,7 +69,7 @@ describe('PortfolioService', () => {
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year
netPerformancePercent: new Big(0.2374)
netPerformancePercentage: new Big(0.2374)
})
.toNumber()
).toBeCloseTo(0.145);

View File

@ -77,7 +77,7 @@ import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from './calculator/portfolio-calculator.factory';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { RulesService } from './rules.service';
const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json');
@ -208,16 +208,16 @@ export class PortfolioService {
public getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent
netPerformancePercentage
}: {
daysInMarket: number;
netPerformancePercent: Big;
netPerformancePercentage: Big;
}): Big {
if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber();
return new Big(
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
).minus(1);
}
@ -277,9 +277,11 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
dateRange,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
});
@ -358,8 +360,9 @@ export class PortfolioService {
userId,
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency,
hasFilters: true, // disable cache
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
this.request.user?.Settings.settings.isExperimentalFeatures
});
const { currentValueInBaseCurrency, hasErrors, positions } =
@ -599,7 +602,7 @@ export class PortfolioService {
aDataSource: DataSource,
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
): Promise<PortfolioHoldingDetail> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
@ -660,6 +663,7 @@ export class PortfolioService {
}),
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency,
hasFilters: true,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
});
@ -689,7 +693,7 @@ export class PortfolioService {
transactionCount
} = position;
const accounts: PortfolioPositionDetail['accounts'] = uniqBy(
const accounts: PortfolioHoldingDetail['accounts'] = uniqBy(
orders.filter(({ Account }) => {
return Account;
}),
@ -700,17 +704,21 @@ export class PortfolioService {
const dividendYieldPercent = this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercent: dividendInBaseCurrency.div(
timeWeightedInvestment
)
netPerformancePercentage: timeWeightedInvestment.eq(0)
? new Big(0)
: dividendInBaseCurrency.div(timeWeightedInvestment)
});
const dividendYieldPercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercent: dividendInBaseCurrency.div(
timeWeightedInvestmentWithCurrencyEffect
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
0
)
? new Big(0)
: dividendInBaseCurrency.div(
timeWeightedInvestmentWithCurrencyEffect
)
});
const historicalData = await this.dataProviderService.getHistorical(
@ -931,6 +939,7 @@ export class PortfolioService {
userId,
calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
});
@ -1085,7 +1094,7 @@ export class PortfolioService {
)
);
const { endDate, startDate } = getInterval(dateRange);
const { endDate } = getInterval(dateRange);
const { activities } = await this.orderService.getOrders({
endDate,
@ -1101,16 +1110,16 @@ export class PortfolioService {
firstOrderDate: undefined,
hasErrors: false,
performance: {
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentGrossPerformancePercentWithCurrencyEffect: 0,
currentGrossPerformanceWithCurrencyEffect: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentNetPerformancePercentWithCurrencyEffect: 0,
currentNetPerformanceWithCurrencyEffect: 0,
currentNetWorth: 0,
currentValue: 0,
currentValueInBaseCurrency: 0,
grossPerformance: 0,
grossPerformancePercentage: 0,
grossPerformancePercentageWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
netPerformance: 0,
netPerformancePercentage: 0,
netPerformancePercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
totalInvestment: 0
}
};
@ -1123,6 +1132,7 @@ export class PortfolioService {
userId,
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
});
@ -1144,9 +1154,9 @@ export class PortfolioService {
let currentNetPerformance = netPerformance;
let currentNetPerformancePercent = netPerformancePercentage;
let currentNetPerformancePercentage = netPerformancePercentage;
let currentNetPerformancePercentWithCurrencyEffect =
let currentNetPerformancePercentageWithCurrencyEffect =
netPerformancePercentageWithCurrencyEffect;
let currentNetPerformanceWithCurrencyEffect =
@ -1165,11 +1175,11 @@ export class PortfolioService {
if (itemOfToday) {
currentNetPerformance = new Big(itemOfToday.netPerformance);
currentNetPerformancePercent = new Big(
currentNetPerformancePercentage = new Big(
itemOfToday.netPerformanceInPercentage
).div(100);
currentNetPerformancePercentWithCurrencyEffect = new Big(
currentNetPerformancePercentageWithCurrencyEffect = new Big(
itemOfToday.netPerformanceInPercentageWithCurrencyEffect
).div(100);
@ -1187,19 +1197,19 @@ export class PortfolioService {
firstOrderDate: parseDate(items[0]?.date),
performance: {
currentNetWorth,
currentGrossPerformance: grossPerformance.toNumber(),
currentGrossPerformancePercent: grossPerformancePercentage.toNumber(),
currentGrossPerformancePercentWithCurrencyEffect:
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
grossPerformance: grossPerformance.toNumber(),
grossPerformancePercentage: grossPerformancePercentage.toNumber(),
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect.toNumber(),
currentGrossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(),
currentNetPerformance: currentNetPerformance.toNumber(),
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(),
currentNetPerformancePercentWithCurrencyEffect:
currentNetPerformancePercentWithCurrencyEffect.toNumber(),
currentNetPerformanceWithCurrencyEffect:
netPerformance: currentNetPerformance.toNumber(),
netPerformancePercentage: currentNetPerformancePercentage.toNumber(),
netPerformancePercentageWithCurrencyEffect:
currentNetPerformancePercentageWithCurrencyEffect.toNumber(),
netPerformanceWithCurrencyEffect:
currentNetPerformanceWithCurrencyEffect.toNumber(),
currentValue: currentValueInBaseCurrency.toNumber(),
totalInvestment: totalInvestment.toNumber()
}
};
@ -1220,6 +1230,7 @@ export class PortfolioService {
userId,
calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: false,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
});
@ -1595,11 +1606,6 @@ export class PortfolioService {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
const performanceInformation = await this.getPerformance({
impersonationId,
userId
});
const { activities } = await this.orderService.getOrders({
userCurrency,
userId,
@ -1617,6 +1623,19 @@ export class PortfolioService {
}
}
const {
currentValueInBaseCurrency,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
totalInvestment
} = await portfolioCalculator.getSnapshot();
const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency();
@ -1685,7 +1704,7 @@ export class PortfolioService {
.toNumber();
const netWorth = new Big(balanceInBaseCurrency)
.plus(performanceInformation.performance.currentValue)
.plus(currentValueInBaseCurrency)
.plus(valuables)
.plus(excludedAccountsAndActivities)
.minus(liabilities)
@ -1695,21 +1714,18 @@ export class PortfolioService {
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent: new Big(
performanceInformation.performance.currentNetPerformancePercent
)
netPerformancePercentage: new Big(netPerformancePercentage)
})?.toNumber();
const annualizedPerformancePercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent: new Big(
performanceInformation.performance.currentNetPerformancePercentWithCurrencyEffect
netPerformancePercentage: new Big(
netPerformancePercentageWithCurrencyEffect
)
})?.toNumber();
return {
...performanceInformation.performance,
annualizedPerformancePercent,
annualizedPerformancePercentWithCurrencyEffect,
cash,
@ -1718,6 +1734,7 @@ export class PortfolioService {
totalBuy,
totalSell,
committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: {
assets: emergencyFundPositionsValueInBaseCurrency,
@ -1731,15 +1748,28 @@ export class PortfolioService {
filteredValueInPercentage: netWorth
? filteredValueInBaseCurrency.div(netWorth).toNumber()
: undefined,
fireWealth: new Big(performanceInformation.performance.currentValue)
fireWealth: new Big(currentValueInBaseCurrency)
.minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(),
grossPerformance: grossPerformance.toNumber(),
grossPerformancePercentage: grossPerformancePercentage.toNumber(),
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect.toNumber(),
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(),
interest: interest.toNumber(),
items: valuables.toNumber(),
liabilities: liabilities.toNumber(),
netPerformance: netPerformance.toNumber(),
netPerformancePercentage: netPerformancePercentage.toNumber(),
netPerformancePercentageWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect.toNumber(),
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect.toNumber(),
ordersCount: activities.filter(({ type }) => {
return type === 'BUY' || type === 'SELL';
return ['BUY', 'SELL'].includes(type);
}).length,
totalInvestment: totalInvestment.toNumber(),
totalValueInBaseCurrency: netWorth
};
}

View File

@ -24,7 +24,7 @@ export class RedisCacheService {
return this.cache.get(key);
}
public getPortfolioSnapshotKey(userId: string) {
public getPortfolioSnapshotKey({ userId }: { userId: string }) {
return `portfolio-snapshot-${userId}`;
}

View File

@ -1,25 +1,8 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { SitemapController } from './sitemap.controller';
@Module({
controllers: [SitemapController],
imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule
]
controllers: [SitemapController]
})
export class SitemapModule {}

View File

@ -1,6 +1,6 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';

View File

@ -1,4 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -12,10 +13,11 @@ import { SymbolService } from './symbol.service';
controllers: [SymbolController],
exports: [SymbolService],
imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PrismaModule
PrismaModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [SymbolService]
})

View File

@ -2,11 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { User, UserSettings } from '@ghostfolio/common/interfaces';
import {
hasPermission,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -63,13 +59,6 @@ export class UserController {
public async getUser(
@Headers('accept-language') acceptLanguage: string
): Promise<User> {
if (hasRole(this.request.user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
return this.userService.getUser(
this.request.user,
acceptLanguage?.split(',')?.[0]

View File

@ -121,28 +121,6 @@ export class UserService {
return usersWithAdminRole.length > 0;
}
public hasReadRestrictedAccessPermission({
impersonationId,
user
}: {
impersonationId: string;
user: UserWithSettings;
}) {
if (!impersonationId) {
return false;
}
const access = user.Access?.find(({ id }) => {
return id === impersonationId;
});
return access?.permissions?.includes('READ_RESTRICTED') ?? true;
}
public isRestrictedView(aUser: UserWithSettings) {
return aUser.Settings.settings.isRestrictedView ?? false;
}
public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> {

File diff suppressed because it is too large Load Diff

View File

@ -12,12 +12,14 @@ export class PortfolioChangedListener {
@OnEvent(PortfolioChangedEvent.getName())
handlePortfolioChangedEvent(event: PortfolioChangedEvent) {
Logger.log(
`Portfolio of user with id ${event.getUserId()} has changed`,
`Portfolio of user '${event.getUserId()}' has changed`,
'PortfolioChangedListener'
);
this.redisCacheService.remove(
this.redisCacheService.getPortfolioSnapshotKey(event.getUserId())
this.redisCacheService.getPortfolioSnapshotKey({
userId: event.getUserId()
})
);
}
}

View File

@ -1,6 +1,9 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
hasReadRestrictedAccessPermission,
isRestrictedView
} from '@ghostfolio/common/permissions';
import { UserWithSettings } from '@ghostfolio/common/types';
import {
@ -16,7 +19,7 @@ import { map } from 'rxjs/operators';
export class RedactValuesInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor(private userService: UserService) {}
public constructor() {}
public intercept(
context: ExecutionContext,
@ -29,15 +32,13 @@ export class RedactValuesInResponseInterceptor<T>
const impersonationId =
headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
const hasReadRestrictedPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user
});
if (
hasReadRestrictedPermission ||
this.userService.isRestrictedView(user)
hasReadRestrictedAccessPermission({
impersonationId,
user
}) ||
isRestrictedView(user)
) {
data = redactAttributes({
object: data,

View File

@ -0,0 +1,4 @@
import { Module } from '@nestjs/common';
@Module({})
export class RedactValuesInResponseModule {}

View File

@ -0,0 +1,11 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Module } from '@nestjs/common';
@Module({
exports: [ConfigurationService],
imports: [ConfigurationModule],
providers: [ConfigurationService]
})
export class TransformDataSourceInRequestModule {}

View File

@ -0,0 +1,11 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Module } from '@nestjs/common';
@Module({
exports: [ConfigurationService],
imports: [ConfigurationModule],
providers: [ConfigurationService]
})
export class TransformDataSourceInResponseModule {}

View File

@ -2,7 +2,7 @@ import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import * as bodyParser from 'body-parser';
import { json } from 'body-parser';
import helmet from 'helmet';
import { AppModule } from './app/app.module';
@ -34,7 +34,7 @@ async function bootstrap() {
);
// Support 10mb csv/json files for importing activities
app.use(bodyParser.json({ limit: '10mb' }));
app.use(json({ limit: '10mb' }));
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
app.use(

View File

@ -50,7 +50,9 @@ export class AlphaVantageService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: false
isPremium: false,
name: 'Alpha Vantage',
url: 'https://www.alphavantage.co'
};
}

View File

@ -598,10 +598,14 @@ export class DataProviderService {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
})
.map((lookupItem) => {
if (
!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') ||
user.subscription.type === 'Premium'
) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (user.subscription.type === 'Premium') {
lookupItem.dataProviderInfo.isPremium = false;
}
lookupItem.dataProviderInfo.name = undefined;
lookupItem.dataProviderInfo.url = undefined;
} else {
lookupItem.dataProviderInfo.isPremium = false;
}

View File

@ -66,7 +66,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: true
isPremium: true,
name: 'EOD Historical Data',
url: 'https://eodhd.com'
};
}

View File

@ -46,7 +46,9 @@ export class GoogleSheetsService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: false
isPremium: false,
name: 'Google Sheets',
url: 'https://docs.google.com/spreadsheets'
};
}

View File

@ -43,7 +43,9 @@ export class RapidApiService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: false
isPremium: false,
name: 'Rapid API',
url: 'https://rapidapi.com'
};
}

View File

@ -43,7 +43,9 @@ export class YahooFinanceService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: false
isPremium: false,
name: 'Yahoo Finance',
url: 'https://finance.yahoo.com'
};
}

View File

@ -78,7 +78,7 @@ export class ExchangeRateDataService {
);
const lastDateString = dateStrings.reduce((a, b) => {
return a > b ? a : b;
});
}, undefined);
let previousExchangeRate =
exchangeRatesByCurrency[`${currency}${targetCurrency}`]?.[

View File

@ -61,7 +61,9 @@ export class SymbolProfileService {
})
}
})
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
.then((symbolProfiles) => {
return this.enhanceSymbolProfiles(symbolProfiles);
});
}
public async getSymbolProfilesByIds(
@ -83,7 +85,9 @@ export class SymbolProfileService {
}
}
})
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
.then((symbolProfiles) => {
return this.enhanceSymbolProfiles(symbolProfiles);
});
}
public updateSymbolProfile({
@ -119,7 +123,7 @@ export class SymbolProfileService {
});
}
private getSymbols(
private enhanceSymbolProfiles(
symbolProfiles: (SymbolProfile & {
_count: { Order: number };
Order?: {

View File

@ -1,3 +1,5 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { paths } from '@ghostfolio/client/core/paths';
import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy';
import { NgModule } from '@angular/core';
@ -5,18 +7,6 @@ import { RouterModule, Routes, TitleStrategy } from '@angular/router';
import { ModulePreloadService } from './core/module-preload.service';
export const paths = {
about: $localize`about`,
faq: $localize`faq`,
features: $localize`features`,
license: $localize`license`,
markets: $localize`markets`,
pricing: $localize`pricing`,
privacyPolicy: $localize`privacy-policy`,
register: $localize`register`,
resources: $localize`resources`
};
const routes: Routes = [
{
path: paths.about,
@ -53,9 +43,12 @@ const routes: Routes = [
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
},
{
path: 'demo',
loadChildren: () =>
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
canActivate: [AuthGuard],
loadComponent: () =>
import('./pages/demo/demo-page.component').then(
(c) => c.GfDemoPageComponent
),
path: 'demo'
},
{
path: paths.faq,
@ -63,11 +56,13 @@ const routes: Routes = [
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
},
{
canActivate: [AuthGuard],
loadComponent: () =>
import('./pages/features/features-page.component').then(
(c) => c.GfFeaturesPageComponent
),
path: paths.features,
loadChildren: () =>
import('./pages/features/features-page.module').then(
(m) => m.FeaturesPageModule
)
title: $localize`Features`
},
{
path: 'home',
@ -75,9 +70,13 @@ const routes: Routes = [
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
},
{
canActivate: [AuthGuard],
loadComponent: () =>
import('./pages/i18n/i18n-page.component').then(
(c) => c.GfI18nPageComponent
),
path: 'i18n',
loadChildren: () =>
import('./pages/i18n/i18n-page.module').then((m) => m.I18nPageModule)
title: $localize`Internationalization`
},
{
path: paths.markets,
@ -134,11 +133,12 @@ const routes: Routes = [
)
},
{
loadComponent: () =>
import('./pages/webauthn/webauthn-page.component').then(
(c) => c.GfWebauthnPageComponent
),
path: 'webauthn',
loadChildren: () =>
import('./pages/webauthn/webauthn-page.module').then(
(m) => m.WebauthnPageModule
)
title: $localize`Sign in`
},
{
path: 'zen',

View File

@ -158,11 +158,9 @@
<li>
<a href="../pt" title="Ghostfolio in Português">Português</a>
</li>
<!--
<li>
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
</li>
-->
<li>
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
</li>
<!--
<li>
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a>

View File

@ -1,3 +1,5 @@
import { GfHoldingDetailDialogComponent } from '@ghostfolio/client/components/holding-detail-dialog/holding-detail-dialog.component';
import { HoldingDetailDialogParams } from '@ghostfolio/client/components/holding-detail-dialog/interfaces/interfaces';
import { getCssVariable } from '@ghostfolio/common/helper';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -13,13 +15,21 @@ import {
OnDestroy,
OnInit
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Title } from '@angular/platform-browser';
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
import {
ActivatedRoute,
NavigationEnd,
PRIMARY_OUTLET,
Router
} from '@angular/router';
import { DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { DataService } from './services/data.service';
import { ImpersonationStorageService } from './services/impersonation-storage.service';
import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service';
@ -38,6 +48,7 @@ export class AppComponent implements OnDestroy, OnInit {
public currentRoute: string;
public currentYear = new Date().getFullYear();
public deviceType: string;
public hasImpersonationId: boolean;
public hasInfoMessage: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
@ -67,7 +78,10 @@ export class AppComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
@Inject(DOCUMENT) private document: Document,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private title: Title,
private tokenStorageService: TokenStorageService,
@ -75,6 +89,21 @@ export class AppComponent implements OnDestroy, OnInit {
) {
this.initializeTheme();
this.user = undefined;
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['dataSource'] &&
params['holdingDetailDialog'] &&
params['symbol']
) {
this.openHoldingDetailDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
}
});
}
public ngOnInit() {
@ -96,6 +125,13 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.enableFearAndGreedIndex
);
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.router.events
.pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => {
@ -197,6 +233,55 @@ export class AppComponent implements OnDestroy, OnInit {
});
}
private openHoldingDetailDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(GfHoldingDetailDialogComponent, {
autoFocus: false,
data: <HoldingDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate([], {
queryParams: {
dataSource: null,
holdingDetailDialog: null,
symbol: null
},
queryParamsHandling: 'merge',
relativeTo: this.route
});
});
});
}
private toggleTheme(isDarkTheme: boolean) {
const themeColor = getCssVariable(
isDarkTheme ? '--dark-background' : '--light-background'

View File

@ -1,3 +1,4 @@
import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/create-account-balance.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -95,19 +96,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.dialogRef.close();
}
public onAddAccountBalance({
balance,
date
}: {
balance: number;
date: Date;
}) {
public onAddAccountBalance(accountBalance: CreateAccountBalanceDto) {
this.dataService
.postAccountBalance({
balance,
date,
accountId: this.data.accountId
})
.postAccountBalance(accountBalance)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchAccount();

View File

@ -94,6 +94,7 @@
[dataSource]="dataSource"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]="
!data.hasImpersonationId && !user.settings.isRestrictedView
"

View File

@ -38,6 +38,7 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'has-fab' },
selector: 'gf-admin-market-data',
styleUrls: ['./admin-market-data.scss'],
templateUrl: './admin-market-data.html'

View File

@ -3,11 +3,11 @@ import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-dat
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AdminMarketDataDetails,
Currency,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
@ -72,7 +72,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public countries: {
[code: string]: { name: string; value: number };
};
public currencies: Currency[] = [];
public currencies: string[] = [];
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
public isBenchmark = false;
public marketDataDetails: MarketData[] = [];
@ -101,10 +101,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
const { benchmarks, currencies } = this.dataService.fetchInfo();
this.benchmarks = benchmarks;
this.currencies = currencies.map((currency) => ({
label: currency,
value: currency
}));
this.currencies = currencies;
this.initialize();
}
@ -258,7 +255,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
});
}
public onSubmit() {
public async onSubmit() {
let countries = [];
let scraperConfiguration = {};
let sectors = [];
@ -292,13 +289,22 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetClass: this.assetProfileForm.get('assetClass').value,
assetSubClass: this.assetProfileForm.get('assetSubClass').value,
comment: this.assetProfileForm.get('comment').value || null,
currency: (<Currency>(
(<unknown>this.assetProfileForm.get('currency').value)
))?.value,
currency: this.assetProfileForm.get('currency').value,
name: this.assetProfileForm.get('name').value,
url: this.assetProfileForm.get('url').value || null
};
try {
await validateObjectForForm({
classDto: UpdateAssetProfileDto,
form: this.assetProfileForm,
object: assetProfileData
});
} catch (error) {
console.error(error);
return;
}
this.adminService
.patchAssetProfile({
...assetProfileData,
@ -331,8 +337,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
' ' +
price +
' ' +
(<Currency>(<unknown>this.assetProfileForm.get('currency').value))
?.value
this.assetProfileForm.get('currency').value
);
});
}

View File

@ -115,11 +115,22 @@
>Symbol</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[value]="
assetProfile?.dataProviderInfo?.name ?? assetProfile?.dataSource
"
>Data Source</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.currency"
>Currency</gf-value
>
</div>
<div class="col-6 mb-3"></div>
<div class="col-6 mb-3">
<gf-value
i18n

View File

@ -143,9 +143,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
const platform: CreatePlatformDto = data?.platform;
.subscribe((platform: CreatePlatformDto | null) => {
if (platform) {
this.adminService
.postPlatform(platform)
@ -182,9 +180,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
const platform: UpdatePlatformDto = data?.platform;
.subscribe((platform: UpdatePlatformDto | null) => {
if (platform) {
this.adminService
.putPlatform(platform)

View File

@ -1,4 +1,14 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import {
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs';
@ -11,18 +21,54 @@ import { CreateOrUpdatePlatformDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-platform-dialog.scss'],
templateUrl: 'create-or-update-platform-dialog.html'
})
export class CreateOrUpdatePlatformDialog {
export class CreateOrUpdatePlatformDialog implements OnDestroy {
public platformForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog>
) {}
public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog>,
private formBuilder: FormBuilder
) {
this.platformForm = this.formBuilder.group({
name: [this.data.platform.name, Validators.required],
url: [this.data.platform.url, Validators.required]
});
}
public onCancel() {
this.dialogRef.close();
}
public async onSubmit() {
try {
const platform: CreatePlatformDto | UpdatePlatformDto = {
name: this.platformForm.get('name')?.value,
url: this.platformForm.get('url')?.value
};
if (this.data.platform.id) {
(platform as UpdatePlatformDto).id = this.data.platform.id;
await validateObjectForForm({
classDto: UpdatePlatformDto,
form: this.platformForm,
object: platform
});
} else {
await validateObjectForForm({
classDto: CreatePlatformDto,
form: this.platformForm,
object: platform
});
}
this.dialogRef.close(platform);
} catch (error) {
console.error(error);
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

View File

@ -1,17 +1,30 @@
<form #addPlatformForm="ngForm" class="d-flex flex-column h-100">
<form
class="d-flex flex-column h-100"
[formGroup]="platformForm"
(keyup.enter)="platformForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 *ngIf="data.platform.id" i18n mat-dialog-title>Update platform</h1>
<h1 *ngIf="!data.platform.id" i18n mat-dialog-title>Add platform</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.platform.name" />
<input
formControlName="name"
matInput
(keydown.enter)="$event.stopPropagation()"
/>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Url</mat-label>
<input matInput name="url" required [(ngModel)]="data.platform.url" />
<input
formControlName="url"
matInput
(keydown.enter)="$event.stopPropagation()"
/>
@if (data.platform.url) {
<gf-asset-profile-icon
class="mr-3"
@ -23,12 +36,12 @@
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
[disabled]="!addPlatformForm.form.valid"
[mat-dialog-close]="data"
type="submit"
[disabled]="!platformForm.valid"
>
<ng-container i18n>Save</ng-container>
</button>

View File

@ -142,9 +142,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
const tag: CreateTagDto = data?.tag;
.subscribe((tag: CreateTagDto | null) => {
if (tag) {
this.adminService
.postTag(tag)
@ -180,9 +178,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
const tag: UpdateTagDto = data?.tag;
.subscribe((tag: UpdateTagDto | null) => {
if (tag) {
this.adminService
.putTag(tag)

View File

@ -1,4 +1,14 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import {
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs';
@ -11,18 +21,52 @@ import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-tag-dialog.scss'],
templateUrl: 'create-or-update-tag-dialog.html'
})
export class CreateOrUpdateTagDialog {
export class CreateOrUpdateTagDialog implements OnDestroy {
public tagForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateTagDialog>
) {}
public dialogRef: MatDialogRef<CreateOrUpdateTagDialog>,
private formBuilder: FormBuilder
) {
this.tagForm = this.formBuilder.group({
name: [this.data.tag.name]
});
}
public onCancel() {
this.dialogRef.close();
}
public async onSubmit() {
try {
const tag: CreateTagDto | UpdateTagDto = {
name: this.tagForm.get('name')?.value
};
if (this.data.tag.id) {
(tag as UpdateTagDto).id = this.data.tag.id;
await validateObjectForForm({
classDto: UpdateTagDto,
form: this.tagForm,
object: tag
});
} else {
await validateObjectForForm({
classDto: CreateTagDto,
form: this.tagForm,
object: tag
});
}
this.dialogRef.close(tag);
} catch (error) {
console.error(error);
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

View File

@ -1,21 +1,30 @@
<form #addTagForm="ngForm" class="d-flex flex-column h-100">
<form
class="d-flex flex-column h-100"
[formGroup]="tagForm"
(keyup.enter)="tagForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 *ngIf="data.tag.id" i18n mat-dialog-title>Update tag</h1>
<h1 *ngIf="!data.tag.id" i18n mat-dialog-title>Add tag</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.tag.name" />
<input
formControlName="name"
matInput
(keydown.enter)="$event.stopPropagation()"
/>
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
[disabled]="!addTagForm.form.valid"
[mat-dialog-close]="data"
type="submit"
[disabled]="!tagForm.valid"
>
<ng-container i18n>Save</ng-container>
</button>

View File

@ -116,7 +116,12 @@
#assistantTrigger="matMenuTrigger"
class="h-100 no-min-width px-2"
mat-button
matBadge="✓"
matBadgeSize="small"
[mat-menu-trigger-for]="assistantMenu"
[matBadgeHidden]="
!hasFilters || !user?.settings?.isExperimentalFeatures
"
[matMenuTriggerRestoreFocus]="false"
(menuOpened)="onOpenAssistant()"
>

View File

@ -28,6 +28,17 @@
text-underline-offset: 0.25rem;
}
&.mat-badge {
::ng-deep {
.mat-badge-content {
--mat-badge-small-size-container-overlap-offset: -0.9rem;
--mat-badge-small-size-text-size: 0;
transform: scale(0.45);
}
}
}
ion-icon {
font-size: 1.5rem;

View File

@ -65,6 +65,7 @@ export class HeaderComponent implements OnChanges {
@ViewChild('assistant') assistantElement: GfAssistantComponent;
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
public hasFilters: boolean;
public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean;
@ -106,6 +107,8 @@ export class HeaderComponent implements OnChanges {
}
public ngOnChanges() {
this.hasFilters = this.userService.hasFilters();
this.hasPermissionForSocialLogin = hasPermission(
this.info?.globalPermissions,
permissions.enableSocialLogin

View File

@ -5,6 +5,7 @@ import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
@ -21,6 +22,7 @@ import { HeaderComponent } from './header.component';
GfLogoComponent,
GfPremiumIndicatorComponent,
LoginWithAccessTokenDialogModule,
MatBadgeModule,
MatButtonModule,
MatMenuModule,
MatToolbarModule,

View File

@ -1,4 +1,7 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-table/accounts-table.module';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
@ -8,9 +11,16 @@ import {
LineChartItem,
User
} from '@ghostfolio/common/interfaces';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits';
import { translate } from '@ghostfolio/ui/i18n';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import {
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
@ -18,24 +28,50 @@ import {
OnDestroy,
OnInit
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { Account, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialogParams } from './interfaces/interfaces';
import { HoldingDetailDialogParams } from './interfaces/interfaces';
@Component({
host: { class: 'd-flex flex-column h-100' },
selector: 'gf-position-detail-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'position-detail-dialog.html',
styleUrls: ['./position-detail-dialog.component.scss']
host: { class: 'd-flex flex-column h-100' },
imports: [
CommonModule,
GfAccountsTableModule,
GfActivitiesTableComponent,
GfDataProviderCreditsComponent,
GfDialogFooterModule,
GfDialogHeaderModule,
GfLineChartComponent,
GfPortfolioProportionChartComponent,
GfValueComponent,
MatButtonModule,
MatChipsModule,
MatDialogModule,
MatTabsModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-holding-detail-dialog',
standalone: true,
styleUrls: ['./holding-detail-dialog.component.scss'],
templateUrl: 'holding-detail-dialog.html'
})
export class PositionDetailDialog implements OnDestroy, OnInit {
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public accounts: Account[];
public activities: Activity[];
public assetClass: string;
@ -56,6 +92,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public marketPrice: number;
public maxPrice: number;
public minPrice: number;
public netPerformance: number;
public netPerformancePercent: number;
public netPerformancePercentWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffect: number;
public quantity: number;
@ -78,14 +116,14 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<PositionDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams,
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
private userService: UserService
) {}
public ngOnInit() {
this.dataService
.fetchPositionDetail({
.fetchHoldingDetail({
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
@ -104,6 +142,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
marketPrice,
maxPrice,
minPrice,
netPerformance,
netPerformancePercent,
netPerformancePercentWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
orders,
@ -126,15 +166,15 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate;
this.historicalDataItems = historicalData.map(
(historicalDataItem) => {
({ averagePrice, date, marketPrice }) => {
this.benchmarkDataItems.push({
date: historicalDataItem.date,
value: historicalDataItem.averagePrice
date,
value: averagePrice
});
return {
date: historicalDataItem.date,
value: historicalDataItem.marketPrice
date,
value: marketPrice
};
}
);
@ -142,6 +182,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.marketPrice = marketPrice;
this.maxPrice = maxPrice;
this.minPrice = minPrice;
this.netPerformance = netPerformance;
this.netPerformancePercent = netPerformancePercent;
this.netPerformancePercentWithCurrencyEffect =
netPerformancePercentWithCurrencyEffect;
this.netPerformanceWithCurrencyEffect =

View File

@ -37,27 +37,58 @@
<div class="row">
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isCurrency]="true"
[locale]="data.locale"
[unit]="data.baseCurrency"
[value]="netPerformanceWithCurrencyEffect"
>Change</gf-value
>
@if (
SymbolProfile?.currency &&
data.baseCurrency !== SymbolProfile?.currency
) {
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isCurrency]="true"
[locale]="data.locale"
[unit]="data.baseCurrency"
[value]="netPerformanceWithCurrencyEffect"
>Change with currency effect</gf-value
>
} @else {
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isCurrency]="true"
[locale]="data.locale"
[unit]="data.baseCurrency"
[value]="netPerformance"
>Change</gf-value
>
}
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isPercent]="true"
[locale]="data.locale"
[value]="netPerformancePercentWithCurrencyEffect"
>Performance</gf-value
>
@if (
SymbolProfile?.currency &&
data.baseCurrency !== SymbolProfile?.currency
) {
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isPercent]="true"
[locale]="data.locale"
[value]="netPerformancePercentWithCurrencyEffect"
>Performance with currency effect</gf-value
>
} @else {
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isPercent]="true"
[locale]="data.locale"
[value]="netPerformancePercent"
>Performance</gf-value
>
}
</div>
<div class="col-6 mb-3">
<gf-value
@ -304,6 +335,7 @@
[dataSource]="dataSource"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]="
!data.hasImpersonationId && !user?.settings?.isRestrictedView
"

View File

@ -2,7 +2,7 @@ import { ColorScheme } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
export interface PositionDetailDialogParams {
export interface HoldingDetailDialogParams {
baseCurrency: string;
colorScheme: ColorScheme;
dataSource: DataSource;

View File

@ -1,33 +1,30 @@
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Position, User } from '@ghostfolio/common/interfaces';
import { PortfolioPosition, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { HoldingType, ToggleOption } from '@ghostfolio/common/types';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialogParams } from '../position/position-detail-dialog/interfaces/interfaces';
@Component({
selector: 'gf-home-holdings',
styleUrls: ['./home-holdings.scss'],
templateUrl: './home-holdings.html'
})
export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public positions: Position[];
public holdings: PortfolioPosition[];
public holdingType: HoldingType = 'ACTIVE';
public holdingTypeOptions: ToggleOption[] = [
{ label: $localize`Active`, value: 'ACTIVE' },
{ label: $localize`Closed`, value: 'CLOSED' }
];
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -36,25 +33,18 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
this.route.queryParams
) {}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['dataSource'] &&
params['positionDetailDialog'] &&
params['symbol']
) {
this.openPositionDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
}
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.userService.stateChanged
@ -68,37 +58,32 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
permissions.createOrder
);
this.update();
this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
public onChangeHoldingType(aHoldingType: HoldingType) {
this.holdingType = aHoldingType;
this.impersonationStorageService
.onChangeHasImpersonation()
this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
}
.subscribe(({ holdings }) => {
this.holdings = holdings;
public onChangeDateRange(dateRange: DateRange) {
this.dataService
.putUserSetting({ dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
});
}
@ -107,59 +92,16 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private openPositionDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
private fetchHoldings() {
const filters = this.userService.getFilters();
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: <PositionDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
if (this.holdingType === 'CLOSED') {
filters.push({ id: 'CLOSED', type: 'HOLDING_TYPE' });
}
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
private update() {
this.positions = undefined;
this.dataService
.fetchPositions({ range: this.user?.settings?.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ positions }) => {
this.positions = positions;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
return this.dataService.fetchPortfolioHoldings({
filters,
range: this.user?.settings?.dateRange
});
}
}

View File

@ -1,27 +1,38 @@
<div class="container justify-content-center p-3">
<div class="container">
<div class="row">
<div class="align-items-center col-xs-12 col-md-8 offset-md-2">
<mat-card appearance="outlined">
<mat-card-content class="p-0">
<gf-positions
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale"
[positions]="positions"
[range]="user?.settings?.dateRange"
/>
</mat-card-content>
</mat-card>
<div *ngIf="hasPermissionToCreateOrder" class="text-center">
<a
class="mt-3"
i18n
mat-stroked-button
[routerLink]="['/portfolio', 'activities']"
>Manage Activities</a
>
<div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Holdings</h1>
</div>
</div>
<div class="row">
<div class="col-lg">
<div class="d-flex justify-content-end">
<gf-toggle
class="d-none d-lg-block"
[defaultValue]="holdingType"
[isLoading]="false"
[options]="holdingTypeOptions"
(change)="onChangeHoldingType($event.value)"
/>
</div>
<gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
[holdings]="holdings"
[locale]="user?.settings?.locale"
/>
@if (hasPermissionToCreateOrder && holdings?.length > 0) {
<div class="text-center">
<a
class="mt-3"
i18n
mat-stroked-button
[routerLink]="['/portfolio', 'activities']"
>Manage Activities</a
>
</div>
}
</div>
</div>
</div>

View File

@ -1,11 +1,9 @@
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { HomeHoldingsComponent } from './home-holdings.component';
@ -14,11 +12,9 @@ import { HomeHoldingsComponent } from './home-holdings.component';
declarations: [HomeHoldingsComponent],
imports: [
CommonModule,
GfPositionDetailDialogModule,
GfPositionsModule,
GfHoldingsTableComponent,
GfToggleModule,
MatButtonModule,
MatCardModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -11,7 +11,7 @@
[colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems"
[isAnimated]="true"
[locale]="user?.settings?.locale"
[locale]="user?.settings?.locale || undefined"
[showXAxis]="true"
[showYAxis]="true"
[yMax]="100"
@ -30,7 +30,7 @@
<div class="col-xs-12 col-md-8 offset-md-2">
<gf-benchmark
[benchmarks]="benchmarks"
[locale]="user?.settings?.locale"
[locale]="user?.settings?.locale || undefined"
[user]="user"
/>
<ngx-skeleton-loader

View File

@ -41,9 +41,7 @@
[isCurrency]="true"
[locale]="locale"
[value]="
isLoading
? undefined
: performance?.currentNetPerformanceWithCurrencyEffect
isLoading ? undefined : performance?.netPerformanceWithCurrencyEffect
"
/>
</div>
@ -55,7 +53,7 @@
[value]="
isLoading
? undefined
: performance?.currentNetPerformancePercentWithCurrencyEffect
: performance?.netPerformancePercentageWithCurrencyEffect
"
/>
</div>

View File

@ -49,12 +49,12 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
this.value.nativeElement.innerHTML = '';
}
} else {
if (isNumber(this.performance?.currentValue)) {
new CountUp('value', this.performance?.currentValue, {
if (isNumber(this.performance?.currentValueInBaseCurrency)) {
new CountUp('value', this.performance?.currentValueInBaseCurrency, {
decimal: getNumberFormatDecimal(this.locale),
decimalPlaces:
this.deviceType === 'mobile' &&
this.performance?.currentValue >= 100000
this.performance?.currentValueInBaseCurrency >= 100000
? 0
: 2,
duration: 1,
@ -63,8 +63,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
} else if (this.showDetails === false) {
new CountUp(
'value',
this.performance?.currentNetPerformancePercentWithCurrencyEffect *
100,
this.performance?.netPerformancePercentageWithCurrencyEffect * 100,
{
decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: 2,

View File

@ -9,9 +9,19 @@
class="flex-nowrap px-3 py-1 row"
[hidden]="summary?.ordersCount === null"
>
<div class="flex-grow-1 ml-3 text-truncate" i18n>
<div class="d-flex flex-grow-1 ml-3 text-truncate">
{{ summary?.ordersCount }}
{summary?.ordersCount, plural, =1 {transaction} other {transactions}}
<ng-container i18n>{summary?.ordersCount, plural,
=1 {activity}
other {activities}
}</ng-container>
<span
class="align-items-center d-flex ml-1"
matTooltipPosition="above"
[matTooltip]="buyAndSellActivitiesTooltip"
>
<ion-icon name="information-circle-outline" />
</span>
</div>
</div>
<div class="row">
@ -65,9 +75,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="
isLoading
? undefined
: summary?.currentGrossPerformanceWithCurrencyEffect
isLoading ? undefined : summary?.grossPerformanceWithCurrencyEffect
"
/>
</div>
@ -91,7 +99,7 @@
[value]="
isLoading
? undefined
: summary?.currentGrossPerformancePercentWithCurrencyEffect
: summary?.grossPerformancePercentageWithCurrencyEffect
"
/>
</div>
@ -121,9 +129,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="
isLoading
? undefined
: summary?.currentNetPerformanceWithCurrencyEffect
isLoading ? undefined : summary?.netPerformanceWithCurrencyEffect
"
/>
</div>
@ -147,7 +153,7 @@
[value]="
isLoading
? undefined
: summary?.currentNetPerformancePercentWithCurrencyEffect
: summary?.netPerformancePercentageWithCurrencyEffect
"
/>
</div>
@ -164,7 +170,7 @@
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentValue"
[value]="isLoading ? undefined : summary?.currentValueInBaseCurrency"
/>
</div>
</div>

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