From 96d56dfb2dc4b7e437047f857139c7b2d3a3a35e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:11:04 +0200 Subject: [PATCH] Bugfix/unused custom asset profiles created by activities import (#7673) * Fix unused custom asset profiles created by activities import * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/import/import.service.ts | 90 +++++++++++++++++-- .../asset-profile-to-create.interface.ts | 6 ++ .../data-provider/data-provider.service.ts | 44 ++++----- 4 files changed, 109 insertions(+), 32 deletions(-) create mode 100644 apps/api/src/app/import/interfaces/asset-profile-to-create.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 11daee2ab..9756be7e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed the _Storybook_ setup by loading the `@angular/localize` polyfill centrally +- Fixed an issue in the activities import where an unused custom asset profile was created if the related activities were not imported ## 3.57.0 - 2026-08-21 diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 26162b9e4..47935c9a7 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -48,6 +48,7 @@ import { omit, uniqBy } from 'lodash'; import { randomUUID } from 'node:crypto'; import { ImportDataDto } from './import-data.dto'; +import { AssetProfileToCreate } from './interfaces/asset-profile-to-create.interface'; @Injectable() export class ImportService { @@ -534,6 +535,8 @@ export class ImportService { } } + const assetProfilesToCreate: AssetProfileToCreate[] = []; + if (assetProfilesWithMarketDataDto?.length) { const customAssetProfileNames = assetProfilesWithMarketDataDto .filter(({ dataSource, name }) => { @@ -557,6 +560,7 @@ export class ImportService { ]); for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { + let assetProfileToCreate: Prisma.SymbolProfileCreateInput; let symbol = assetProfileWithMarketData.symbol; // Check if there is any existing asset profile @@ -605,13 +609,10 @@ export class ImportService { assetProfile.symbol = symbol; if (!isDryRun) { - // Create a new asset profile - const assetProfileObject: Prisma.SymbolProfileCreateInput = { + assetProfileToCreate = { ...assetProfile, user: { connect: { id: user.id } } }; - - await this.symbolProfileService.add(assetProfileObject); } } @@ -625,7 +626,6 @@ export class ImportService { } if (!isDryRun) { - // Insert or update market data const marketDataObjects = ( assetProfileWithMarketData.marketData ?? [] ).map((marketData) => { @@ -636,7 +636,40 @@ export class ImportService { } as Prisma.MarketDataUpdateInput; }); - await this.marketDataService.updateMany({ data: marketDataObjects }); + if (assetProfileToCreate) { + const assetProfileToCreateIdentifier = + getAssetProfileIdentifier(assetProfileToCreate); + + const duplicateAssetProfileToCreate = assetProfilesToCreate.find( + ({ assetProfile }) => { + return ( + getAssetProfileIdentifier(assetProfile) === + assetProfileToCreateIdentifier + ); + } + ); + + if (duplicateAssetProfileToCreate) { + // The import contains the same asset profile more than once, + // which would fail with a unique constraint violation. Keep the + // first asset profile and merge the market data into it. + duplicateAssetProfileToCreate.marketDataObjects.push( + ...marketDataObjects + ); + } else { + // Create the new asset profile and its market data later, once it + // is known which activities are imported + assetProfilesToCreate.push({ + marketDataObjects, + assetProfile: assetProfileToCreate + }); + } + } else { + // Insert or update market data + await this.marketDataService.updateMany({ + data: marketDataObjects + }); + } } } } @@ -719,6 +752,25 @@ export class ImportService { return id === TAG_ID_DRAFT; }) ?? { id: TAG_ID_DRAFT, name: 'DRAFT' }; + // Create the new asset profiles of the activities to import only, so that + // no unused asset profile remains, for example if no activity refers to + // the asset profile. An asset profile which is created before the + // validation of the activities would stay behind, because the import is + // not rolled back on an error. + if (!isDryRun) { + for (const { + assetProfile, + marketDataObjects + } of this.getAssetProfilesToCreate({ + activities: activitiesExtendedWithErrors, + assetProfiles: assetProfilesToCreate + })) { + await this.symbolProfileService.add(assetProfile); + + await this.marketDataService.updateMany({ data: marketDataObjects }); + } + } + const activities: Activity[] = []; for (const activity of activitiesExtendedWithErrors) { @@ -934,7 +986,7 @@ export class ImportService { activitiesDto: Partial[]; userCurrency: string; userId: string; - }): Promise[]> { + }): Promise<(Partial & Pick)[]> { const { activities: existingActivities } = await this.activitiesService.getActivities({ userCurrency, @@ -1055,6 +1107,30 @@ export class ImportService { return matchingAccountsOfUser[0]; } + private getAssetProfilesToCreate({ + activities, + assetProfiles + }: { + activities: Pick[]; + assetProfiles: AssetProfileToCreate[]; + }) { + const assetProfileIdentifiersToImport = new Set( + activities + .filter(({ error }) => { + return !error; + }) + .map(({ assetProfile }) => { + return getAssetProfileIdentifier(assetProfile); + }) + ); + + return assetProfiles.filter(({ assetProfile }) => { + return assetProfileIdentifiersToImport.has( + getAssetProfileIdentifier(assetProfile) + ); + }); + } + private isUniqueAccount(accounts: AccountWithValue[]) { const uniqueAccountIds = new Set(); diff --git a/apps/api/src/app/import/interfaces/asset-profile-to-create.interface.ts b/apps/api/src/app/import/interfaces/asset-profile-to-create.interface.ts new file mode 100644 index 000000000..2787b7eb1 --- /dev/null +++ b/apps/api/src/app/import/interfaces/asset-profile-to-create.interface.ts @@ -0,0 +1,6 @@ +import { Prisma } from '@prisma/client'; + +export interface AssetProfileToCreate { + assetProfile: Prisma.SymbolProfileCreateInput; + marketDataObjects: Prisma.MarketDataUpdateInput[]; +} 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 e999bd595..85b4a068f 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -39,7 +39,7 @@ import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client'; import { Big } from 'big.js'; import { eachDayOfInterval, format, isValid } from 'date-fns'; -import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash'; +import { groupBy, isEmpty, isNumber, omit, uniqWith } from 'lodash'; import ms from 'ms'; import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error'; @@ -272,23 +272,31 @@ export class DataProviderService implements OnModuleInit { }); if (!assetProfiles[assetProfileIdentifier]) { + const assetProfileInImport = assetProfilesWithMarketDataDto?.find( + (assetProfileWithMarketData) => { + return ( + assetProfileWithMarketData.dataSource === dataSource && + assetProfileWithMarketData.symbol === symbol + ); + } + ); + + // A custom asset profile of the import is created after the + // validation, thus the data provider cannot resolve it yet if ( (dataSource === DataSource.MANUAL && type === 'BUY') || + assetProfileInImport?.dataSource === DataSource.MANUAL || NON_INVESTMENT_ACTIVITY_TYPES.includes(type) ) { - const assetProfileInImport = assetProfilesWithMarketDataDto?.find( - (assetProfile) => { - return ( - assetProfile.dataSource === dataSource && - assetProfile.symbol === symbol - ); - } - ); - assetProfiles[assetProfileIdentifier] = { - currency, + ...omit(assetProfileInImport ?? {}, [ + 'dataSource', + 'marketData', + 'symbol' + ]), dataSource, symbol, + currency: assetProfileInImport?.currency ?? currency, name: assetProfileInImport?.name ?? symbol }; @@ -308,20 +316,6 @@ export class DataProviderService implements OnModuleInit { )?.[assetProfileIdentifier]; } catch {} - if (!assetProfile?.name) { - const assetProfileInImport = assetProfilesWithMarketDataDto?.find( - (profile) => { - return ( - profile.dataSource === dataSource && profile.symbol === symbol - ); - } - ); - - if (assetProfileInImport) { - Object.assign(assetProfile, assetProfileInImport); - } - } - if (!assetProfile?.name) { throw new Error( `${activityPath}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")`