diff --git a/CHANGELOG.md b/CHANGELOG.md index b9cbcd7e21..90e2175e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Harmonized the colors in the investment chart component - Improved the language localization for Chinese (`zh`) +### Fixed + +- Fixed an issue where a user could create a custom asset profile with a symbol of the prefix `GF_` + ## 3.82.0 - 2026-10-09 ### Added diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts index 09aa64b0e2..fb2c68ff40 100644 --- a/apps/api/src/app/activities/activities.service.spec.ts +++ b/apps/api/src/app/activities/activities.service.spec.ts @@ -8,6 +8,7 @@ import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { INVESTMENT_ACTIVITY_TYPES, NON_INVESTMENT_ACTIVITY_TYPES @@ -15,12 +16,15 @@ import { import { parseDate } from '@ghostfolio/common/helper'; import { Activity, Filter } from '@ghostfolio/common/interfaces'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { AssetProfileSplit, DataSource, + Prisma, Type as ActivityType } from '@prisma/client'; import { Big } from 'big.js'; +import { isUUID } from 'class-validator'; import { ActivitiesService } from './activities.service'; @@ -49,6 +53,98 @@ describe('ActivitiesService', () => { ); }); + describe('createActivity', () => { + it('creates a custom asset profile with a UUID if no asset profile exists for a symbol with the prefix', async () => { + const assetProfile = await getCreatedAssetProfile({ symbol: 'GF_COPX' }); + + expect(isUUID(assetProfile.symbol)).toBe(true); + expect(assetProfile.name).toBe('GF_COPX'); + }); + + it('connects to the existing asset profile of a symbol with the prefix', async () => { + const assetProfile = await getCreatedAssetProfile({ + existingAssetProfile: { id: 'asset-profile-id' }, + symbol: 'GF_COPX' + }); + + expect(assetProfile.symbol).toBe('GF_COPX'); + }); + + it('creates a custom asset profile with the requested UUID if no asset profile exists', async () => { + const symbol = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b'; + + const assetProfile = await getCreatedAssetProfile({ symbol }); + + expect(assetProfile.symbol).toBe(symbol); + }); + + async function getCreatedAssetProfile({ + existingAssetProfile, + symbol + }: { + existingAssetProfile?: { id: string }; + symbol: string; + }) { + const create = jest.fn(({ data }: Prisma.OrderCreateArgs) => { + return { + ...data, + SymbolProfile: data.SymbolProfile.connectOrCreate.create + }; + }); + + const service = new ActivitiesService( + null, + null, + null, + null, + null, + null, + { emit: jest.fn() } as unknown as EventEmitter2, + null, + null, + { + order: { create }, + symbolProfile: { + findUnique: jest + .fn() + .mockResolvedValue(existingAssetProfile ?? null) + } + } as unknown as PrismaService, + null, + { validateTagIds: jest.fn() } as unknown as TagService + ); + + await service.createActivity({ + currency: 'USD', + date: parseDate('2024-01-01'), + fee: 0, + quantity: 1, + SymbolProfile: { + connectOrCreate: { + create: { symbol, currency: 'USD', dataSource: DataSource.MANUAL }, + where: { + dataSource_symbol: { symbol, dataSource: DataSource.MANUAL } + } + } + }, + type: 'BUY', + unitPrice: 100, + user: { connect: { id: 'user-id' } }, + userId: 'user-id' + }); + + const { create: assetProfile, where } = + create.mock.calls[0][0].data.SymbolProfile.connectOrCreate; + + expect(where.dataSource_symbol).toEqual({ + dataSource: DataSource.MANUAL, + symbol: assetProfile.symbol + }); + + return assetProfile; + } + }); + describe('getActivities', () => { it('returns the activities with the asset profile but without the relation to the symbol profile', async () => { const assetProfile = { diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 1c933956a8..c592a44e76 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -34,10 +34,11 @@ import { } from '@ghostfolio/common/config'; import { canDeleteAssetProfile, + canUseCustomAssetProfileSymbol, getAssetProfileIdentifier, getStartOfUtcDateOfTomorrow, - isDraftActivity, - isValidCustomAssetProfileSymbol + hasGhostfolioPrefix, + isDraftActivity } from '@ghostfolio/common/helper'; import { ActivitiesResponse, @@ -220,19 +221,31 @@ export class ActivitiesService { const assetSubClass = data.assetSubClass; const dataSource: DataSource = 'MANUAL'; + const requestedSymbol = data.SymbolProfile.connectOrCreate.create.symbol; + let name = data.SymbolProfile.connectOrCreate.create.name; let symbol: string; + const existingAssetProfile = hasGhostfolioPrefix(requestedSymbol) + ? await this.prismaService.symbolProfile.findUnique({ + select: { id: true }, + where: { + dataSource_symbol: { dataSource, symbol: requestedSymbol } + } + }) + : null; + if ( - isValidCustomAssetProfileSymbol( - data.SymbolProfile.connectOrCreate.create.symbol - ) + canUseCustomAssetProfileSymbol({ + assetProfile: existingAssetProfile, + symbol: requestedSymbol + }) ) { // Connect custom asset profile (clone) - symbol = data.SymbolProfile.connectOrCreate.create.symbol; + symbol = requestedSymbol; } else { // Create custom asset profile - name = name ?? data.SymbolProfile.connectOrCreate.create.symbol; + name = name ?? requestedSymbol; symbol = randomUUID(); } diff --git a/apps/api/src/app/import/import.service.spec.ts b/apps/api/src/app/import/import.service.spec.ts index afabdbcb1f..5858cf0b65 100644 --- a/apps/api/src/app/import/import.service.spec.ts +++ b/apps/api/src/app/import/import.service.spec.ts @@ -3,10 +3,15 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.ser 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'; 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 { NON_INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config'; -import { CreateOrderDto } from '@ghostfolio/common/dtos'; +import { + CreateAssetProfileWithMarketDataDto, + CreateOrderDto +} from '@ghostfolio/common/dtos'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { Activity, @@ -15,6 +20,7 @@ import { import { UserWithSettings } from '@ghostfolio/common/types'; import { DataSource, SymbolProfile } from '@prisma/client'; +import { isUUID } from 'class-validator'; import { parseISO } from 'date-fns'; import { ImportService } from './import.service'; @@ -22,9 +28,11 @@ import { ImportService } from './import.service'; const CUSTOM_ASSET_PROFILE_SYMBOL = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b'; describe('ImportService', () => { + let addSymbolProfile: jest.Mock; let createActivity: jest.Mock; let gatherSymbols: jest.Mock; let importService: ImportService; + let updateManyMarketData: jest.Mock; beforeEach(() => { const configuration = { @@ -33,8 +41,10 @@ describe('ImportService', () => { MAX_ACTIVITIES_TO_IMPORT: Number.MAX_SAFE_INTEGER }; + addSymbolProfile = jest.fn(); createActivity = jest.fn(); gatherSymbols = jest.fn(); + updateManyMarketData = jest.fn(); const validateActivities = jest.fn( ({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => { @@ -77,10 +87,14 @@ describe('ImportService', () => { { toCurrencyAtDate: jest.fn().mockResolvedValue(0) } as unknown as ExchangeRateDataService, + { updateMany: updateManyMarketData } as unknown as MarketDataService, null, null, - null, - null, + { + add: addSymbolProfile, + getCustomSymbolProfilesByNames: jest.fn().mockResolvedValue([]), + getSymbolProfiles: jest.fn().mockResolvedValue([]) + } as unknown as SymbolProfileService, { getTagsForUser: jest.fn().mockResolvedValue([]) } as unknown as TagService @@ -275,6 +289,146 @@ describe('ImportService', () => { ]); }); + it('creates a custom asset profile with a UUID instead of a symbol with the prefix', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.MANUAL, + date: '2024-01-01T00:00:00.000Z', + symbol: 'GF_COPX', + type: 'BUY' + }) + ], + assetProfilesWithMarketDataDto: [ + { + currency: 'USD', + dataSource: DataSource.MANUAL, + marketData: [], + name: 'Global X Copper Miners ETF', + symbol: 'GF_COPX' + } + ] + }); + + const { symbol } = addSymbolProfile.mock.calls[0][0]; + + expect(isUUID(symbol)).toBe(true); + + expect( + createActivity.mock.calls[0][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ symbol, dataSource: DataSource.MANUAL }); + }); + + it('keeps the symbol with the prefix as the name of a custom asset profile without a name', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.MANUAL, + date: '2024-01-01T00:00:00.000Z', + symbol: 'GF_COPX', + type: 'BUY' + }) + ], + assetProfilesWithMarketDataDto: [ + { + currency: 'USD', + dataSource: DataSource.MANUAL, + marketData: [], + symbol: 'GF_COPX' + } + ] + }); + + expect(addSymbolProfile.mock.calls[0][0].name).toBe('GF_COPX'); + }); + + it('merges the market data of asset profiles with the same symbol with the prefix', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.MANUAL, + date: '2024-01-01T00:00:00.000Z', + symbol: 'GF_COPX', + type: 'BUY' + }) + ], + assetProfilesWithMarketDataDto: [ + { + currency: 'USD', + dataSource: DataSource.MANUAL, + marketData: [{ date: '2024-01-01', marketPrice: 100 }], + name: 'Global X Copper Miners ETF', + symbol: 'GF_COPX' + }, + { + currency: 'USD', + dataSource: DataSource.MANUAL, + marketData: [{ date: '2024-01-02', marketPrice: 101 }], + name: 'Global X Copper Miners ETF', + symbol: 'GF_COPX' + } + ] + }); + + const { symbol } = addSymbolProfile.mock.calls[0][0]; + + expect(addSymbolProfile).toHaveBeenCalledTimes(1); + + expect(updateManyMarketData.mock.calls[0][0].data).toMatchObject([ + { symbol, date: '2024-01-01', marketPrice: 100 }, + { symbol, date: '2024-01-02', marketPrice: 101 } + ]); + + expect( + createActivity.mock.calls[0][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ symbol, dataSource: DataSource.MANUAL }); + }); + + it('refers the next activities with a symbol with the prefix to the custom asset profile which createActivity() has created', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.MANUAL, + date: '2024-01-01T00:00:00.000Z', + symbol: 'GF_COPX', + type: 'BUY' + }), + createActivityDto({ + dataSource: DataSource.MANUAL, + date: '2024-01-02T00:00:00.000Z', + symbol: 'GF_COPX', + type: 'BUY' + }) + ] + }); + + expect( + createActivity.mock.calls.map(([activity]) => { + return activity.SymbolProfile.connectOrCreate.create; + }) + ).toMatchObject([ + { dataSource: DataSource.MANUAL, symbol: 'GF_COPX' }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + }); + it('keeps the asset profiles of the activities in a dry run', async () => { const activities = await importActivities({ activitiesDto: [ @@ -306,16 +460,18 @@ describe('ImportService', () => { function importActivities({ activitiesDto, + assetProfilesWithMarketDataDto = [], isDryRun }: { activitiesDto: CreateOrderDto[]; + assetProfilesWithMarketDataDto?: CreateAssetProfileWithMarketDataDto[]; isDryRun?: boolean; }) { return importService.import({ activitiesDto, + assetProfilesWithMarketDataDto, isDryRun, accountsWithBalancesDto: [], - assetProfilesWithMarketDataDto: [], platformsDto: [], tagsDto: [], user: { diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 80637ea4d6..0f44fc2855 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -27,6 +27,7 @@ import { import { SubscriptionType } from '@ghostfolio/common/enums'; import { getAssetProfileIdentifier, + hasGhostfolioPrefix, isValidCustomAssetProfileSymbol, parseDate } from '@ghostfolio/common/helper'; @@ -649,9 +650,23 @@ export class ImportService { 'marketData' ); - // Asset profile belongs to a different user, generate a new symbol - if (existingAssetProfile && !isDryRun) { - symbol = randomUUID(); + if ( + (existingAssetProfile || hasGhostfolioPrefix(symbol)) && + !isDryRun + ) { + const isSymbolOfAssetProfileToCreate = assetProfilesToCreate.some( + ({ assetProfile: { symbol: symbolToCreate } }) => { + return symbolToCreate === assetProfileSymbolMapping[symbol]; + } + ); + + if (hasGhostfolioPrefix(symbol)) { + assetProfile.name ??= symbol; + } + + symbol = isSymbolOfAssetProfileToCreate + ? assetProfileSymbolMapping[symbol] + : randomUUID(); } assetProfile.symbol = symbol; diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index 71b934628c..d1ae262248 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -4,6 +4,7 @@ import { } from '@ghostfolio/common/config'; import { canApplyFiltersToAccess, + canUseCustomAssetProfileSymbol, extractNumberFromString, getCountryCodeFromCurrency, getEmojiFlag, @@ -39,6 +40,50 @@ describe('Helper', () => { }); }); + describe('Can use custom asset profile symbol', () => { + const assetProfile = { id: 'asset-profile-id' }; + const symbol = '7e91b7d4-1430-4212-8380-289a06c9bbc1'; + + it('Free-text symbol', () => { + expect( + canUseCustomAssetProfileSymbol({ + assetProfile: null, + symbol: 'Penthouse Apartment' + }) + ).toEqual(false); + }); + + it('New asset profile with UUID', () => { + expect( + canUseCustomAssetProfileSymbol({ symbol, assetProfile: null }) + ).toEqual(true); + }); + + it('New asset profile with Ghostfolio prefix', () => { + expect( + canUseCustomAssetProfileSymbol({ + assetProfile: null, + symbol: 'GF_PENTHOUSE_APARTMENT' + }) + ).toEqual(false); + }); + + it('Existing asset profile with UUID', () => { + expect(canUseCustomAssetProfileSymbol({ assetProfile, symbol })).toEqual( + true + ); + }); + + it('Existing asset profile with Ghostfolio prefix', () => { + expect( + canUseCustomAssetProfileSymbol({ + assetProfile, + symbol: 'GF_PENTHOUSE_APARTMENT' + }) + ).toEqual(true); + }); + }); + describe('Extract number from string', () => { it('Get decimal number', () => { expect(extractNumberFromString({ value: '999.99' })).toEqual(999.99); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 7e4e13de7e..0a3ec5ab85 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -237,6 +237,29 @@ export function canOpenHoldingDetail({ return assetProfile?.assetSubClass !== AssetSubClass.CASH; } +/** + * Checks if the symbol of a custom asset profile can be used, either to connect + * to the existing asset profile or to create a new one. The prefix is reserved + * for the asset profiles of the admin, thus a new asset profile cannot use it. + */ +export function canUseCustomAssetProfileSymbol({ + assetProfile, + symbol +}: { + assetProfile: Pick | null; + symbol: string; +}): boolean { + if (!isValidCustomAssetProfileSymbol(symbol)) { + return false; + } + + if (assetProfile) { + return true; + } + + return !hasGhostfolioPrefix(symbol); +} + export function capitalize(aString: string) { return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase(); }