From 08c18643f065e8f92bfbae63aeb2926aea56fbf8 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:45:31 +0200 Subject: [PATCH] Improve validation of manual symbols --- .../src/app/activities/activities.service.ts | 5 +- apps/api/src/app/import/import.service.ts | 7 +- .../data-provider/data-provider.service.ts | 3 +- .../app/services/import-activities.service.ts | 9 ++- libs/common/src/lib/config.ts | 9 +++ libs/common/src/lib/helper.ts | 2 +- .../has-valid-manual-symbols.ts | 71 ++++++++++++------- .../interfaces/interfaces.ts | 6 ++ test/import/ok/penthouse-apartment.csv | 2 - test/import/ok/sample.csv | 1 - test/import/ok/without-accounts.json | 10 --- 11 files changed, 79 insertions(+), 46 deletions(-) create mode 100644 libs/common/src/lib/validator-constraints/interfaces/interfaces.ts delete mode 100644 test/import/ok/penthouse-apartment.csv diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index b4158d599b..53741a3199 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -14,6 +14,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { + ACTIVITY_TYPES_WITH_GENERATED_UUID_SYMBOL, DATA_GATHERING_QUEUE_PRIORITY_HIGH, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, @@ -188,7 +189,7 @@ export class ActivitiesService { const userId = data.userId; if ( - ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) || + ACTIVITY_TYPES_WITH_GENERATED_UUID_SYMBOL.includes(data.type) || (data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' && data.type === 'BUY') ) { @@ -957,7 +958,7 @@ export class ActivitiesService { let isDraft = false; if ( - ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) || + ACTIVITY_TYPES_WITH_GENERATED_UUID_SYMBOL.includes(data.type) || (data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' && data.type === 'BUY') ) { diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index b706baa8d5..ac47cdb5cd 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -9,7 +9,10 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; -import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; +import { + ACTIVITY_TYPES_WITH_GENERATED_UUID_SYMBOL, + DATA_GATHERING_QUEUE_PRIORITY_HIGH +} from '@ghostfolio/common/config'; import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos'; import { getAssetProfileIdentifier, @@ -384,7 +387,7 @@ export class ImportService { for (const activity of activitiesDto) { if (!activity.dataSource) { - if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { + if (ACTIVITY_TYPES_WITH_GENERATED_UUID_SYMBOL.includes(activity.type)) { activity.dataSource = DataSource.MANUAL; } else { activity.dataSource = 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 49f5f68f44..def1cdae52 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -7,6 +7,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { + ACTIVITY_TYPES_WITH_GENERATED_UUID_SYMBOL, DEFAULT_CURRENCY, DERIVED_CURRENCIES, PROPERTY_API_KEY_GHOSTFOLIO, @@ -267,7 +268,7 @@ export class DataProviderService implements OnModuleInit { if (!assetProfiles[assetProfileIdentifier]) { if ( (dataSource === DataSource.MANUAL && type === 'BUY') || - ['FEE', 'INTEREST', 'LIABILITY'].includes(type) + ACTIVITY_TYPES_WITH_GENERATED_UUID_SYMBOL.includes(type) ) { const assetProfileInImport = assetProfilesWithMarketDataDto?.find( (assetProfile) => { diff --git a/apps/client/src/app/services/import-activities.service.ts b/apps/client/src/app/services/import-activities.service.ts index ff56797184..43c8522608 100644 --- a/apps/client/src/app/services/import-activities.service.ts +++ b/apps/client/src/app/services/import-activities.service.ts @@ -1,3 +1,4 @@ +import { ACTIVITY_TYPES_WITH_GENERATED_UUID_SYMBOL } from '@ghostfolio/common/config'; import { CreateAccountWithBalancesDto, CreateAssetProfileWithMarketDataDto, @@ -77,8 +78,12 @@ export class ImportActivitiesService { updateAccountBalance: false }); - if (dataSource === DataSource.MANUAL) { - // Create synthetic asset profile for MANUAL data source + if ( + dataSource === DataSource.MANUAL && + !ACTIVITY_TYPES_WITH_GENERATED_UUID_SYMBOL.includes(type) + ) { + // Create synthetic asset profile for MANUAL data source. Activity types + // with a generated symbol are skipped, as the backend assigns a UUID. assetProfiles.push({ currency, symbol, diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 6b070755f2..83c107cf4e 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -32,6 +32,15 @@ export const warnColorRgb = { b: 69 }; +// The symbol of these activity types is a label rather than an asset +// identifier, so a symbol profile with a generated UUID is created for each of +// them +export const ACTIVITY_TYPES_WITH_GENERATED_UUID_SYMBOL: Type[] = [ + Type.FEE, + Type.INTEREST, + Type.LIABILITY +]; + export const ASSET_CLASS_MAPPING = new Map([ [AssetClass.ALTERNATIVE_INVESTMENT, [AssetSubClass.COLLECTIBLE]], [AssetClass.COMMODITY, [AssetSubClass.PRECIOUS_METAL]], diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 119dd17fc0..7cf26d4b9c 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -510,7 +510,7 @@ export function isRootCurrency(aCurrency: string) { }); } -export function isValidManualSymbol(aSymbol: string) { +export function isValidManualSymbol(aSymbol?: string) { if (!aSymbol) { return false; } diff --git a/libs/common/src/lib/validator-constraints/has-valid-manual-symbols.ts b/libs/common/src/lib/validator-constraints/has-valid-manual-symbols.ts index a920dce3d8..88ed772c24 100644 --- a/libs/common/src/lib/validator-constraints/has-valid-manual-symbols.ts +++ b/libs/common/src/lib/validator-constraints/has-valid-manual-symbols.ts @@ -1,46 +1,67 @@ +import { DataSource } from '@prisma/client'; import { ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator'; -import { ghostfolioPrefix } from '../config'; +import { + ACTIVITY_TYPES_WITH_GENERATED_UUID_SYMBOL, + ghostfolioPrefix +} from '../config'; import { isValidManualSymbol } from '../helper'; - -interface ImportDataLike { - activities?: { dataSource?: string; symbol?: string; type?: string }[]; - assetProfiles?: { dataSource?: string; symbol?: string }[]; -} +import { ImportDataLike } from './interfaces/interfaces'; @ValidatorConstraint({ name: 'hasValidManualSymbols' }) export class HasValidManualSymbolsConstraint implements ValidatorConstraintInterface { - public defaultMessage() { - return `manual symbols must be a UUID or start with "${ghostfolioPrefix}_"`; + public defaultMessage(args: ValidationArguments) { + const entry = this.getEntryWithInvalidManualSymbol( + args.object as ImportDataLike + ); + + return `manual symbols must be a UUID or start with "${ghostfolioPrefix}_", but got "${entry?.symbol ?? ''}"`; } public validate(_: unknown, args: ValidationArguments) { - const { activities = [], assetProfiles = [] } = - args.object as ImportDataLike; + return !this.getEntryWithInvalidManualSymbol(args.object as ImportDataLike); + } - const activitiesAreValid = activities.every( + private getEntryWithInvalidManualSymbol({ + activities, + assetProfiles + }: ImportDataLike) { + // Defer to @IsArray() and @ValidateNested() for malformed input + const activityWithInvalidSymbol = this.toEntries(activities).find( ({ dataSource, symbol, type }) => { - // FEE, INTEREST and LIABILITY default to the MANUAL data source - // (resolved in the backend), so treat them as manual when no data - // source is set - const isManual = - dataSource === 'MANUAL' || - (!dataSource && ['FEE', 'INTEREST', 'LIABILITY'].includes(type)); - - return !isManual || isValidManualSymbol(symbol); - } - ); + const hasGeneratedUuidSymbol = + ACTIVITY_TYPES_WITH_GENERATED_UUID_SYMBOL.some((activityType) => { + return activityType === type; + }); - const assetProfilesAreValid = assetProfiles.every( - ({ dataSource, symbol }) => { - return dataSource !== 'MANUAL' || isValidManualSymbol(symbol); + return ( + dataSource === DataSource.MANUAL && + !hasGeneratedUuidSymbol && + !isValidManualSymbol(symbol) + ); } ); - return activitiesAreValid && assetProfilesAreValid; + if (activityWithInvalidSymbol) { + return activityWithInvalidSymbol; + } + + return this.toEntries(assetProfiles).find(({ dataSource, symbol }) => { + return dataSource === DataSource.MANUAL && !isValidManualSymbol(symbol); + }); + } + + private toEntries(aValue?: T[]) { + if (!Array.isArray(aValue)) { + return []; + } + + return aValue.filter((entry) => { + return entry instanceof Object; + }); } } diff --git a/libs/common/src/lib/validator-constraints/interfaces/interfaces.ts b/libs/common/src/lib/validator-constraints/interfaces/interfaces.ts new file mode 100644 index 0000000000..087f3847f5 --- /dev/null +++ b/libs/common/src/lib/validator-constraints/interfaces/interfaces.ts @@ -0,0 +1,6 @@ +import { DataSource, Type } from '@prisma/client'; + +export interface ImportDataLike { + activities?: { dataSource?: DataSource; symbol?: string; type?: Type }[]; + assetProfiles?: { dataSource?: DataSource; symbol?: string }[]; +} diff --git a/test/import/ok/penthouse-apartment.csv b/test/import/ok/penthouse-apartment.csv deleted file mode 100644 index 27eb5bf1c2..0000000000 --- a/test/import/ok/penthouse-apartment.csv +++ /dev/null @@ -1,2 +0,0 @@ -Date,Code,DataSource,Currency,Price,Quantity,Action,Fee,Note -01.01.2022,Penthouse Apartment,MANUAL,USD,500000.0,1,buy,0.00, diff --git a/test/import/ok/sample.csv b/test/import/ok/sample.csv index a22a4b89e1..8c588ff9e7 100644 --- a/test/import/ok/sample.csv +++ b/test/import/ok/sample.csv @@ -2,5 +2,4 @@ Date,Code,DataSource,Currency,Price,Quantity,Action,Fee,Note 01-09-2021,Account Opening Fee,MANUAL,USD,0,0,fee,49, 16-09-2021,MSFT,YAHOO,USD,298.580,5,buy,19.00,My first order 🤓 17/11/2021,MSFT,YAHOO,USD,0.62,5,dividend,0.00, -01.01.2022,Penthouse Apartment,MANUAL,USD,500000.0,1,buy,0.00, 20500606,US5949181045,YAHOO,USD,0.00,0,buy,0.00, diff --git a/test/import/ok/without-accounts.json b/test/import/ok/without-accounts.json index 2283dd889a..ec423f66cc 100644 --- a/test/import/ok/without-accounts.json +++ b/test/import/ok/without-accounts.json @@ -14,16 +14,6 @@ "date": "2050-06-06T00:00:00.000Z", "symbol": "MSFT" }, - { - "fee": 0, - "quantity": 1, - "type": "BUY", - "unitPrice": 500000, - "currency": "USD", - "dataSource": "MANUAL", - "date": "2022-01-01T00:00:00.000Z", - "symbol": "Penthouse Apartment" - }, { "fee": 0, "quantity": 5,