diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa48dcce6..b39f92677a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.3` to `14.0` - Upgraded `nestjs` from version `11.2.3` to `11.2.6` +### Fixed + +- Fixed the asset profile of the activities after a fee, an interest or a liability with the same symbol in the activities import + ## 3.78.0 - 2026-10-03 ### Added diff --git a/apps/api/src/app/import/import.service.spec.ts b/apps/api/src/app/import/import.service.spec.ts new file mode 100644 index 0000000000..afabdbcb1f --- /dev/null +++ b/apps/api/src/app/import/import.service.spec.ts @@ -0,0 +1,365 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.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 { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { + Activity, + AssetProfileIdentifier +} from '@ghostfolio/common/interfaces'; +import { UserWithSettings } from '@ghostfolio/common/types'; + +import { DataSource, SymbolProfile } from '@prisma/client'; +import { parseISO } from 'date-fns'; + +import { ImportService } from './import.service'; + +const CUSTOM_ASSET_PROFILE_SYMBOL = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b'; + +describe('ImportService', () => { + let createActivity: jest.Mock; + let gatherSymbols: jest.Mock; + let importService: ImportService; + + beforeEach(() => { + const configuration = { + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [], + ENABLE_FEATURE_SUBSCRIPTION: false, + MAX_ACTIVITIES_TO_IMPORT: Number.MAX_SAFE_INTEGER + }; + + createActivity = jest.fn(); + gatherSymbols = jest.fn(); + + const validateActivities = jest.fn( + ({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => { + const assetProfiles: { + [assetProfileIdentifier: string]: Partial; + } = {}; + + for (const { currency, dataSource, symbol } of activitiesDto) { + assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] ??= { + currency, + dataSource, + symbol, + name: symbol + }; + } + + return assetProfiles; + } + ); + + importService = new ImportService( + { + getAccounts: jest.fn().mockResolvedValue([]) + } as unknown as AccountService, + { + createActivity, + getActivities: jest.fn().mockResolvedValue({ activities: [], count: 0 }) + } as unknown as ActivitiesService, + null, + { + get: (key: keyof typeof configuration) => { + return configuration[key]; + } + } as unknown as ConfigurationService, + { gatherSymbols } as unknown as DataGatheringService, + { + validateActivities, + getDataSourceForImport: jest.fn().mockReturnValue(DataSource.MANUAL) + } as unknown as DataProviderService, + { + toCurrencyAtDate: jest.fn().mockResolvedValue(0) + } as unknown as ExchangeRateDataService, + null, + null, + null, + null, + { + getTagsForUser: jest.fn().mockResolvedValue([]) + } as unknown as TagService + ); + }); + + describe('import', () => { + it('keeps the asset profile of an investment activity after a non-investment activity', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + ]); + + const activities = await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL', + type: 'FEE' + }), + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-02T00:00:00.000Z', + symbol: 'AAPL', + type: 'BUY' + }) + ] + }); + + expect( + createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }); + + expect(getAssetProfileIdentifiers(activities)).toEqual([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + ]); + + expect(gatherSymbols.mock.calls[0][0].dataGatheringItems).toEqual([ + { + dataSource: DataSource.MANUAL, + date: parseISO('2024-01-01T00:00:00.000Z'), + symbol: CUSTOM_ASSET_PROFILE_SYMBOL + }, + { + dataSource: DataSource.YAHOO, + date: parseISO('2024-01-02T00:00:00.000Z'), + symbol: 'AAPL' + } + ]); + }); + + it('shows the custom asset profile of a non-investment activity after an investment activity', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + const activities = await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL', + type: 'BUY' + }), + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-02T00:00:00.000Z', + symbol: 'AAPL', + type: 'FEE' + }) + ] + }); + + expect( + createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }); + + expect(getAssetProfileIdentifiers(activities)).toEqual([ + { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + }); + + it.each(NON_INVESTMENT_ACTIVITY_TYPES)( + 'refers the next %s activities of a data provider to the custom asset profile which createActivity() has created', + async (type) => { + mockCreatedAssetProfiles([ + { + dataSource: DataSource.MANUAL, + symbol: CUSTOM_ASSET_PROFILE_SYMBOL + }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + type, + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL' + }), + createActivityDto({ + type, + dataSource: DataSource.YAHOO, + date: '2024-01-02T00:00:00.000Z', + symbol: 'AAPL' + }) + ] + }); + + expect( + createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ symbol: CUSTOM_ASSET_PROFILE_SYMBOL }); + } + ); + + it('refers the next activities without a data source 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({ + date: '2024-01-01T00:00:00.000Z', + symbol: 'Broker fee', + type: 'FEE' + }), + createActivityDto({ + date: '2024-01-02T00:00:00.000Z', + symbol: 'Broker fee', + type: 'FEE' + }) + ] + }); + + expect( + createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ + dataSource: DataSource.MANUAL, + symbol: CUSTOM_ASSET_PROFILE_SYMBOL + }); + }); + + it('refers the next investment activities without a data source 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 }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + date: '2024-01-01T00:00:00.000Z', + symbol: 'Gold', + type: 'BUY' + }), + createActivityDto({ + date: '2024-01-02T00:00:00.000Z', + symbol: 'Gold', + type: 'BUY' + }), + createActivityDto({ + date: '2024-01-03T00:00:00.000Z', + symbol: 'Gold', + type: 'SELL' + }) + ] + }); + + expect( + createActivity.mock.calls.slice(1).map(([activity]) => { + return activity.SymbolProfile.connectOrCreate.create; + }) + ).toMatchObject([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + expect(gatherSymbols.mock.calls[0][0].dataGatheringItems).toEqual([ + { + dataSource: DataSource.MANUAL, + date: parseISO('2024-01-01T00:00:00.000Z'), + symbol: CUSTOM_ASSET_PROFILE_SYMBOL + } + ]); + }); + + it('keeps the asset profiles of the activities in a dry run', async () => { + const activities = await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL', + type: 'FEE' + }), + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-02T00:00:00.000Z', + symbol: 'AAPL', + type: 'BUY' + }) + ], + isDryRun: true + }); + + expect(createActivity).not.toHaveBeenCalled(); + expect(gatherSymbols).not.toHaveBeenCalled(); + + expect(getAssetProfileIdentifiers(activities)).toEqual([ + { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + ]); + }); + }); + + function importActivities({ + activitiesDto, + isDryRun + }: { + activitiesDto: CreateOrderDto[]; + isDryRun?: boolean; + }) { + return importService.import({ + activitiesDto, + isDryRun, + accountsWithBalancesDto: [], + assetProfilesWithMarketDataDto: [], + platformsDto: [], + tagsDto: [], + user: { + id: 'user-id', + permissions: [], + settings: { settings: { baseCurrency: 'USD' } } + } as unknown as UserWithSettings + }); + } + + function mockCreatedAssetProfiles(assetProfiles: AssetProfileIdentifier[]) { + for (const assetProfile of assetProfiles) { + createActivity.mockImplementationOnce( + ({ + date, + type + }: Parameters[0]) => { + return { date, type, SymbolProfile: assetProfile }; + } + ); + } + } +}); + +function createActivityDto({ + dataSource, + date, + symbol, + type +}: Pick) { + return { + dataSource, + date, + symbol, + type, + currency: 'USD', + fee: 0, + quantity: 1, + unitPrice: 100 + } as CreateOrderDto; +} + +function getAssetProfileIdentifiers(activities: Activity[]) { + return activities.map(({ assetProfile: { dataSource, symbol } }) => { + return { dataSource, symbol }; + }); +} diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 8a2c80e2c3..80637ea4d6 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -836,6 +836,10 @@ export class ImportService { const activities: Activity[] = []; + const customAssetProfileSymbols: { + [assetProfileIdentifier: string]: string; + } = {}; + for (const activity of activitiesExtendedWithErrors) { const accountId = activity.accountId; const comment = activity.comment; @@ -848,12 +852,12 @@ export class ImportService { const type = activity.type; const unitPrice = activity.unitPrice; - const assetProfile = assetProfiles[ - getAssetProfileIdentifier({ - dataSource: activity.assetProfile.dataSource, - symbol: activity.assetProfile.symbol - }) - ] ?? { + const assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource: activity.assetProfile.dataSource, + symbol: activity.assetProfile.symbol + }); + + let assetProfile = assetProfiles[assetProfileIdentifier] ?? { dataSource: activity.assetProfile.dataSource, symbol: activity.assetProfile.symbol }; @@ -904,7 +908,14 @@ export class ImportService { continue; } - const { dataSource, name, symbol } = assetProfile; + const customAssetProfileSymbol = + NON_INVESTMENT_ACTIVITY_TYPES.includes(type) || + assetProfile.dataSource === DataSource.MANUAL + ? customAssetProfileSymbols[assetProfileIdentifier] + : undefined; + + const { dataSource, name } = assetProfile; + const symbol = customAssetProfileSymbol ?? assetProfile.symbol; const createdActivity = await this.activitiesService.createActivity({ comment, @@ -940,9 +951,15 @@ export class ImportService { userId: user.id }); - if (createdActivity.SymbolProfile?.symbol) { - // Update symbol that may have been assigned in createOrder() - assetProfile.symbol = createdActivity.SymbolProfile.symbol; + if (createdActivity.SymbolProfile.dataSource === DataSource.MANUAL) { + customAssetProfileSymbols[assetProfileIdentifier] = + createdActivity.SymbolProfile.symbol; + + assetProfile = { + ...assetProfile, + dataSource: DataSource.MANUAL, + symbol: createdActivity.SymbolProfile.symbol + }; } order = createdActivity;