diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 53741a3199..19578f19b2 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -23,6 +23,7 @@ import { import { canDeleteAssetProfile, getAssetProfileIdentifier, + isGhostfolioSymbol, isValidManualSymbol } from '@ghostfolio/common/helper'; import { @@ -205,6 +206,21 @@ export class ActivitiesService { ) { // Connect custom asset profile (clone) symbol = data.SymbolProfile.connectOrCreate.create.symbol; + + if (isGhostfolioSymbol(symbol)) { + // Asset profiles with the Ghostfolio prefix are created in the admin + // control only, so they must exist already + const existingAssetProfile = + await this.prismaService.symbolProfile.findUnique({ + where: { dataSource_symbol: { dataSource, symbol } } + }); + + if (!existingAssetProfile) { + throw new Error( + `Asset profile not found for ${symbol} (${dataSource})` + ); + } + } } else { // Create custom asset profile name = name ?? data.SymbolProfile.connectOrCreate.create.symbol; diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index ac47cdb5cd..4f22cfa23c 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 { ACTIVITY_TYPES_WITH_GENERATED_UUID_SYMBOL, - DATA_GATHERING_QUEUE_PRIORITY_HIGH + DATA_GATHERING_QUEUE_PRIORITY_HIGH, + ghostfolioPrefix } from '@ghostfolio/common/config'; import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos'; import { getAssetProfileIdentifier, + isGhostfolioSymbol, parseDate } from '@ghostfolio/common/helper'; import { @@ -34,7 +36,7 @@ import { Injectable } from '@nestjs/common'; import { DataSource, Prisma } from '@prisma/client'; import { Big } from 'big.js'; import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns'; -import { omit, uniqBy } from 'lodash'; +import { omit, uniq, uniqBy } from 'lodash'; import { randomUUID } from 'node:crypto'; import { ImportDataDto } from './import-data.dto'; @@ -193,6 +195,11 @@ export class ImportService { const tagIdMapping: { [oldTagId: string]: string } = {}; const userCurrency = user.settings.settings.baseCurrency; + await this.validateGhostfolioSymbols({ + activitiesDto, + assetProfilesWithMarketDataDto + }); + const existingTagsOfUser = tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length) ? await this.tagService.getTagsForUser(user.id) @@ -749,4 +756,45 @@ export class ImportService { return uniqueAccountIds.size === 1; } + + private async validateGhostfolioSymbols({ + activitiesDto, + assetProfilesWithMarketDataDto + }: { + activitiesDto: ImportDataDto['activities']; + assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles']; + }) { + const symbols = uniq( + [...(activitiesDto ?? []), ...(assetProfilesWithMarketDataDto ?? [])].map( + ({ symbol }) => { + return symbol; + } + ) + ).filter((symbol) => { + return isGhostfolioSymbol(symbol); + }); + + if (symbols.length === 0) { + return; + } + + const existingAssetProfiles = + await this.symbolProfileService.getSymbolProfiles( + symbols.map((symbol) => { + return { symbol, dataSource: DataSource.MANUAL }; + }) + ); + + const missingSymbols = symbols.filter((symbol) => { + return !existingAssetProfiles.some(({ symbol: existingSymbol }) => { + return existingSymbol === symbol; + }); + }); + + if (missingSymbols.length > 0) { + throw new Error( + `Asset profiles with the prefix "${ghostfolioPrefix}_" can only be created in the admin control (${missingSymbols.join(', ')})` + ); + } + } } diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index 0fbea13e93..bf7269bcc6 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -3,6 +3,7 @@ import { getNumberFormatGroup, isCurrency, isCurrencySymbol, + isGhostfolioSymbol, isValidManualSymbol } from '@ghostfolio/common/helper'; @@ -197,6 +198,22 @@ describe('Helper', () => { }); }); + describe('Is Ghostfolio symbol', () => { + it('Ghostfolio-prefixed symbol', () => { + expect(isGhostfolioSymbol('GF_PENTHOUSE_APARTMENT')).toEqual(true); + }); + + it('UUID', () => { + expect( + isGhostfolioSymbol('7e91b7d4-1430-4212-8380-289a06c9bbc1') + ).toEqual(false); + }); + + it('Human-readable symbol', () => { + expect(isGhostfolioSymbol('PENTHOUSE_APARTMENT')).toEqual(false); + }); + }); + describe('Is valid manual symbol', () => { it('UUID', () => { expect( diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 7cf26d4b9c..2073607d4c 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -500,6 +500,14 @@ export function isDerivedCurrency(aCurrency: string) { }); } +export function isGhostfolioSymbol(aSymbol?: string) { + if (!aSymbol) { + return false; + } + + return aSymbol.startsWith(`${ghostfolioPrefix}_`); +} + export function isRootCurrency(aCurrency: string) { if (aCurrency === 'USD') { return true; @@ -515,7 +523,7 @@ export function isValidManualSymbol(aSymbol?: string) { return false; } - return isUUID(aSymbol) || aSymbol.startsWith(`${ghostfolioPrefix}_`); + return isGhostfolioSymbol(aSymbol) || isUUID(aSymbol); } export function parseDate(date: string): Date | undefined {