diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 344ba87704..de20606c56 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -198,6 +198,20 @@ export class ImportService { const tagIdMapping: { [oldTagId: string]: string } = {}; const userCurrency = user.settings.settings.baseCurrency; + // Validate the symbols before any data is persisted + for (const [index, assetProfileWithMarketData] of ( + assetProfilesWithMarketDataDto ?? [] + ).entries()) { + if ( + assetProfileWithMarketData.dataSource === DataSource.MANUAL && + !isValidCustomAssetProfileSymbol(assetProfileWithMarketData.symbol) + ) { + throw new Error( + `assetProfiles.${index}.symbol ("${assetProfileWithMarketData.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` + ); + } + } + if (platformsDto?.length) { const canCreatePlatform = hasPermission( user.permissions, @@ -236,6 +250,26 @@ export class ImportService { } } + for (const [index, activity] of activitiesDto.entries()) { + if (!activity.dataSource) { + if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { + activity.dataSource = DataSource.MANUAL; + } else { + activity.dataSource = + this.dataProviderService.getDataSourceForImport(); + } + } + + if ( + activity.dataSource === DataSource.MANUAL && + !isValidCustomAssetProfileSymbol(activity.symbol) + ) { + throw new Error( + `activities.${index}.symbol ("${activity.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` + ); + } + } + const existingTagsOfUser = tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length) ? await this.tagService.getTagsForUser(user.id) @@ -386,93 +420,119 @@ export class ImportService { } } - for (const [index, assetProfileWithMarketData] of ( - assetProfilesWithMarketDataDto ?? [] - ).entries()) { - if ( - assetProfileWithMarketData.dataSource === DataSource.MANUAL && - !isValidCustomAssetProfileSymbol(assetProfileWithMarketData.symbol) - ) { - throw new Error( - `assetProfiles.${index}.symbol ("${assetProfileWithMarketData.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` - ); - } - } + if (assetProfilesWithMarketDataDto?.length) { + const customAssetProfileNames = assetProfilesWithMarketDataDto + .filter(({ dataSource, name }) => { + return dataSource === DataSource.MANUAL && Boolean(name); + }) + .map(({ name }) => { + return name; + }); - if (!isDryRun && assetProfilesWithMarketDataDto?.length) { - const existingAssetProfiles = - await this.symbolProfileService.getSymbolProfiles( - assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => { - return { dataSource, symbol }; + const [existingAssetProfiles, existingCustomAssetProfilesOfUser] = + await Promise.all([ + this.symbolProfileService.getSymbolProfiles( + assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }) + ), + this.symbolProfileService.getCustomSymbolProfilesByNames({ + names: customAssetProfileNames, + userId: user.id }) - ); + ]); for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { + let symbol = assetProfileWithMarketData.symbol; + // Check if there is any existing asset profile const existingAssetProfile = existingAssetProfiles.find( - ({ dataSource, symbol }) => { + (assetProfile) => { return ( - dataSource === assetProfileWithMarketData.dataSource && - symbol === assetProfileWithMarketData.symbol + assetProfile.dataSource === + assetProfileWithMarketData.dataSource && + assetProfile.symbol === assetProfileWithMarketData.symbol ); } ); - // If there is no asset profile or if the asset profile belongs to a different user, then create a new asset profile + // If there is no asset profile or if the asset profile belongs to a different user, then reuse the custom asset profile of the user or create a new asset profile if (!existingAssetProfile || existingAssetProfile.userId !== user.id) { - const assetProfile: CreateAssetProfileDto = omit( - assetProfileWithMarketData, - 'marketData' - ); + // Check if the user has a custom asset profile with the same name + const existingCustomAssetProfileOfUser = + assetProfileWithMarketData.dataSource === DataSource.MANUAL + ? existingCustomAssetProfilesOfUser.find(({ name }) => { + return name === assetProfileWithMarketData.name; + }) + : undefined; + + if (existingCustomAssetProfileOfUser) { + // Reuse the custom asset profile of the user instead of creating a duplicate + symbol = existingCustomAssetProfileOfUser.symbol; + } else { + const assetProfile: CreateAssetProfileDto = omit( + assetProfileWithMarketData, + 'marketData' + ); + + // Asset profile belongs to a different user + if (existingAssetProfile) { + symbol = randomUUID(); + } - // Asset profile belongs to a different user - if (existingAssetProfile) { - const symbol = randomUUID(); - assetProfileSymbolMapping[assetProfile.symbol] = symbol; assetProfile.symbol = symbol; - } - // Create a new asset profile - const assetProfileObject: Prisma.SymbolProfileCreateInput = { - ...assetProfile, - user: { connect: { id: user.id } } - }; + if (!isDryRun) { + // Create a new asset profile + const assetProfileObject: Prisma.SymbolProfileCreateInput = { + ...assetProfile, + user: { connect: { id: user.id } } + }; - await this.symbolProfileService.add(assetProfileObject); - } + await this.symbolProfileService.add(assetProfileObject); + } - // Insert or update market data - const marketDataObjects = assetProfileWithMarketData.marketData.map( - (marketData) => { - return { - ...marketData, - dataSource: assetProfileWithMarketData.dataSource, - symbol: assetProfileWithMarketData.symbol - } as Prisma.MarketDataUpdateInput; + if ( + assetProfile.dataSource === DataSource.MANUAL && + Boolean(assetProfile.name) + ) { + existingCustomAssetProfilesOfUser.push({ + name: assetProfile.name, + symbol: assetProfile.symbol + }); + } } - ); - await this.marketDataService.updateMany({ data: marketDataObjects }); - } - } + if (symbol !== assetProfileWithMarketData.symbol) { + assetProfileSymbolMapping[assetProfileWithMarketData.symbol] = + symbol; - for (const [index, activity] of activitiesDto.entries()) { - if (!activity.dataSource) { - if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { - activity.dataSource = DataSource.MANUAL; - } else { - activity.dataSource = - this.dataProviderService.getDataSourceForImport(); + // Keep the asset profile in sync with the activities to validate + assetProfileWithMarketData.symbol = symbol; + } + } + + if (!isDryRun) { + // Insert or update market data + const marketDataObjects = assetProfileWithMarketData.marketData.map( + (marketData) => { + return { + ...marketData, + symbol, + dataSource: assetProfileWithMarketData.dataSource + } as Prisma.MarketDataUpdateInput; + } + ); + + await this.marketDataService.updateMany({ data: marketDataObjects }); } } + } - if ( - activity.dataSource === DataSource.MANUAL && - !isValidCustomAssetProfileSymbol(activity.symbol) - ) { - throw new Error( - `activities.${index}.symbol ("${activity.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` - ); + for (const activity of activitiesDto) { + // If an asset profile is created or reused, then update the symbol in all activities + if (assetProfileSymbolMapping[activity.symbol]) { + activity.symbol = assetProfileSymbolMapping[activity.symbol]; } if (!isDryRun) { @@ -481,11 +541,6 @@ export class ImportService { activity.accountId = accountIdMapping[activity.accountId]; } - // If a new asset profile is created, then update the symbol in all activities - if (assetProfileSymbolMapping[activity.symbol]) { - activity.symbol = assetProfileSymbolMapping[activity.symbol]; - } - // If a new tag is created, then update the tag ID in all activities activity.tags = (activity.tags ?? []).map((tagId) => { return tagIdMapping[tagId] ?? tagId; diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 9f6c78357f..1b29ed5c41 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -268,6 +268,15 @@ export class DataProviderService implements OnModuleInit { }); if (!assetProfiles[assetProfileIdentifier]) { + if ( + dataSource === DataSource.MANUAL && + !isValidCustomAssetProfileSymbol(symbol) + ) { + throw new Error( + `${activityPath}.symbol ("${symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` + ); + } + if ( (dataSource === DataSource.MANUAL && type === 'BUY') || ['FEE', 'INTEREST', 'LIABILITY'].includes(type) @@ -291,15 +300,6 @@ export class DataProviderService implements OnModuleInit { continue; } - if ( - dataSource === DataSource.MANUAL && - !isValidCustomAssetProfileSymbol(symbol) - ) { - throw new Error( - `${activityPath}.symbol ("${symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")` - ); - } - let assetProfile: Partial = { currency }; try { diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index 1854a9f933..ebc8a94c76 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -105,6 +105,27 @@ export class SymbolProfileService { }; } + public async getCustomSymbolProfilesByNames({ + names, + userId + }: { + names: string[]; + userId: string; + }): Promise[]> { + if (names.length === 0) { + return []; + } + + return this.prismaService.symbolProfile.findMany({ + select: { name: true, symbol: true }, + where: { + userId, + dataSource: DataSource.MANUAL, + name: { in: names } + } + }); + } + public async getSymbolProfiles( aAssetProfileIdentifiers: AssetProfileIdentifier[] ): Promise {