From db95a457c006a8ac11c207d808de6a16ac328e65 Mon Sep 17 00:00:00 2001 From: Varun Jain Date: Sat, 1 Aug 2026 20:35:24 +0530 Subject: [PATCH] Fix activity import duplicate detection missing real duplicates Treat an omitted comment the same as an explicit null, and match holdings by SymbolProfile.isin in addition to dataSource/symbol so a security imported by ISIN is recognized as a duplicate of the same security re-imported by its ticker symbol. Closes #6057 --- CHANGELOG.md | 1 + .../api/src/app/import/import.service.spec.ts | 135 ++++++++++++++++++ apps/api/src/app/import/import.service.ts | 31 +++- 3 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/app/import/import.service.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d14f77f5e..b4a0d8891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed the duplicate detection during the activity import so it treats an omitted comment the same as null and matches holdings imported by ISIN against holdings imported by ticker symbol - Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Equity) - Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Fixed Income) - Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment) 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 000000000..be100bac8 --- /dev/null +++ b/apps/api/src/app/import/import.service.spec.ts @@ -0,0 +1,135 @@ +import { CreateOrderDto } from '@ghostfolio/common/dtos'; + +import { DataSource, Type } from '@prisma/client'; + +import { ImportService } from './import.service'; + +jest.mock('@ghostfolio/api/app/activities/activities.service', () => { + return { + ActivitiesService: jest.fn().mockImplementation(() => { + return { + getActivities: () => { + return Promise.resolve({ + activities: [ + { + accountId: 'df1c6156-9e17-4434-93c8-6ee4e15c8c1d', + comment: null, + currency: 'USD', + date: new Date('2025-05-09T13:00:28.000Z'), + fee: 0.35074925, + quantity: 2, + type: Type.BUY, + unitPrice: 102.548, + assetProfile: { + currency: 'USD', + dataSource: DataSource.YAHOO, + isin: 'US0079031078', + symbol: 'US0079031078' + } + } + ] + }); + } + }; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/symbol-profile/symbol-profile.service', + () => { + return { + SymbolProfileService: jest.fn().mockImplementation(() => { + return { + getSymbolProfiles: ( + assetProfileIdentifiers: { + dataSource: DataSource; + symbol: string; + }[] + ) => { + return Promise.resolve( + assetProfileIdentifiers + .filter(({ symbol }) => { + return symbol === 'AMD'; + }) + .map(({ dataSource, symbol }) => { + return { dataSource, symbol, isin: 'US0079031078' }; + }) + ); + } + }; + }) + }; + } +); + +describe('ImportService', () => { + let importService: ImportService; + + beforeAll(() => { + importService = new ImportService( + null, + new (jest.requireMock( + '@ghostfolio/api/app/activities/activities.service' + ).ActivitiesService)(), + null, + null, + null, + null, + null, + null, + null, + new (jest.requireMock( + '@ghostfolio/api/services/symbol-profile/symbol-profile.service' + ).SymbolProfileService)(), + null + ); + }); + + function buildActivityDto( + overrides: Partial = {} + ): Partial { + return { + accountId: 'df1c6156-9e17-4434-93c8-6ee4e15c8c1d', + currency: 'USD', + dataSource: DataSource.YAHOO, + date: '2025-05-09T13:00:28.000Z', + fee: 0.35074925, + quantity: 2, + symbol: 'US0079031078', + type: Type.BUY, + unitPrice: 102.548, + ...overrides + }; + } + + it('flags a duplicate when the comment is omitted instead of null', async () => { + const [activity] = await (importService as any).extendActivitiesWithErrors({ + activitiesDto: [buildActivityDto()], + userCurrency: 'USD', + userId: 'da09d1fa-b8e2-40a1-9e5a-decd1cbb63b1' + }); + + expect(activity.error).toEqual({ code: 'IS_DUPLICATE' }); + }); + + it('flags a duplicate when the same holding is imported by ticker symbol instead of ISIN', async () => { + const [activity] = await (importService as any).extendActivitiesWithErrors({ + activitiesDto: [buildActivityDto({ symbol: 'AMD' })], + userCurrency: 'USD', + userId: 'da09d1fa-b8e2-40a1-9e5a-decd1cbb63b1' + }); + + expect(activity.error).toEqual({ code: 'IS_DUPLICATE' }); + }); + + it('does not flag a different holding as a duplicate', async () => { + const [activity] = await (importService as any).extendActivitiesWithErrors({ + activitiesDto: [buildActivityDto({ symbol: 'MSFT' })], + userCurrency: 'USD', + userId: 'da09d1fa-b8e2-40a1-9e5a-decd1cbb63b1' + }); + + expect(activity.error).toBeUndefined(); + }); +}); diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index b706baa8d..1f964f874 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -671,6 +671,18 @@ export class ImportService { withExcludedAccountsAndActivities: true }); + const incomingAssetProfiles = + await this.symbolProfileService.getSymbolProfiles( + uniqBy( + activitiesDto.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }), + ({ dataSource, symbol }) => { + return getAssetProfileIdentifier({ dataSource, symbol }); + } + ) + ); + return activitiesDto.map( ({ accountId, @@ -686,17 +698,30 @@ export class ImportService { unitPrice }) => { const date = parseISO(dateString); + + const incomingIsin = incomingAssetProfiles.find((assetProfile) => { + return ( + assetProfile.dataSource === dataSource && + assetProfile.symbol === symbol + ); + })?.isin; + const isDuplicate = existingActivities.some((activity) => { + const isSameAssetProfile = + (activity.assetProfile.dataSource === dataSource && + activity.assetProfile.symbol === symbol) || + (!!activity.assetProfile.isin && + activity.assetProfile.isin === incomingIsin); + return ( activity.accountId === accountId && - activity.comment === comment && + (activity.comment ?? null) === (comment ?? null) && (activity.currency === currency || activity.assetProfile.currency === currency) && - activity.assetProfile.dataSource === dataSource && + isSameAssetProfile && isSameSecond(activity.date, date) && activity.fee === fee && activity.quantity === quantity && - activity.assetProfile.symbol === symbol && activity.type === type && activity.unitPrice === unitPrice );