Browse Source

Bugfix/creation of custom asset profiles with ghostfolio prefix (#8070)

* Fix creation of custom asset profiles with ghostfolio prefix

* Update changelog
pull/8061/head
Thomas Kaul 10 hours ago
committed by GitHub
parent
commit
effae560ab
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 96
      apps/api/src/app/activities/activities.service.spec.ts
  3. 27
      apps/api/src/app/activities/activities.service.ts
  4. 164
      apps/api/src/app/import/import.service.spec.ts
  5. 21
      apps/api/src/app/import/import.service.ts
  6. 45
      libs/common/src/lib/helper.spec.ts
  7. 23
      libs/common/src/lib/helper.ts

4
CHANGELOG.md

@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Harmonized the colors in the investment chart component - Harmonized the colors in the investment chart component
- Improved the language localization for Chinese (`zh`) - Improved the language localization for Chinese (`zh`)
### Fixed
- Fixed an issue where a user could create a custom asset profile with a symbol of the prefix `GF_`
## 3.82.0 - 2026-10-09 ## 3.82.0 - 2026-10-09
### Added ### Added

96
apps/api/src/app/activities/activities.service.spec.ts

@ -8,6 +8,7 @@ import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
INVESTMENT_ACTIVITY_TYPES, INVESTMENT_ACTIVITY_TYPES,
NON_INVESTMENT_ACTIVITY_TYPES NON_INVESTMENT_ACTIVITY_TYPES
@ -15,12 +16,15 @@ import {
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity, Filter } from '@ghostfolio/common/interfaces'; import { Activity, Filter } from '@ghostfolio/common/interfaces';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { import {
AssetProfileSplit, AssetProfileSplit,
DataSource, DataSource,
Prisma,
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { ActivitiesService } from './activities.service'; import { ActivitiesService } from './activities.service';
@ -49,6 +53,98 @@ describe('ActivitiesService', () => {
); );
}); });
describe('createActivity', () => {
it('creates a custom asset profile with a UUID if no asset profile exists for a symbol with the prefix', async () => {
const assetProfile = await getCreatedAssetProfile({ symbol: 'GF_COPX' });
expect(isUUID(assetProfile.symbol)).toBe(true);
expect(assetProfile.name).toBe('GF_COPX');
});
it('connects to the existing asset profile of a symbol with the prefix', async () => {
const assetProfile = await getCreatedAssetProfile({
existingAssetProfile: { id: 'asset-profile-id' },
symbol: 'GF_COPX'
});
expect(assetProfile.symbol).toBe('GF_COPX');
});
it('creates a custom asset profile with the requested UUID if no asset profile exists', async () => {
const symbol = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b';
const assetProfile = await getCreatedAssetProfile({ symbol });
expect(assetProfile.symbol).toBe(symbol);
});
async function getCreatedAssetProfile({
existingAssetProfile,
symbol
}: {
existingAssetProfile?: { id: string };
symbol: string;
}) {
const create = jest.fn(({ data }: Prisma.OrderCreateArgs) => {
return {
...data,
SymbolProfile: data.SymbolProfile.connectOrCreate.create
};
});
const service = new ActivitiesService(
null,
null,
null,
null,
null,
null,
{ emit: jest.fn() } as unknown as EventEmitter2,
null,
null,
{
order: { create },
symbolProfile: {
findUnique: jest
.fn()
.mockResolvedValue(existingAssetProfile ?? null)
}
} as unknown as PrismaService,
null,
{ validateTagIds: jest.fn() } as unknown as TagService
);
await service.createActivity({
currency: 'USD',
date: parseDate('2024-01-01'),
fee: 0,
quantity: 1,
SymbolProfile: {
connectOrCreate: {
create: { symbol, currency: 'USD', dataSource: DataSource.MANUAL },
where: {
dataSource_symbol: { symbol, dataSource: DataSource.MANUAL }
}
}
},
type: 'BUY',
unitPrice: 100,
user: { connect: { id: 'user-id' } },
userId: 'user-id'
});
const { create: assetProfile, where } =
create.mock.calls[0][0].data.SymbolProfile.connectOrCreate;
expect(where.dataSource_symbol).toEqual({
dataSource: DataSource.MANUAL,
symbol: assetProfile.symbol
});
return assetProfile;
}
});
describe('getActivities', () => { describe('getActivities', () => {
it('returns the activities with the asset profile but without the relation to the symbol profile', async () => { it('returns the activities with the asset profile but without the relation to the symbol profile', async () => {
const assetProfile = { const assetProfile = {

27
apps/api/src/app/activities/activities.service.ts

@ -34,10 +34,11 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
canUseCustomAssetProfileSymbol,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getStartOfUtcDateOfTomorrow, getStartOfUtcDateOfTomorrow,
isDraftActivity, hasGhostfolioPrefix,
isValidCustomAssetProfileSymbol isDraftActivity
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse, ActivitiesResponse,
@ -220,19 +221,31 @@ export class ActivitiesService {
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
const dataSource: DataSource = 'MANUAL'; const dataSource: DataSource = 'MANUAL';
const requestedSymbol = data.SymbolProfile.connectOrCreate.create.symbol;
let name = data.SymbolProfile.connectOrCreate.create.name; let name = data.SymbolProfile.connectOrCreate.create.name;
let symbol: string; let symbol: string;
const existingAssetProfile = hasGhostfolioPrefix(requestedSymbol)
? await this.prismaService.symbolProfile.findUnique({
select: { id: true },
where: {
dataSource_symbol: { dataSource, symbol: requestedSymbol }
}
})
: null;
if ( if (
isValidCustomAssetProfileSymbol( canUseCustomAssetProfileSymbol({
data.SymbolProfile.connectOrCreate.create.symbol assetProfile: existingAssetProfile,
) symbol: requestedSymbol
})
) { ) {
// Connect custom asset profile (clone) // Connect custom asset profile (clone)
symbol = data.SymbolProfile.connectOrCreate.create.symbol; symbol = requestedSymbol;
} else { } else {
// Create custom asset profile // Create custom asset profile
name = name ?? data.SymbolProfile.connectOrCreate.create.symbol; name = name ?? requestedSymbol;
symbol = randomUUID(); symbol = randomUUID();
} }

164
apps/api/src/app/import/import.service.spec.ts

@ -3,10 +3,15 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.ser
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { NON_INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config'; import { NON_INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos'; import {
CreateAssetProfileWithMarketDataDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
Activity, Activity,
@ -15,6 +20,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { isUUID } from 'class-validator';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { ImportService } from './import.service'; import { ImportService } from './import.service';
@ -22,9 +28,11 @@ import { ImportService } from './import.service';
const CUSTOM_ASSET_PROFILE_SYMBOL = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b'; const CUSTOM_ASSET_PROFILE_SYMBOL = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b';
describe('ImportService', () => { describe('ImportService', () => {
let addSymbolProfile: jest.Mock;
let createActivity: jest.Mock; let createActivity: jest.Mock;
let gatherSymbols: jest.Mock; let gatherSymbols: jest.Mock;
let importService: ImportService; let importService: ImportService;
let updateManyMarketData: jest.Mock;
beforeEach(() => { beforeEach(() => {
const configuration = { const configuration = {
@ -33,8 +41,10 @@ describe('ImportService', () => {
MAX_ACTIVITIES_TO_IMPORT: Number.MAX_SAFE_INTEGER MAX_ACTIVITIES_TO_IMPORT: Number.MAX_SAFE_INTEGER
}; };
addSymbolProfile = jest.fn();
createActivity = jest.fn(); createActivity = jest.fn();
gatherSymbols = jest.fn(); gatherSymbols = jest.fn();
updateManyMarketData = jest.fn();
const validateActivities = jest.fn( const validateActivities = jest.fn(
({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => { ({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => {
@ -77,10 +87,14 @@ describe('ImportService', () => {
{ {
toCurrencyAtDate: jest.fn().mockResolvedValue(0) toCurrencyAtDate: jest.fn().mockResolvedValue(0)
} as unknown as ExchangeRateDataService, } as unknown as ExchangeRateDataService,
{ updateMany: updateManyMarketData } as unknown as MarketDataService,
null, null,
null, null,
null, {
null, add: addSymbolProfile,
getCustomSymbolProfilesByNames: jest.fn().mockResolvedValue([]),
getSymbolProfiles: jest.fn().mockResolvedValue([])
} as unknown as SymbolProfileService,
{ {
getTagsForUser: jest.fn().mockResolvedValue([]) getTagsForUser: jest.fn().mockResolvedValue([])
} as unknown as TagService } as unknown as TagService
@ -275,6 +289,146 @@ describe('ImportService', () => {
]); ]);
}); });
it('creates a custom asset profile with a UUID instead of a symbol with the prefix', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-01T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
})
],
assetProfilesWithMarketDataDto: [
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [],
name: 'Global X Copper Miners ETF',
symbol: 'GF_COPX'
}
]
});
const { symbol } = addSymbolProfile.mock.calls[0][0];
expect(isUUID(symbol)).toBe(true);
expect(
createActivity.mock.calls[0][0].SymbolProfile.connectOrCreate.create
).toMatchObject({ symbol, dataSource: DataSource.MANUAL });
});
it('keeps the symbol with the prefix as the name of a custom asset profile without a name', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-01T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
})
],
assetProfilesWithMarketDataDto: [
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [],
symbol: 'GF_COPX'
}
]
});
expect(addSymbolProfile.mock.calls[0][0].name).toBe('GF_COPX');
});
it('merges the market data of asset profiles with the same symbol with the prefix', async () => {
mockCreatedAssetProfiles([
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-01T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
})
],
assetProfilesWithMarketDataDto: [
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [{ date: '2024-01-01', marketPrice: 100 }],
name: 'Global X Copper Miners ETF',
symbol: 'GF_COPX'
},
{
currency: 'USD',
dataSource: DataSource.MANUAL,
marketData: [{ date: '2024-01-02', marketPrice: 101 }],
name: 'Global X Copper Miners ETF',
symbol: 'GF_COPX'
}
]
});
const { symbol } = addSymbolProfile.mock.calls[0][0];
expect(addSymbolProfile).toHaveBeenCalledTimes(1);
expect(updateManyMarketData.mock.calls[0][0].data).toMatchObject([
{ symbol, date: '2024-01-01', marketPrice: 100 },
{ symbol, date: '2024-01-02', marketPrice: 101 }
]);
expect(
createActivity.mock.calls[0][0].SymbolProfile.connectOrCreate.create
).toMatchObject({ symbol, dataSource: DataSource.MANUAL });
});
it('refers the next activities with a symbol with the prefix 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({
dataSource: DataSource.MANUAL,
date: '2024-01-01T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
}),
createActivityDto({
dataSource: DataSource.MANUAL,
date: '2024-01-02T00:00:00.000Z',
symbol: 'GF_COPX',
type: 'BUY'
})
]
});
expect(
createActivity.mock.calls.map(([activity]) => {
return activity.SymbolProfile.connectOrCreate.create;
})
).toMatchObject([
{ dataSource: DataSource.MANUAL, symbol: 'GF_COPX' },
{ dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }
]);
});
it('keeps the asset profiles of the activities in a dry run', async () => { it('keeps the asset profiles of the activities in a dry run', async () => {
const activities = await importActivities({ const activities = await importActivities({
activitiesDto: [ activitiesDto: [
@ -306,16 +460,18 @@ describe('ImportService', () => {
function importActivities({ function importActivities({
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto = [],
isDryRun isDryRun
}: { }: {
activitiesDto: CreateOrderDto[]; activitiesDto: CreateOrderDto[];
assetProfilesWithMarketDataDto?: CreateAssetProfileWithMarketDataDto[];
isDryRun?: boolean; isDryRun?: boolean;
}) { }) {
return importService.import({ return importService.import({
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto,
isDryRun, isDryRun,
accountsWithBalancesDto: [], accountsWithBalancesDto: [],
assetProfilesWithMarketDataDto: [],
platformsDto: [], platformsDto: [],
tagsDto: [], tagsDto: [],
user: { user: {

21
apps/api/src/app/import/import.service.ts

@ -27,6 +27,7 @@ import {
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
hasGhostfolioPrefix,
isValidCustomAssetProfileSymbol, isValidCustomAssetProfileSymbol,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -649,9 +650,23 @@ export class ImportService {
'marketData' 'marketData'
); );
// Asset profile belongs to a different user, generate a new symbol if (
if (existingAssetProfile && !isDryRun) { (existingAssetProfile || hasGhostfolioPrefix(symbol)) &&
symbol = randomUUID(); !isDryRun
) {
const isSymbolOfAssetProfileToCreate = assetProfilesToCreate.some(
({ assetProfile: { symbol: symbolToCreate } }) => {
return symbolToCreate === assetProfileSymbolMapping[symbol];
}
);
if (hasGhostfolioPrefix(symbol)) {
assetProfile.name ??= symbol;
}
symbol = isSymbolOfAssetProfileToCreate
? assetProfileSymbolMapping[symbol]
: randomUUID();
} }
assetProfile.symbol = symbol; assetProfile.symbol = symbol;

45
libs/common/src/lib/helper.spec.ts

@ -4,6 +4,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
canApplyFiltersToAccess, canApplyFiltersToAccess,
canUseCustomAssetProfileSymbol,
extractNumberFromString, extractNumberFromString,
getCountryCodeFromCurrency, getCountryCodeFromCurrency,
getEmojiFlag, getEmojiFlag,
@ -39,6 +40,50 @@ describe('Helper', () => {
}); });
}); });
describe('Can use custom asset profile symbol', () => {
const assetProfile = { id: 'asset-profile-id' };
const symbol = '7e91b7d4-1430-4212-8380-289a06c9bbc1';
it('Free-text symbol', () => {
expect(
canUseCustomAssetProfileSymbol({
assetProfile: null,
symbol: 'Penthouse Apartment'
})
).toEqual(false);
});
it('New asset profile with UUID', () => {
expect(
canUseCustomAssetProfileSymbol({ symbol, assetProfile: null })
).toEqual(true);
});
it('New asset profile with Ghostfolio prefix', () => {
expect(
canUseCustomAssetProfileSymbol({
assetProfile: null,
symbol: 'GF_PENTHOUSE_APARTMENT'
})
).toEqual(false);
});
it('Existing asset profile with UUID', () => {
expect(canUseCustomAssetProfileSymbol({ assetProfile, symbol })).toEqual(
true
);
});
it('Existing asset profile with Ghostfolio prefix', () => {
expect(
canUseCustomAssetProfileSymbol({
assetProfile,
symbol: 'GF_PENTHOUSE_APARTMENT'
})
).toEqual(true);
});
});
describe('Extract number from string', () => { describe('Extract number from string', () => {
it('Get decimal number', () => { it('Get decimal number', () => {
expect(extractNumberFromString({ value: '999.99' })).toEqual(999.99); expect(extractNumberFromString({ value: '999.99' })).toEqual(999.99);

23
libs/common/src/lib/helper.ts

@ -237,6 +237,29 @@ export function canOpenHoldingDetail({
return assetProfile?.assetSubClass !== AssetSubClass.CASH; return assetProfile?.assetSubClass !== AssetSubClass.CASH;
} }
/**
* Checks if the symbol of a custom asset profile can be used, either to connect
* to the existing asset profile or to create a new one. The prefix is reserved
* for the asset profiles of the admin, thus a new asset profile cannot use it.
*/
export function canUseCustomAssetProfileSymbol({
assetProfile,
symbol
}: {
assetProfile: Pick<SymbolProfile, 'id'> | null;
symbol: string;
}): boolean {
if (!isValidCustomAssetProfileSymbol(symbol)) {
return false;
}
if (assetProfile) {
return true;
}
return !hasGhostfolioPrefix(symbol);
}
export function capitalize(aString: string) { export function capitalize(aString: string) {
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase(); return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
} }

Loading…
Cancel
Save