From 4d4a209e3d02f58bac3547417028cd393349a05c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:43:49 +0200 Subject: [PATCH] Improve symbol validation for assets with manual data source --- apps/api/src/app/admin/admin.service.ts | 2 +- apps/api/src/app/import/import.service.ts | 22 +++++- .../data-provider/data-provider.service.ts | 4 +- .../app/services/import-activities.service.ts | 72 ++++++++++++------- ...eate-asset-profile-with-market-data.dto.ts | 6 +- .../is-custom-asset-profile-symbol.ts | 18 ----- ...nvalid-symbol-with-manual-data-source.json | 27 +++++++ test/import/ok/without-accounts.json | 24 ++++++- 8 files changed, 121 insertions(+), 54 deletions(-) delete mode 100644 libs/common/src/lib/validator-constraints/is-custom-asset-profile-symbol.ts create mode 100644 test/import/not-ok/invalid-symbol-with-manual-data-source.json diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index b7499b62f..d384e0d55 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -67,7 +67,7 @@ export class AdminService { if (dataSource === 'MANUAL') { if (!hasGhostfolioPrefix(symbol)) { throw new BadRequestException( - `symbol "${symbol}" must start with the prefix "${ghostfolioPrefix}_" for the data source "${dataSource}"` + `symbol ("${symbol}") must start with the prefix "${ghostfolioPrefix}_" for the data source ("${dataSource}")` ); } diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 52b0662d6..c179598ed 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -11,11 +11,13 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/sy import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, + ghostfolioPrefix, TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos'; import { getAssetProfileIdentifier, + isValidCustomAssetProfileSymbol, parseDate } from '@ghostfolio/common/helper'; import { @@ -393,6 +395,15 @@ export class ImportService { ); for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { + if ( + !isValidCustomAssetProfileSymbol(assetProfileWithMarketData.symbol) + ) { + // Skip synthetic asset profiles (e.g. of the csv import), where the + // symbol is used as the name of the asset profile created in + // createActivity() + continue; + } + // Check if there is any existing asset profile const existingAssetProfile = existingAssetProfiles.find( ({ dataSource, symbol }) => { @@ -441,7 +452,7 @@ export class ImportService { } } - for (const activity of activitiesDto) { + for (const [index, activity] of activitiesDto.entries()) { if (!activity.dataSource) { if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { activity.dataSource = DataSource.MANUAL; @@ -451,6 +462,15 @@ export class ImportService { } } + if ( + 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}")` + ); + } + if (!isDryRun) { // If a new account is created, then update the accountId in all activities if (accountIdMapping[activity.accountId]) { 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 6ef7bdcbd..9f6c78357 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -296,7 +296,7 @@ export class DataProviderService implements OnModuleInit { !isValidCustomAssetProfileSymbol(symbol) ) { throw new Error( - `${activityPath}.symbol "${symbol}" must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source "${DataSource.MANUAL}"` + `${activityPath}.symbol ("${symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` ); } @@ -329,7 +329,7 @@ export class DataProviderService implements OnModuleInit { if (!assetProfile?.name) { throw new Error( - `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")` + `${activityPath}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")` ); } diff --git a/apps/client/src/app/services/import-activities.service.ts b/apps/client/src/app/services/import-activities.service.ts index 6a8f99bda..1fe067b3c 100644 --- a/apps/client/src/app/services/import-activities.service.ts +++ b/apps/client/src/app/services/import-activities.service.ts @@ -5,7 +5,10 @@ import { CreatePlatformDto, CreateTagDto } from '@ghostfolio/common/dtos'; -import { parseDate as parseDateHelper } from '@ghostfolio/common/helper'; +import { + isValidCustomAssetProfileSymbol, + parseDate as parseDateHelper +} from '@ghostfolio/common/helper'; import { Activity } from '@ghostfolio/common/interfaces'; import { HttpClient } from '@angular/common/http'; @@ -57,13 +60,54 @@ export class ImportActivitiesService { const activities: CreateOrderDto[] = []; const assetProfiles: CreateAssetProfileWithMarketDataDto[] = []; + const assetProfileSymbolMapping: { [name: string]: string } = {}; for (const [index, item] of content.entries()) { const currency = this.parseCurrency({ content, index, item }); const dataSource = this.parseDataSource({ item }); - const symbol = this.parseSymbol({ content, index, item }); const type = this.parseType({ content, index, item }); + let symbol = this.parseSymbol({ content, index, item }); + + if (dataSource === DataSource.MANUAL) { + const name = symbol; + + if (!isValidCustomAssetProfileSymbol(symbol)) { + // Generate a symbol and keep the free text as the name + assetProfileSymbolMapping[name] = + assetProfileSymbolMapping[name] ?? crypto.randomUUID(); + symbol = assetProfileSymbolMapping[name]; + } + + const isExistingAssetProfile = assetProfiles.some((assetProfile) => { + return assetProfile.symbol === symbol; + }); + + if (!isExistingAssetProfile) { + // Create synthetic asset profile for MANUAL data source + assetProfiles.push({ + currency, + name, + symbol, + assetClass: undefined, + assetSubClass: undefined, + comment: undefined, + countries: [], + cusip: undefined, + dataSource: DataSource.MANUAL, + figi: undefined, + figiComposite: undefined, + figiShareClass: undefined, + holdings: [], + isActive: true, + isin: undefined, + marketData: [], + sectors: [], + url: undefined + }); + } + } + activities.push({ currency, dataSource, @@ -77,30 +121,6 @@ export class ImportActivitiesService { unitPrice: this.parseUnitPrice({ content, index, item }), updateAccountBalance: false }); - - if (dataSource === DataSource.MANUAL) { - // Create synthetic asset profile for MANUAL data source - assetProfiles.push({ - currency, - symbol, - assetClass: undefined, - assetSubClass: undefined, - comment: undefined, - countries: [], - cusip: undefined, - dataSource: DataSource.MANUAL, - figi: undefined, - figiComposite: undefined, - figiShareClass: undefined, - holdings: [], - isActive: true, - isin: undefined, - marketData: [], - name: symbol, - sectors: [], - url: undefined - }); - } } const result = await this.importJson({ diff --git a/libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts b/libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts index e9b26a5b6..60afcb558 100644 --- a/libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts +++ b/libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts @@ -1,8 +1,7 @@ import { MarketData } from '@ghostfolio/common/interfaces'; -import { IsCustomAssetProfileSymbolConstraint } from '@ghostfolio/common/validator-constraints/is-custom-asset-profile-symbol'; import { DataSource } from '@prisma/client'; -import { IsArray, IsIn, IsOptional, Validate } from 'class-validator'; +import { IsArray, IsIn, IsOptional } from 'class-validator'; import { CreateAssetProfileDto } from './create-asset-profile.dto'; @@ -15,7 +14,4 @@ export class CreateAssetProfileWithMarketDataDto extends CreateAssetProfileDto { @IsArray() @IsOptional() marketData?: MarketData[]; - - @Validate(IsCustomAssetProfileSymbolConstraint) - override symbol: string; } diff --git a/libs/common/src/lib/validator-constraints/is-custom-asset-profile-symbol.ts b/libs/common/src/lib/validator-constraints/is-custom-asset-profile-symbol.ts deleted file mode 100644 index ea6e8e97e..000000000 --- a/libs/common/src/lib/validator-constraints/is-custom-asset-profile-symbol.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { - ValidatorConstraint, - ValidatorConstraintInterface -} from 'class-validator'; - -import { ghostfolioPrefix } from '../config'; -import { isValidCustomAssetProfileSymbol } from '../helper'; - -@ValidatorConstraint({ name: 'isCustomAssetProfileSymbol' }) -export class IsCustomAssetProfileSymbolConstraint implements ValidatorConstraintInterface { - public defaultMessage() { - return `$property must be a UUID or start with the prefix "${ghostfolioPrefix}_"`; - } - - public validate(aSymbol: string) { - return isValidCustomAssetProfileSymbol(aSymbol); - } -} diff --git a/test/import/not-ok/invalid-symbol-with-manual-data-source.json b/test/import/not-ok/invalid-symbol-with-manual-data-source.json new file mode 100644 index 000000000..08d070e61 --- /dev/null +++ b/test/import/not-ok/invalid-symbol-with-manual-data-source.json @@ -0,0 +1,27 @@ +{ + "meta": { + "date": "2023-02-05T00:00:00.000Z", + "version": "dev" + }, + "activities": [ + { + "accountId": null, + "comment": null, + "currency": "USD", + "dataSource": "MANUAL", + "date": "2022-01-01T00:00:00.000Z", + "fee": 0, + "quantity": 1, + "symbol": "Penthouse Apartment", + "tags": [], + "type": "BUY", + "unitPrice": 500000 + } + ], + "user": { + "settings": { + "currency": "USD", + "performanceCalculationType": "ROAI" + } + } +} diff --git a/test/import/ok/without-accounts.json b/test/import/ok/without-accounts.json index 2283dd889..cacbe153e 100644 --- a/test/import/ok/without-accounts.json +++ b/test/import/ok/without-accounts.json @@ -3,6 +3,28 @@ "date": "2022-04-01T00:00:00.000Z", "version": "dev" }, + "assetProfiles": [ + { + "assetClass": null, + "assetSubClass": null, + "comment": null, + "countries": [], + "currency": "USD", + "cusip": null, + "dataSource": "MANUAL", + "figi": null, + "figiComposite": null, + "figiShareClass": null, + "holdings": [], + "isActive": true, + "isin": null, + "marketData": [], + "name": "Penthouse Apartment", + "sectors": [], + "symbol": "7e91b7d4-1430-4212-8380-289a06c9bbc1", + "url": null + } + ], "activities": [ { "fee": 0, @@ -22,7 +44,7 @@ "currency": "USD", "dataSource": "MANUAL", "date": "2022-01-01T00:00:00.000Z", - "symbol": "Penthouse Apartment" + "symbol": "7e91b7d4-1430-4212-8380-289a06c9bbc1" }, { "fee": 0,