From aa81ed29dccd088d96636f3d539e8150fded1d8b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:35:39 +0200 Subject: [PATCH] Improve validation of manual symbols --- apps/api/src/app/import/import-data.dto.ts | 4 +- libs/common/src/lib/helper.spec.ts | 27 +++++++++- libs/common/src/lib/helper.ts | 11 +++- .../has-valid-manual-symbols.ts | 46 ++++++++++++++++ ...nvalid-symbol-with-manual-data-source.json | 52 +++++++++++++++++++ 5 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 libs/common/src/lib/validator-constraints/has-valid-manual-symbols.ts create mode 100644 test/import/not-ok/invalid-symbol-with-manual-data-source.json diff --git a/apps/api/src/app/import/import-data.dto.ts b/apps/api/src/app/import/import-data.dto.ts index bf45c7cda7..589d7e18bf 100644 --- a/apps/api/src/app/import/import-data.dto.ts +++ b/apps/api/src/app/import/import-data.dto.ts @@ -4,9 +4,10 @@ import { CreateOrderDto, CreateTagDto } from '@ghostfolio/common/dtos'; +import { HasValidManualSymbolsConstraint } from '@ghostfolio/common/validator-constraints/has-valid-manual-symbols'; import { Type } from 'class-transformer'; -import { IsArray, IsOptional, ValidateNested } from 'class-validator'; +import { IsArray, IsOptional, Validate, ValidateNested } from 'class-validator'; export class ImportDataDto { @IsArray() @@ -17,6 +18,7 @@ export class ImportDataDto { @IsArray() @Type(() => CreateOrderDto) + @Validate(HasValidManualSymbolsConstraint) @ValidateNested({ each: true }) activities: CreateOrderDto[]; diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index d33f104522..0fbea13e93 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -2,7 +2,8 @@ import { extractNumberFromString, getNumberFormatGroup, isCurrency, - isCurrencySymbol + isCurrencySymbol, + isValidManualSymbol } from '@ghostfolio/common/helper'; describe('Helper', () => { @@ -195,4 +196,28 @@ describe('Helper', () => { expect(isCurrencySymbol('')).toEqual(false); }); }); + + describe('Is valid manual symbol', () => { + it('UUID', () => { + expect( + isValidManualSymbol('7e91b7d4-1430-4212-8380-289a06c9bbc1') + ).toEqual(true); + }); + + it('Ghostfolio-prefixed symbol', () => { + expect(isValidManualSymbol('GF_PENTHOUSE_APARTMENT')).toEqual(true); + }); + + it('Human-readable symbol', () => { + expect(isValidManualSymbol('PENTHOUSE_APARTMENT')).toEqual(false); + }); + + it('Human-readable name', () => { + expect(isValidManualSymbol('Penthouse Apartment')).toEqual(false); + }); + + it('Empty symbol', () => { + expect(isValidManualSymbol('')).toEqual(false); + }); + }); }); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 5d1ff538e5..119dd17fc0 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -8,7 +8,7 @@ import { SymbolProfile } from '@prisma/client'; import { Big } from 'big.js'; -import { isISO4217CurrencyCode } from 'class-validator'; +import { isISO4217CurrencyCode, isUUID } from 'class-validator'; import { getDate, getMonth, @@ -41,6 +41,7 @@ import { DERIVED_CURRENCIES, ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolStocks, + ghostfolioPrefix, TAG_ID_EXCLUDE_FROM_ANALYSIS } from './config'; import { @@ -509,6 +510,14 @@ export function isRootCurrency(aCurrency: string) { }); } +export function isValidManualSymbol(aSymbol: string) { + if (!aSymbol) { + return false; + } + + return isUUID(aSymbol) || aSymbol.startsWith(`${ghostfolioPrefix}_`); +} + export function parseDate(date: string): Date | undefined { if (!date) { return undefined; 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 new file mode 100644 index 0000000000..a920dce3d8 --- /dev/null +++ b/libs/common/src/lib/validator-constraints/has-valid-manual-symbols.ts @@ -0,0 +1,46 @@ +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface +} from 'class-validator'; + +import { ghostfolioPrefix } from '../config'; +import { isValidManualSymbol } from '../helper'; + +interface ImportDataLike { + activities?: { dataSource?: string; symbol?: string; type?: string }[]; + assetProfiles?: { dataSource?: string; symbol?: string }[]; +} + +@ValidatorConstraint({ name: 'hasValidManualSymbols' }) +export class HasValidManualSymbolsConstraint implements ValidatorConstraintInterface { + public defaultMessage() { + return `manual symbols must be a UUID or start with "${ghostfolioPrefix}_"`; + } + + public validate(_: unknown, args: ValidationArguments) { + const { activities = [], assetProfiles = [] } = + args.object as ImportDataLike; + + const activitiesAreValid = activities.every( + ({ 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 assetProfilesAreValid = assetProfiles.every( + ({ dataSource, symbol }) => { + return dataSource !== 'MANUAL' || isValidManualSymbol(symbol); + } + ); + + return activitiesAreValid && assetProfilesAreValid; + } +} 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 0000000000..1a4bc989fc --- /dev/null +++ b/test/import/not-ok/invalid-symbol-with-manual-data-source.json @@ -0,0 +1,52 @@ +{ + "meta": { + "date": "2023-02-05T00:00:00.000Z", + "version": "dev" + }, + "accounts": [], + "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": "PENTHOUSE_APARTMENT", + "url": null + } + ], + "platforms": [], + "tags": [], + "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" + } + } +}