diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts index c97836ea0b..fb2c68ff40 100644 --- a/apps/api/src/app/activities/activities.service.spec.ts +++ b/apps/api/src/app/activities/activities.service.spec.ts @@ -61,35 +61,16 @@ describe('ActivitiesService', () => { expect(assetProfile.name).toBe('GF_COPX'); }); - it.each(['1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b', 'GF_COPX'])( - 'creates a custom asset profile with a new UUID if the asset profile of %s belongs to another user', - async (symbol) => { - const assetProfile = await getCreatedAssetProfile({ - symbol, - existingAssetProfile: { userId: 'other-user-id' } - }); - - expect(isUUID(assetProfile.symbol)).toBe(true); - expect(assetProfile.symbol).not.toBe(symbol); - } - ); - - it.each([ - { owner: 'the admin', userId: null }, - { owner: 'the user', userId: 'user-id' } - ])( - 'connects to the asset profile of $owner for a symbol with the prefix', - async ({ userId }) => { - const assetProfile = await getCreatedAssetProfile({ - existingAssetProfile: { userId }, - symbol: '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'); - } - ); + expect(assetProfile.symbol).toBe('GF_COPX'); + }); - it('connects to the custom asset profile of a UUID symbol', async () => { + 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 }); @@ -101,7 +82,7 @@ describe('ActivitiesService', () => { existingAssetProfile, symbol }: { - existingAssetProfile?: { userId: string | null }; + existingAssetProfile?: { id: string }; symbol: string; }) { const create = jest.fn(({ data }: Prisma.OrderCreateArgs) => { @@ -152,7 +133,15 @@ describe('ActivitiesService', () => { userId: 'user-id' }); - return create.mock.calls[0][0].data.SymbolProfile.connectOrCreate.create; + 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; } }); diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 28c0c7e1a5..bbd24dd0e5 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -226,13 +226,11 @@ export class ActivitiesService { let name = data.SymbolProfile.connectOrCreate.create.name; let symbol: string; - // Look up valid custom asset profile symbols only, since a free text is - // never the symbol of a custom asset profile const existingAssetProfile = isValidCustomAssetProfileSymbol( requestedSymbol ) ? await this.prismaService.symbolProfile.findUnique({ - select: { userId: true }, + select: { id: true }, where: { dataSource_symbol: { dataSource, symbol: requestedSymbol } } @@ -241,7 +239,6 @@ export class ActivitiesService { if ( canUseCustomAssetProfileSymbol({ - userId, assetProfile: existingAssetProfile, symbol: requestedSymbol }) diff --git a/apps/api/src/app/import/import.service.spec.ts b/apps/api/src/app/import/import.service.spec.ts index 183111eb12..4a297dcd9d 100644 --- a/apps/api/src/app/import/import.service.spec.ts +++ b/apps/api/src/app/import/import.service.spec.ts @@ -32,6 +32,7 @@ describe('ImportService', () => { let createActivity: jest.Mock; let gatherSymbols: jest.Mock; let importService: ImportService; + let updateManyMarketData: jest.Mock; beforeEach(() => { const configuration = { @@ -43,6 +44,7 @@ describe('ImportService', () => { addSymbolProfile = jest.fn(); createActivity = jest.fn(); gatherSymbols = jest.fn(); + updateManyMarketData = jest.fn(); const validateActivities = jest.fn( ({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => { @@ -85,7 +87,7 @@ describe('ImportService', () => { { toCurrencyAtDate: jest.fn().mockResolvedValue(0) } as unknown as ExchangeRateDataService, - { updateMany: jest.fn() } as unknown as MarketDataService, + { updateMany: updateManyMarketData } as unknown as MarketDataService, null, null, { @@ -321,6 +323,79 @@ describe('ImportService', () => { ).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('keeps the asset profiles of the activities in a dry run', async () => { const activities = await importActivities({ activitiesDto: [ diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 93d5325b94..3cfb1973ab 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -26,8 +26,8 @@ import { } from '@ghostfolio/common/dtos'; import { SubscriptionType } from '@ghostfolio/common/enums'; import { - canUseCustomAssetProfileSymbol, getAssetProfileIdentifier, + hasGhostfolioPrefix, isValidCustomAssetProfileSymbol, parseDate } from '@ghostfolio/common/helper'; @@ -650,16 +650,21 @@ export class ImportService { 'marketData' ); - // Generate a new symbol if the user cannot use the symbol for a new - // asset profile, or if the asset profile exists. Unlike in - // createActivity(), this applies to an asset profile of the admin - // as well, since the asset profile of the import has its own data. if ( - (existingAssetProfile || - !canUseCustomAssetProfileSymbol({ symbol, userId: user.id })) && + (existingAssetProfile || hasGhostfolioPrefix(symbol)) && !isDryRun ) { - symbol = randomUUID(); + const isSymbolOfAssetProfileToCreate = assetProfilesToCreate.some( + ({ assetProfile: { symbol: symbolToCreate } }) => { + return symbolToCreate === assetProfileSymbolMapping[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 38fe5995c3..d1ae262248 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -41,60 +41,47 @@ describe('Helper', () => { }); describe('Can use custom asset profile symbol', () => { + const assetProfile = { id: 'asset-profile-id' }; const symbol = '7e91b7d4-1430-4212-8380-289a06c9bbc1'; - const userId = 'user-id'; it('Free-text symbol', () => { expect( canUseCustomAssetProfileSymbol({ - userId, + assetProfile: null, symbol: 'Penthouse Apartment' }) ).toEqual(false); }); it('New asset profile with UUID', () => { - expect(canUseCustomAssetProfileSymbol({ symbol, userId })).toEqual(true); + expect( + canUseCustomAssetProfileSymbol({ symbol, assetProfile: null }) + ).toEqual(true); }); it('New asset profile with Ghostfolio prefix', () => { expect( canUseCustomAssetProfileSymbol({ - userId, + assetProfile: null, symbol: 'GF_PENTHOUSE_APARTMENT' }) ).toEqual(false); }); - it('Asset profile of the admin', () => { - expect( - canUseCustomAssetProfileSymbol({ - userId, - assetProfile: { userId: null }, - symbol: 'GF_PENTHOUSE_APARTMENT' - }) - ).toEqual(true); + it('Existing asset profile with UUID', () => { + expect(canUseCustomAssetProfileSymbol({ assetProfile, symbol })).toEqual( + true + ); }); - it('Asset profile of the user', () => { + it('Existing asset profile with Ghostfolio prefix', () => { expect( canUseCustomAssetProfileSymbol({ - symbol, - userId, - assetProfile: { userId } + assetProfile, + symbol: 'GF_PENTHOUSE_APARTMENT' }) ).toEqual(true); }); - - it('Asset profile of another user', () => { - expect( - canUseCustomAssetProfileSymbol({ - symbol, - userId, - assetProfile: { userId: 'other-user-id' } - }) - ).toEqual(false); - }); }); describe('Extract number from string', () => { diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 8e5b58d9f9..0a3ec5ab85 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -238,29 +238,25 @@ export function canOpenHoldingDetail({ } /** - * Checks if a user can use the symbol of a custom asset profile, either to - * connect to the existing asset profile or to create a new one + * 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, - userId + symbol }: { - assetProfile?: Pick | null; + assetProfile: Pick | null; symbol: string; - userId: string; }): boolean { if (!isValidCustomAssetProfileSymbol(symbol)) { return false; } if (assetProfile) { - // Same as in the search: the custom asset profiles of the user and of the - // admin - return !assetProfile.userId || assetProfile.userId === userId; + return true; } - // The prefix is reserved for the asset profiles of the admin return !hasGhostfolioPrefix(symbol); }