From b371318c7851f8c5b33316ba0ad60c1846e86a76 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 30 May 2026 09:36:09 +0200 Subject: [PATCH 1/5] Bugfix/add missing guards to getMarketDataBySymbol() in market data controller (#6958) Add missing guards --- .../src/app/endpoints/market-data/market-data.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/endpoints/market-data/market-data.controller.ts b/apps/api/src/app/endpoints/market-data/market-data.controller.ts index 0dae82d2c..f6857283b 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.controller.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.controller.ts @@ -120,10 +120,10 @@ export class MarketDataController { if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) { throw new HttpException( - assetProfile.userId + assetProfile?.userId ? getReasonPhrase(StatusCodes.NOT_FOUND) : getReasonPhrase(StatusCodes.FORBIDDEN), - assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN + assetProfile?.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN ); } From fea5ee33bb5178d48ca89c90253e6e8ea48c3870 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 30 May 2026 14:36:27 +0700 Subject: [PATCH 2/5] Task/improve type safety in HTTP interceptor (#6957) * feat(client): resolve errors * feat(client): replace constructor based DI with inject function * feat(client): enforce encapsulation and immutability * feat(client): remove unused tap operator * feat(client): implement generic type parameter * feat(client): replace constructor based DI with inject functions --- apps/client/src/app/core/auth.guard.ts | 12 +++-- apps/client/src/app/core/auth.interceptor.ts | 16 +++---- .../src/app/core/http-response.interceptor.ts | 45 +++++++++---------- 3 files changed, 34 insertions(+), 39 deletions(-) diff --git a/apps/client/src/app/core/auth.guard.ts b/apps/client/src/app/core/auth.guard.ts index 3292f0ff7..6ac3417db 100644 --- a/apps/client/src/app/core/auth.guard.ts +++ b/apps/client/src/app/core/auth.guard.ts @@ -3,7 +3,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes'; import { DataService } from '@ghostfolio/ui/services'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Router, @@ -14,12 +14,10 @@ import { catchError } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class AuthGuard { - public constructor( - private dataService: DataService, - private router: Router, - private settingsStorageService: SettingsStorageService, - private userService: UserService - ) {} + private readonly dataService = inject(DataService); + private readonly router = inject(Router); + private readonly settingsStorageService = inject(SettingsStorageService); + private readonly userService = inject(UserService); canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { const utmSource = route.queryParams?.utm_source; diff --git a/apps/client/src/app/core/auth.interceptor.ts b/apps/client/src/app/core/auth.interceptor.ts index 7491cecf1..9c06a11d5 100644 --- a/apps/client/src/app/core/auth.interceptor.ts +++ b/apps/client/src/app/core/auth.interceptor.ts @@ -13,20 +13,20 @@ import { HttpInterceptor, HttpRequest } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; @Injectable() export class AuthInterceptor implements HttpInterceptor { - public constructor( - private impersonationStorageService: ImpersonationStorageService, - private tokenStorageService: TokenStorageService - ) {} + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly tokenStorageService = inject(TokenStorageService); - public intercept( - req: HttpRequest, + public intercept( + req: HttpRequest, next: HttpHandler - ): Observable> { + ): Observable> { let request = req; if (request.headers.has(HEADER_KEY_SKIP_INTERCEPTOR)) { diff --git a/apps/client/src/app/core/http-response.interceptor.ts b/apps/client/src/app/core/http-response.interceptor.ts index 315e9d64e..17927a924 100644 --- a/apps/client/src/app/core/http-response.interceptor.ts +++ b/apps/client/src/app/core/http-response.interceptor.ts @@ -12,7 +12,7 @@ import { HttpInterceptor, HttpRequest } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { MatSnackBar, MatSnackBarRef, @@ -22,31 +22,28 @@ import { Router } from '@angular/router'; import { StatusCodes } from 'http-status-codes'; import ms from 'ms'; import { Observable, throwError } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { catchError } from 'rxjs/operators'; @Injectable() export class HttpResponseInterceptor implements HttpInterceptor { - public info: InfoItem; - public snackBarRef: MatSnackBarRef; + private readonly info: InfoItem; + private snackBarRef: MatSnackBarRef | undefined; - public constructor( - private dataService: DataService, - private router: Router, - private snackBar: MatSnackBar, - private userService: UserService, - private webAuthnService: WebAuthnService - ) { + private readonly dataService = inject(DataService); + private readonly router = inject(Router); + private readonly snackBar = inject(MatSnackBar); + private readonly userService = inject(UserService); + private readonly webAuthnService = inject(WebAuthnService); + + public constructor() { this.info = this.dataService.fetchInfo(); } - public intercept( - request: HttpRequest, + public intercept( + request: HttpRequest, next: HttpHandler - ): Observable> { + ): Observable> { return next.handle(request).pipe( - tap((event: HttpEvent) => { - return event; - }), catchError((error: HttpErrorResponse) => { if (error.status === StatusCodes.FORBIDDEN) { if (!this.snackBarRef) { @@ -61,7 +58,7 @@ export class HttpResponseInterceptor implements HttpInterceptor { } ); } else if ( - !error.url.includes(internalRoutes.auth.routerLink.join('')) + !error.url?.includes(internalRoutes.auth.routerLink.join('')) ) { this.snackBarRef = this.snackBar.open( $localize`This action is not allowed.`, @@ -72,11 +69,11 @@ export class HttpResponseInterceptor implements HttpInterceptor { ); } - this.snackBarRef.afterDismissed().subscribe(() => { + this.snackBarRef?.afterDismissed().subscribe(() => { this.snackBarRef = undefined; }); - this.snackBarRef.onAction().subscribe(() => { + this.snackBarRef?.onAction().subscribe(() => { this.router.navigate(publicRoutes.pricing.routerLink); }); } @@ -92,11 +89,11 @@ export class HttpResponseInterceptor implements HttpInterceptor { } ); - this.snackBarRef.afterDismissed().subscribe(() => { + this.snackBarRef?.afterDismissed().subscribe(() => { this.snackBarRef = undefined; }); - this.snackBarRef.onAction().subscribe(() => { + this.snackBarRef?.onAction().subscribe(() => { window.location.reload(); }); } @@ -106,12 +103,12 @@ export class HttpResponseInterceptor implements HttpInterceptor { $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.` ); - this.snackBarRef.afterDismissed().subscribe(() => { + this.snackBarRef?.afterDismissed().subscribe(() => { this.snackBarRef = undefined; }); } } else if (error.status === StatusCodes.UNAUTHORIZED) { - if (!error.url.includes('/data-providers/ghostfolio/status')) { + if (!error.url?.includes('/data-providers/ghostfolio/status')) { if (this.webAuthnService.isEnabled()) { this.router.navigate(internalRoutes.webauthn.routerLink); } else { From b6b44347b029bad0a896112ee404f0bbc1858bfe Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sun, 31 May 2026 11:51:22 +0700 Subject: [PATCH 3/5] Task/improve type safety in file drop directive (#6961) * fix(client): resolve errors * feat(client): migrate to output signal * feat(client): migrate to host property --- .../file-drop/file-drop.directive.ts | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/client/src/app/directives/file-drop/file-drop.directive.ts b/apps/client/src/app/directives/file-drop/file-drop.directive.ts index a7e628bc9..b46357005 100644 --- a/apps/client/src/app/directives/file-drop/file-drop.directive.ts +++ b/apps/client/src/app/directives/file-drop/file-drop.directive.ts @@ -1,28 +1,34 @@ -import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; +import { Directive, output } from '@angular/core'; @Directive({ + host: { + '(dragenter)': 'onDragEnter($event)', + '(dragover)': 'onDragOver($event)', + '(drop)': 'onDrop($event)' + }, selector: '[gfFileDrop]' }) export class GfFileDropDirective { - @Output() filesDropped = new EventEmitter(); + public readonly filesDropped = output(); - @HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) { + public onDragEnter(event: DragEvent) { event.preventDefault(); event.stopPropagation(); } - @HostListener('dragover', ['$event']) onDragOver(event: DragEvent) { + public onDragOver(event: DragEvent) { event.preventDefault(); event.stopPropagation(); } - @HostListener('drop', ['$event']) onDrop(event: DragEvent) { + public onDrop(event: DragEvent) { event.preventDefault(); event.stopPropagation(); - // Prevent the browser's default behavior for handling the file drop - event.dataTransfer.dropEffect = 'copy'; - - this.filesDropped.emit(event.dataTransfer.files); + if (event.dataTransfer) { + // Prevent the browser's default behavior for handling the file drop + event.dataTransfer.dropEffect = 'copy'; + this.filesDropped.emit(event.dataTransfer.files); + } } } From f9ebb9150abfb49ece0f7d51758d51971c2d3d8e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 31 May 2026 06:54:51 +0200 Subject: [PATCH 4/5] Task/add portfolio service test (#6949) * Add tests to cover cash positions --- .../app/portfolio/portfolio.service.spec.ts | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 apps/api/src/app/portfolio/portfolio.service.spec.ts diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts new file mode 100644 index 000000000..da846c45d --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -0,0 +1,272 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Account, DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import { randomUUID } from 'node:crypto'; + +import { PortfolioService } from './portfolio.service'; + +describe('PortfolioService', () => { + let accountService: AccountService; + let activitiesService: ActivitiesService; + let configurationService: ConfigurationService; + let dataProviderService: DataProviderService; + let exchangeRateDataService: ExchangeRateDataService; + let impersonationService: ImpersonationService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioService: PortfolioService; + let symbolProfileService: SymbolProfileService; + let userService: UserService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + dataProviderService = new DataProviderService( + configurationService, + null, + null, + null, + null, + null + ); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + accountService = new AccountService( + null, + null, + exchangeRateDataService, + null + ); + + activitiesService = new ActivitiesService( + null, + accountService, + null, + dataProviderService, + null, + exchangeRateDataService, + null, + null + ); + + impersonationService = new ImpersonationService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + null, + exchangeRateDataService, + null, + null + ); + + symbolProfileService = new SymbolProfileService(null); + + userService = new UserService( + null, + null, + null, + null, + null, + null, + null, + null + ); + + portfolioService = new PortfolioService( + null, + accountService, + activitiesService, + null, + portfolioCalculatorFactory, + dataProviderService, + exchangeRateDataService, + null, + impersonationService, + null, + null, + symbolProfileService, + userService + ); + }); + + describe('getCashSymbolProfiles', () => { + it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => { + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + const cashDetails: CashDetails = { + accounts: [ + { + balance: 2000, + comment: null, + createdAt: parseDate('2024-01-01'), + currency: 'USD', + id: randomUUID(), + isExcluded: false, + name: 'USD', + platformId: null, + updatedAt: parseDate('2024-01-01'), + userId: userDummyData.id + } + ], + balanceInBaseCurrency: 1820 + }; + + const assetProfiles = ( + portfolioService as unknown as { + getCashSymbolProfiles: ( + aCashDetails: CashDetails + ) => { dataSource: DataSource; symbol: string }[]; + } + ).getCashSymbolProfiles(cashDetails); + + expect(assetProfiles).toHaveLength(1); + expect(assetProfiles[0].dataSource).toBe(DataSource.YAHOO); + expect(assetProfiles[0].symbol).toBe('USD'); + }); + }); + + describe('getDetails', () => { + it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => { + const accountId = randomUUID(); + + const cashAccount: Account = { + balance: 2000, + comment: null, + createdAt: parseDate('2024-01-01'), + currency: 'USD', + id: accountId, + isExcluded: false, + name: 'USD', + platformId: null, + updatedAt: parseDate('2024-01-01'), + userId: userDummyData.id + }; + + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [cashAccount], + balanceInBaseCurrency: 1820 + }); + + jest + .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') + .mockResolvedValue({ activities: [], count: 0 }); + + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + jest + .spyOn(impersonationService, 'validateImpersonationId') + .mockResolvedValue(null); + + jest + .spyOn(symbolProfileService, 'getSymbolProfiles') + .mockResolvedValue([]); + + jest.spyOn(userService, 'user').mockResolvedValue({ + accessesGet: [], + accounts: [], + activityCount: 0, + dataProviderGhostfolioDailyRequests: 0, + id: userDummyData.id, + settings: { + settings: { + baseCurrency: 'CHF' + } + } + } as unknown as Awaited>); + + const usdPosition = { + activitiesCount: 1, + averagePrice: new Big(1), + currency: 'USD', + dataSource: DataSource.YAHOO, + dateOfFirstActivity: '2024-01-01', + dividend: new Big(0), + dividendInBaseCurrency: new Big(0), + fee: new Big(0), + feeInBaseCurrency: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + investment: new Big(1820), + investmentWithCurrencyEffect: new Big(1820), + marketPrice: 1, + marketPriceInBaseCurrency: 0.91, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: {}, + netPerformanceWithCurrencyEffectMap: {}, + quantity: new Big(2000), + symbol: 'USD', + tags: [], + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentWithCurrencyEffect: new Big(0), + valueInBaseCurrency: new Big(1820) + }; + + jest + .spyOn(portfolioCalculatorFactory, 'createCalculator') + .mockReturnValue({ + getSnapshot: jest.fn().mockResolvedValue({ + activitiesCount: 1, + createdAt: parseDate('2024-01-01'), + currentValueInBaseCurrency: new Big(1820), + errors: [], + hasErrors: false, + historicalData: [], + positions: [usdPosition], + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(1820), + totalInvestmentWithCurrencyEffect: new Big(1820), + totalLiabilitiesWithCurrencyEffect: new Big(0) + }) + } as unknown as ReturnType< + typeof portfolioCalculatorFactory.createCalculator + >); + + jest + .spyOn( + portfolioService as unknown as { + getValueOfAccountsAndPlatforms: () => Promise<{ + accounts: object; + platforms: object; + }>; + }, + 'getValueOfAccountsAndPlatforms' + ) + .mockResolvedValue({ accounts: {}, platforms: {} }); + + const { holdings } = await portfolioService.getDetails({ + filters: [], + impersonationId: userDummyData.id, + userId: userDummyData.id + }); + + expect(holdings['USD']).toBeDefined(); + expect(holdings['USD'].assetProfile.dataSource).toBe(DataSource.YAHOO); + expect(holdings['USD'].assetProfile.symbol).toBe('USD'); + }); + }); +}); From aa0ec2622f3474a62d030ae512e906c8c33c6d33 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 31 May 2026 07:03:47 +0200 Subject: [PATCH 5/5] Task/extend countries mapping in Trackinsight data enhancer service (#6959) * Extend countries mapping with USA * Update changelog --- CHANGELOG.md | 6 ++++++ .../data-enhancer/trackinsight/trackinsight.service.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983e9d6ea..9842a2b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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). +## Unreleased + +### Changed + +- Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_ + ## 3.6.0 - 2026-05-28 ### Added diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 54627f312..eeccf725e 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -13,7 +13,8 @@ import { countries } from 'countries-list'; export class TrackinsightDataEnhancerService implements DataEnhancerInterface { private static baseUrl = 'https://www.trackinsight.com/data-api'; private static countriesMapping = { - 'Russian Federation': 'Russia' + 'Russian Federation': 'Russia', + USA: 'United States' }; private static holdingsWeightTreshold = 0.85; private static sectorsMapping = {