From d00690b2c043cc532d7ed52de4e1b303c71ad0e0 Mon Sep 17 00:00:00 2001 From: Varun Jain Date: Tue, 4 Aug 2026 10:24:53 +0530 Subject: [PATCH] Reuse existing shared manual asset profile on activity import An asset profile created via the admin control panel has no user (admin.service.ts addAssetProfile), so the ownership check in ImportService.import() treated it as belonging to a different user and cloned it under a random UUID. Activities were attached to the clone while the original asset profile stayed empty. Only clone an existing asset profile when it belongs to a different user, which aligns the asset profile loop with the activity creation that connects on dataSource and symbol without an ownership check. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 +- .../api/src/app/import/import.service.spec.ts | 114 +++++++++++++++++- apps/api/src/app/import/import.service.ts | 8 +- 3 files changed, 122 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f00f06609e..d9d0b207f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- Fixed the activity import so it no longer fails when multiple rows create the same new manual asset profile +- Fixed the activity import to reuse an existing manual asset profile of the admin control panel instead of duplicating it + ## 3.41.0 - 2026-08-03 ### Added @@ -36,7 +43,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fixed the activity import so it no longer fails when multiple rows create the same new manual asset profile - Fixed the handling of the _Exclude from Analysis_ tag in the activities table - Fixed the persistence of an empty comment in the create or update account dialog - Resolved a validation error caused by empty strings in the asset profile details dialog of the admin control panel diff --git a/apps/api/src/app/import/import.service.spec.ts b/apps/api/src/app/import/import.service.spec.ts index 1d939c5822..5bf077045d 100644 --- a/apps/api/src/app/import/import.service.spec.ts +++ b/apps/api/src/app/import/import.service.spec.ts @@ -8,10 +8,12 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/sy import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { UserWithSettings } from '@ghostfolio/common/types'; -import { DataSource } from '@prisma/client'; +import { DataSource, SymbolProfile } from '@prisma/client'; import { ImportService } from './import.service'; +let mockExistingAssetProfiles: Partial[] = []; + jest.mock('@ghostfolio/api/app/account/account.service', () => { return { AccountService: jest.fn().mockImplementation(() => { @@ -27,12 +29,14 @@ jest.mock('@ghostfolio/api/app/activities/activities.service', () => { ActivitiesService: jest.fn().mockImplementation(() => { return { getActivities: () => Promise.resolve({ activities: [] }), - createActivity: () => { + createActivity: jest.fn().mockImplementation((data) => { return Promise.resolve({ id: 'ee3949fa-9df5-4b4e-9856-14dd1cfe9c86', - SymbolProfile: { symbol: 'Repeated Fee' } + SymbolProfile: { + symbol: data.SymbolProfile.connectOrCreate.create.symbol + } }); - } + }) }; }) }; @@ -95,7 +99,7 @@ jest.mock( SymbolProfileService: jest.fn().mockImplementation(() => { return { add: jest.fn().mockResolvedValue(undefined), - getSymbolProfiles: () => Promise.resolve([]) + getSymbolProfiles: () => Promise.resolve(mockExistingAssetProfiles) }; }) }; @@ -124,6 +128,8 @@ describe('ImportService', () => { let tagService: TagService; beforeEach(() => { + mockExistingAssetProfiles = []; + accountService = new AccountService(null, null, null, null, null); activitiesService = new ActivitiesService( null, @@ -223,10 +229,108 @@ describe('ImportService', () => { ], assetProfilesWithMarketDataDto: [assetProfile, assetProfile], maxActivitiesToImport: 10, + platformsDto: [], tagsDto: [], user }); expect(symbolProfileService.add).toHaveBeenCalledTimes(1); }); + + it('reuses an existing manual asset profile without a user', async () => { + mockExistingAssetProfiles = [ + { + currency: 'USD', + dataSource: DataSource.MANUAL, + name: 'Manual Asset Profile', + symbol: 'GF_MANUAL', + userId: null + } + ]; + + await importActivitiesWithExistingAssetProfile(); + + expect(symbolProfileService.add).not.toHaveBeenCalled(); + + for (const [{ SymbolProfile }] of ( + activitiesService.createActivity as jest.Mock + ).mock.calls) { + expect(SymbolProfile.connectOrCreate.create.symbol).toEqual('GF_MANUAL'); + } + }); + + it('creates a new asset profile when the existing manual asset profile belongs to a different user', async () => { + mockExistingAssetProfiles = [ + { + currency: 'USD', + dataSource: DataSource.MANUAL, + name: 'Manual Asset Profile', + symbol: 'GF_MANUAL', + userId: '5b7a1b3a-1f1c-4c7a-9a1a-3a1b5b7a1b3a' + } + ]; + + await importActivitiesWithExistingAssetProfile(); + + expect(symbolProfileService.add).toHaveBeenCalledTimes(1); + + const [[{ symbol }]] = (symbolProfileService.add as jest.Mock).mock.calls; + + expect(symbol).not.toEqual('GF_MANUAL'); + + for (const [{ SymbolProfile }] of ( + activitiesService.createActivity as jest.Mock + ).mock.calls) { + expect(SymbolProfile.connectOrCreate.create.symbol).toEqual(symbol); + } + }); + + function importActivitiesWithExistingAssetProfile() { + const user = { + id: 'da8a5786-1223-4a51-9a86-2b60433c9f3f', + permissions: [], + settings: { settings: { baseCurrency: 'USD' } } + } as unknown as UserWithSettings; + + // The client creates a synthetic asset profile per activity + const assetProfile = { + currency: 'USD', + dataSource: DataSource.MANUAL, + isActive: true, + marketData: [], + name: 'GF_MANUAL', + symbol: 'GF_MANUAL' + }; + + return importService.import({ + accountsWithBalancesDto: [], + activitiesDto: [ + { + currency: 'USD', + dataSource: DataSource.MANUAL, + date: '2024-01-01T00:00:00.000Z', + fee: 0, + quantity: 1, + symbol: 'GF_MANUAL', + type: 'BUY', + unitPrice: 1 + }, + { + currency: 'USD', + dataSource: DataSource.MANUAL, + date: '2024-01-02T00:00:00.000Z', + fee: 0, + quantity: 2, + symbol: 'GF_MANUAL', + type: 'BUY', + unitPrice: 1 + } + ], + assetProfilesWithMarketDataDto: [assetProfile, assetProfile], + maxActivitiesToImport: 10, + platformsDto: [], + tagsDto: [], + user + }); + } }); diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 9610317129..58cbd1bf70 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -413,9 +413,13 @@ export class ImportService { // If there is no asset profile or if the asset profile belongs to a // different user, then create a new asset profile, unless it has // already been created earlier in this loop (e.g. multiple imported - // activities referencing the same new manual asset profile) + // activities referencing the same new manual asset profile). An asset + // profile without a user is shared, for example created via the admin + // control panel, and is reused as is if ( - (!existingAssetProfile || existingAssetProfile.userId !== user.id) && + (!existingAssetProfile || + (existingAssetProfile.userId && + existingAssetProfile.userId !== user.id)) && !createdAssetProfileIdentifiers.has(assetProfileIdentifier) ) { const assetProfile: CreateAssetProfileDto = omit(