Browse Source

Fix creation of custom asset profiles with ghostfolio prefix

pull/8070/head
Thomas Kaul 1 week ago
parent
commit
778435dd32
  1. 107
      apps/api/src/app/activities/activities.service.spec.ts
  2. 28
      apps/api/src/app/activities/activities.service.ts
  3. 56
      apps/api/src/app/import/import.service.spec.ts
  4. 12
      apps/api/src/app/import/import.service.ts
  5. 58
      libs/common/src/lib/helper.spec.ts
  6. 27
      libs/common/src/lib/helper.ts

107
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,109 @@ 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.each(['1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b', 'GF_COPX'])(
'creates a custom asset profile with a new UUID if the asset profile of %s belongs to another user',
async (symbol) => {
const assetProfile = await getCreatedAssetProfile({
symbol,
existingAssetProfile: { userId: 'other-user-id' }
});
expect(isUUID(assetProfile.symbol)).toBe(true);
expect(assetProfile.symbol).not.toBe(symbol);
}
);
it.each([
{ owner: 'the admin', userId: null },
{ owner: 'the user', userId: 'user-id' }
])(
'connects to the asset profile of $owner for a symbol with the prefix',
async ({ userId }) => {
const assetProfile = await getCreatedAssetProfile({
existingAssetProfile: { userId },
symbol: 'GF_COPX'
});
expect(assetProfile.symbol).toBe('GF_COPX');
}
);
it('connects to the custom asset profile of a UUID symbol', async () => {
const symbol = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b';
const assetProfile = await getCreatedAssetProfile({ symbol });
expect(assetProfile.symbol).toBe(symbol);
});
async function getCreatedAssetProfile({
existingAssetProfile,
symbol
}: {
existingAssetProfile?: { userId: string | null };
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'
});
return create.mock.calls[0][0].data.SymbolProfile.connectOrCreate.create;
}
});
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 = {

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

@ -34,6 +34,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
canUseCustomAssetProfileSymbol,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getStartOfUtcDateOfTomorrow, getStartOfUtcDateOfTomorrow,
isDraftActivity, isDraftActivity,
@ -220,19 +221,36 @@ 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;
// Look up valid custom asset profile symbols only, since a free text is
// never the symbol of a custom asset profile
const existingAssetProfile = isValidCustomAssetProfileSymbol(
requestedSymbol
)
? await this.prismaService.symbolProfile.findUnique({
select: { userId: true },
where: {
dataSource_symbol: { dataSource, symbol: requestedSymbol }
}
})
: null;
if ( if (
isValidCustomAssetProfileSymbol( canUseCustomAssetProfileSymbol({
data.SymbolProfile.connectOrCreate.create.symbol userId,
) 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();
} }

56
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,6 +28,7 @@ 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;
@ -33,6 +40,7 @@ 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();
@ -77,10 +85,14 @@ describe('ImportService', () => {
{ {
toCurrencyAtDate: jest.fn().mockResolvedValue(0) toCurrencyAtDate: jest.fn().mockResolvedValue(0)
} as unknown as ExchangeRateDataService, } as unknown as ExchangeRateDataService,
{ updateMany: jest.fn() } 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 +287,40 @@ 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 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 +352,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: {

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

@ -26,6 +26,7 @@ import {
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
canUseCustomAssetProfileSymbol,
getAssetProfileIdentifier, getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol, isValidCustomAssetProfileSymbol,
parseDate parseDate
@ -649,8 +650,15 @@ export class ImportService {
'marketData' 'marketData'
); );
// Asset profile belongs to a different user, generate a new symbol // Generate a new symbol if the user cannot use the symbol for a new
if (existingAssetProfile && !isDryRun) { // asset profile, or if the asset profile exists. Unlike in
// createActivity(), this applies to an asset profile of the admin
// as well, since the asset profile of the import has its own data.
if (
(existingAssetProfile ||
!canUseCustomAssetProfileSymbol({ symbol, userId: user.id })) &&
!isDryRun
) {
symbol = randomUUID(); symbol = randomUUID();
} }

58
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,63 @@ describe('Helper', () => {
}); });
}); });
describe('Can use custom asset profile symbol', () => {
const symbol = '7e91b7d4-1430-4212-8380-289a06c9bbc1';
const userId = 'user-id';
it('Free-text symbol', () => {
expect(
canUseCustomAssetProfileSymbol({
userId,
symbol: 'Penthouse Apartment'
})
).toEqual(false);
});
it('New asset profile with UUID', () => {
expect(canUseCustomAssetProfileSymbol({ symbol, userId })).toEqual(true);
});
it('New asset profile with Ghostfolio prefix', () => {
expect(
canUseCustomAssetProfileSymbol({
userId,
symbol: 'GF_PENTHOUSE_APARTMENT'
})
).toEqual(false);
});
it('Asset profile of the admin', () => {
expect(
canUseCustomAssetProfileSymbol({
userId,
assetProfile: { userId: null },
symbol: 'GF_PENTHOUSE_APARTMENT'
})
).toEqual(true);
});
it('Asset profile of the user', () => {
expect(
canUseCustomAssetProfileSymbol({
symbol,
userId,
assetProfile: { userId }
})
).toEqual(true);
});
it('Asset profile of another user', () => {
expect(
canUseCustomAssetProfileSymbol({
symbol,
userId,
assetProfile: { userId: 'other-user-id' }
})
).toEqual(false);
});
});
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);

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

@ -237,6 +237,33 @@ export function canOpenHoldingDetail({
return assetProfile?.assetSubClass !== AssetSubClass.CASH; return assetProfile?.assetSubClass !== AssetSubClass.CASH;
} }
/**
* Checks if a user can use the symbol of a custom asset profile, either to
* connect to the existing asset profile or to create a new one
*/
export function canUseCustomAssetProfileSymbol({
assetProfile,
symbol,
userId
}: {
assetProfile?: Pick<SymbolProfile, 'userId'> | null;
symbol: string;
userId: string;
}): boolean {
if (!isValidCustomAssetProfileSymbol(symbol)) {
return false;
}
if (assetProfile) {
// Same as in the search: the custom asset profiles of the user and of the
// admin
return !assetProfile.userId || assetProfile.userId === userId;
}
// The prefix is reserved for the asset profiles of the admin
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