Browse Source

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 <noreply@anthropic.com>
pull/7513/head
Varun Jain 4 weeks ago
parent
commit
d00690b2c0
  1. 8
      CHANGELOG.md
  2. 114
      apps/api/src/app/import/import.service.spec.ts
  3. 8
      apps/api/src/app/import/import.service.ts

8
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

114
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<SymbolProfile>[] = [];
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
});
}
});

8
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(

Loading…
Cancel
Save