Browse Source

Bugfix/unused custom asset profiles created by activities import (#7673)

* Fix unused custom asset profiles created by activities import

* Update changelog
pull/7693/head
Thomas Kaul 6 days ago
committed by GitHub
parent
commit
96d56dfb2d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 90
      apps/api/src/app/import/import.service.ts
  3. 6
      apps/api/src/app/import/interfaces/asset-profile-to-create.interface.ts
  4. 40
      apps/api/src/services/data-provider/data-provider.service.ts

1
CHANGELOG.md

@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed the _Storybook_ setup by loading the `@angular/localize` polyfill centrally - 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 ## 3.57.0 - 2026-08-21

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

@ -48,6 +48,7 @@ import { omit, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { ImportDataDto } from './import-data.dto'; import { ImportDataDto } from './import-data.dto';
import { AssetProfileToCreate } from './interfaces/asset-profile-to-create.interface';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
@ -534,6 +535,8 @@ export class ImportService {
} }
} }
const assetProfilesToCreate: AssetProfileToCreate[] = [];
if (assetProfilesWithMarketDataDto?.length) { if (assetProfilesWithMarketDataDto?.length) {
const customAssetProfileNames = assetProfilesWithMarketDataDto const customAssetProfileNames = assetProfilesWithMarketDataDto
.filter(({ dataSource, name }) => { .filter(({ dataSource, name }) => {
@ -557,6 +560,7 @@ export class ImportService {
]); ]);
for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) {
let assetProfileToCreate: Prisma.SymbolProfileCreateInput;
let symbol = assetProfileWithMarketData.symbol; let symbol = assetProfileWithMarketData.symbol;
// Check if there is any existing asset profile // Check if there is any existing asset profile
@ -605,13 +609,10 @@ export class ImportService {
assetProfile.symbol = symbol; assetProfile.symbol = symbol;
if (!isDryRun) { if (!isDryRun) {
// Create a new asset profile assetProfileToCreate = {
const assetProfileObject: Prisma.SymbolProfileCreateInput = {
...assetProfile, ...assetProfile,
user: { connect: { id: user.id } } user: { connect: { id: user.id } }
}; };
await this.symbolProfileService.add(assetProfileObject);
} }
} }
@ -625,7 +626,6 @@ export class ImportService {
} }
if (!isDryRun) { if (!isDryRun) {
// Insert or update market data
const marketDataObjects = ( const marketDataObjects = (
assetProfileWithMarketData.marketData ?? [] assetProfileWithMarketData.marketData ?? []
).map((marketData) => { ).map((marketData) => {
@ -636,7 +636,40 @@ export class ImportService {
} as Prisma.MarketDataUpdateInput; } 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; return id === TAG_ID_DRAFT;
}) ?? { id: TAG_ID_DRAFT, name: '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[] = []; const activities: Activity[] = [];
for (const activity of activitiesExtendedWithErrors) { for (const activity of activitiesExtendedWithErrors) {
@ -934,7 +986,7 @@ export class ImportService {
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<Partial<Activity>[]> { }): Promise<(Partial<Activity> & Pick<Activity, 'assetProfile'>)[]> {
const { activities: existingActivities } = const { activities: existingActivities } =
await this.activitiesService.getActivities({ await this.activitiesService.getActivities({
userCurrency, userCurrency,
@ -1055,6 +1107,30 @@ export class ImportService {
return matchingAccountsOfUser[0]; return matchingAccountsOfUser[0];
} }
private getAssetProfilesToCreate({
activities,
assetProfiles
}: {
activities: Pick<Activity, 'assetProfile' | 'error'>[];
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[]) { private isUniqueAccount(accounts: AccountWithValue[]) {
const uniqueAccountIds = new Set<string>(); const uniqueAccountIds = new Set<string>();

6
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[];
}

40
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 { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { eachDayOfInterval, format, isValid } from 'date-fns'; 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 ms from 'ms';
import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error'; import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error';
@ -272,23 +272,31 @@ export class DataProviderService implements OnModuleInit {
}); });
if (!assetProfiles[assetProfileIdentifier]) { if (!assetProfiles[assetProfileIdentifier]) {
if (
(dataSource === DataSource.MANUAL && type === 'BUY') ||
NON_INVESTMENT_ACTIVITY_TYPES.includes(type)
) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find( const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(assetProfile) => { (assetProfileWithMarketData) => {
return ( return (
assetProfile.dataSource === dataSource && assetProfileWithMarketData.dataSource === dataSource &&
assetProfile.symbol === symbol 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)
) {
assetProfiles[assetProfileIdentifier] = { assetProfiles[assetProfileIdentifier] = {
currency, ...omit(assetProfileInImport ?? {}, [
'dataSource',
'marketData',
'symbol'
]),
dataSource, dataSource,
symbol, symbol,
currency: assetProfileInImport?.currency ?? currency,
name: assetProfileInImport?.name ?? symbol name: assetProfileInImport?.name ?? symbol
}; };
@ -308,20 +316,6 @@ export class DataProviderService implements OnModuleInit {
)?.[assetProfileIdentifier]; )?.[assetProfileIdentifier];
} catch {} } 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) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`${activityPath}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")` `${activityPath}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")`

Loading…
Cancel
Save