Browse Source

Bugfix/asset profile of imported activities (#8024)

* Fix asset profile of imported activities

* Update changelog
pull/8035/head
Thomas Kaul 6 days ago
committed by GitHub
parent
commit
903eee2405
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 365
      apps/api/src/app/import/import.service.spec.ts
  3. 33
      apps/api/src/app/import/import.service.ts

4
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

365
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<SymbolProfile>;
} = {};
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<ActivitiesService['createActivity']>[0]) => {
return { date, type, SymbolProfile: assetProfile };
}
);
}
}
});
function createActivityDto({
dataSource,
date,
symbol,
type
}: Pick<CreateOrderDto, 'dataSource' | 'date' | 'symbol' | 'type'>) {
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 };
});
}

33
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({
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;

Loading…
Cancel
Save