From 5b6536ec8545f2c26d4dc661343941940d5402fe Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:22:43 +0200 Subject: [PATCH] Bugfix/validation of custom asset profile symbols (#7721) * Improve validation of activities and asset profiles when combining custom asset profile symbol with data source other than MANUAL * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/import/import.service.ts | 64 +++++++++++++++---- .../data-provider/data-provider.service.ts | 12 +++- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0992c5589..24e90226a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Harmonized the icons and labels in the access table to share the portfolio - Improved the data source column in the historical market data table of the admin control panel by showing the name of the data provider - Migrated the create and edit access dialogs to dedicated routes +- Improved the validation of activities and asset profiles when combining a custom asset profile symbol with a data source other than `MANUAL` - Improved the response of the historical market data gathering endpoint for a specific date - Introduced a timeout for the asset profile and the historical market data gathering jobs - Reduced the number of attempts of the asset profile and the historical market data gathering jobs diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 47935c9a7..313325f04 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -3,7 +3,9 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.ser import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { getTagsWithDraftTag } from '@ghostfolio/api/helper/activity.helper'; +import { getMaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; @@ -56,6 +58,7 @@ export class ImportService { private readonly accountService: AccountService, private readonly activitiesService: ActivitiesService, private readonly apiService: ApiService, + private readonly configurationService: ConfigurationService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, @@ -204,6 +207,9 @@ export class ImportService { }): Promise { const accountIdMapping: { [oldAccountId: string]: string } = {}; const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {}; + const ghostfolioDataSources = this.configurationService.get( + 'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER' + ); const platformIdMapping: { [oldPlatformId: string]: string } = {}; const tagIdMapping: { [oldTagId: string]: string } = {}; const userCurrency = user.settings.settings.baseCurrency; @@ -212,34 +218,64 @@ export class ImportService { for (const [index, assetProfileWithMarketData] of ( assetProfilesWithMarketDataDto ?? [] ).entries()) { + const { dataSource, symbol } = assetProfileWithMarketData; + if ( - assetProfileWithMarketData.dataSource === DataSource.MANUAL && - !isValidCustomAssetProfileSymbol(assetProfileWithMarketData.symbol) + dataSource === DataSource.MANUAL && + !isValidCustomAssetProfileSymbol(symbol) + ) { + throw new Error( + `assetProfiles.${index}.symbol ("${symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` + ); + } else if ( + dataSource !== DataSource.MANUAL && + isValidCustomAssetProfileSymbol(symbol) ) { + const maskedDataSource = getMaskedGhostfolioDataSource({ + dataSource, + ghostfolioDataSources + }); + throw new Error( - `assetProfiles.${index}.symbol ("${assetProfileWithMarketData.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` + `assetProfiles.${index}.symbol ("${symbol}") is not valid for the data source ("${maskedDataSource}")` ); } } - // Validate the symbols before any data is persisted. Activities without a - // data source are excluded, since a symbol is generated in - // createActivity() if needed. + // Validate the symbols before any data is persisted. Activities without an + // explicit data source are excluded from the first check, since a symbol is + // generated in createActivity() if needed. for (const [index, activity] of activitiesDto.entries()) { - if (!activity.dataSource) { - if (NON_INVESTMENT_ACTIVITY_TYPES.includes(activity.type)) { - activity.dataSource = DataSource.MANUAL; - } else { - activity.dataSource = - this.dataProviderService.getDataSourceForImport(); - } - } else if ( + const hasDataSource = Boolean(activity.dataSource); + + if (!hasDataSource) { + activity.dataSource = NON_INVESTMENT_ACTIVITY_TYPES.includes( + activity.type + ) + ? DataSource.MANUAL + : this.dataProviderService.getDataSourceForImport(); + } + + if ( + hasDataSource && activity.dataSource === DataSource.MANUAL && !isValidCustomAssetProfileSymbol(activity.symbol) ) { throw new Error( `activities.${index}.symbol ("${activity.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` ); + } else if ( + activity.dataSource !== DataSource.MANUAL && + isValidCustomAssetProfileSymbol(activity.symbol) + ) { + const maskedDataSource = getMaskedGhostfolioDataSource({ + ghostfolioDataSources, + dataSource: activity.dataSource + }); + + throw new Error( + `activities.${index}.symbol ("${activity.symbol}") is not valid for the data source ("${maskedDataSource}")` + ); } } diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index e9449be9e..00d6b74bc 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -23,6 +23,7 @@ import { getStartOfUtcDate, isCurrency, isDerivedCurrency, + isValidCustomAssetProfileSymbol, isValidSearchQuery } from '@ghostfolio/common/helper'; import { @@ -254,6 +255,15 @@ export class DataProviderService implements OnModuleInit { ); } + if ( + dataSource !== DataSource.MANUAL && + isValidCustomAssetProfileSymbol(symbol) + ) { + throw new Error( + `${activityPath}.symbol ("${symbol}") is not valid for the data source ("${maskedDataSource}")` + ); + } + if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && subscription?.type === SubscriptionType.Basic @@ -319,7 +329,7 @@ export class DataProviderService implements OnModuleInit { if (!assetProfile?.name) { throw new Error( - `${activityPath}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")` + `${activityPath}.symbol ("${symbol}") cannot be resolved by the data source ("${maskedDataSource}")` ); }