Browse Source

Improve symbol validation for assets with manual data source

pull/7467/head
Thomas Kaul 1 month ago
parent
commit
4d4a209e3d
  1. 2
      apps/api/src/app/admin/admin.service.ts
  2. 22
      apps/api/src/app/import/import.service.ts
  3. 4
      apps/api/src/services/data-provider/data-provider.service.ts
  4. 52
      apps/client/src/app/services/import-activities.service.ts
  5. 6
      libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts
  6. 18
      libs/common/src/lib/validator-constraints/is-custom-asset-profile-symbol.ts
  7. 27
      test/import/not-ok/invalid-symbol-with-manual-data-source.json
  8. 24
      test/import/ok/without-accounts.json

2
apps/api/src/app/admin/admin.service.ts

@ -67,7 +67,7 @@ export class AdminService {
if (dataSource === 'MANUAL') { if (dataSource === 'MANUAL') {
if (!hasGhostfolioPrefix(symbol)) { if (!hasGhostfolioPrefix(symbol)) {
throw new BadRequestException( throw new BadRequestException(
`symbol "${symbol}" must start with the prefix "${ghostfolioPrefix}_" for the data source "${dataSource}"` `symbol ("${symbol}") must start with the prefix "${ghostfolioPrefix}_" for the data source ("${dataSource}")`
); );
} }

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

@ -11,11 +11,13 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/sy
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
ghostfolioPrefix,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos'; import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -393,6 +395,15 @@ export class ImportService {
); );
for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) {
if (
!isValidCustomAssetProfileSymbol(assetProfileWithMarketData.symbol)
) {
// Skip synthetic asset profiles (e.g. of the csv import), where the
// symbol is used as the name of the asset profile created in
// createActivity()
continue;
}
// Check if there is any existing asset profile // Check if there is any existing asset profile
const existingAssetProfile = existingAssetProfiles.find( const existingAssetProfile = existingAssetProfiles.find(
({ dataSource, symbol }) => { ({ dataSource, symbol }) => {
@ -441,7 +452,7 @@ export class ImportService {
} }
} }
for (const activity of activitiesDto) { for (const [index, activity] of activitiesDto.entries()) {
if (!activity.dataSource) { if (!activity.dataSource) {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) {
activity.dataSource = DataSource.MANUAL; activity.dataSource = DataSource.MANUAL;
@ -451,6 +462,15 @@ export class ImportService {
} }
} }
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}")`
);
}
if (!isDryRun) { if (!isDryRun) {
// If a new account is created, then update the accountId in all activities // If a new account is created, then update the accountId in all activities
if (accountIdMapping[activity.accountId]) { if (accountIdMapping[activity.accountId]) {

4
apps/api/src/services/data-provider/data-provider.service.ts

@ -296,7 +296,7 @@ export class DataProviderService implements OnModuleInit {
!isValidCustomAssetProfileSymbol(symbol) !isValidCustomAssetProfileSymbol(symbol)
) { ) {
throw new Error( throw new Error(
`${activityPath}.symbol "${symbol}" must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source "${DataSource.MANUAL}"` `${activityPath}.symbol ("${symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")`
); );
} }
@ -329,7 +329,7 @@ export class DataProviderService implements OnModuleInit {
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")` `${activityPath}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")`
); );
} }

52
apps/client/src/app/services/import-activities.service.ts

@ -5,7 +5,10 @@ import {
CreatePlatformDto, CreatePlatformDto,
CreateTagDto CreateTagDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { parseDate as parseDateHelper } from '@ghostfolio/common/helper'; import {
isValidCustomAssetProfileSymbol,
parseDate as parseDateHelper
} from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces'; import { Activity } from '@ghostfolio/common/interfaces';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
@ -57,31 +60,34 @@ export class ImportActivitiesService {
const activities: CreateOrderDto[] = []; const activities: CreateOrderDto[] = [];
const assetProfiles: CreateAssetProfileWithMarketDataDto[] = []; const assetProfiles: CreateAssetProfileWithMarketDataDto[] = [];
const assetProfileSymbolMapping: { [name: string]: string } = {};
for (const [index, item] of content.entries()) { for (const [index, item] of content.entries()) {
const currency = this.parseCurrency({ content, index, item }); const currency = this.parseCurrency({ content, index, item });
const dataSource = this.parseDataSource({ item }); const dataSource = this.parseDataSource({ item });
const symbol = this.parseSymbol({ content, index, item });
const type = this.parseType({ content, index, item }); const type = this.parseType({ content, index, item });
activities.push({ let symbol = this.parseSymbol({ content, index, item });
currency,
dataSource,
symbol,
type,
accountId: this.parseAccount({ item, userAccounts }),
comment: this.parseComment({ item }),
date: this.parseDate({ content, index, item }),
fee: this.parseFee({ content, index, item }),
quantity: this.parseQuantity({ content, index, item }),
unitPrice: this.parseUnitPrice({ content, index, item }),
updateAccountBalance: false
});
if (dataSource === DataSource.MANUAL) { if (dataSource === DataSource.MANUAL) {
const name = symbol;
if (!isValidCustomAssetProfileSymbol(symbol)) {
// Generate a symbol and keep the free text as the name
assetProfileSymbolMapping[name] =
assetProfileSymbolMapping[name] ?? crypto.randomUUID();
symbol = assetProfileSymbolMapping[name];
}
const isExistingAssetProfile = assetProfiles.some((assetProfile) => {
return assetProfile.symbol === symbol;
});
if (!isExistingAssetProfile) {
// Create synthetic asset profile for MANUAL data source // Create synthetic asset profile for MANUAL data source
assetProfiles.push({ assetProfiles.push({
currency, currency,
name,
symbol, symbol,
assetClass: undefined, assetClass: undefined,
assetSubClass: undefined, assetSubClass: undefined,
@ -96,13 +102,27 @@ export class ImportActivitiesService {
isActive: true, isActive: true,
isin: undefined, isin: undefined,
marketData: [], marketData: [],
name: symbol,
sectors: [], sectors: [],
url: undefined url: undefined
}); });
} }
} }
activities.push({
currency,
dataSource,
symbol,
type,
accountId: this.parseAccount({ item, userAccounts }),
comment: this.parseComment({ item }),
date: this.parseDate({ content, index, item }),
fee: this.parseFee({ content, index, item }),
quantity: this.parseQuantity({ content, index, item }),
unitPrice: this.parseUnitPrice({ content, index, item }),
updateAccountBalance: false
});
}
const result = await this.importJson({ const result = await this.importJson({
activities, activities,
assetProfiles, assetProfiles,

6
libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts

@ -1,8 +1,7 @@
import { MarketData } from '@ghostfolio/common/interfaces'; import { MarketData } from '@ghostfolio/common/interfaces';
import { IsCustomAssetProfileSymbolConstraint } from '@ghostfolio/common/validator-constraints/is-custom-asset-profile-symbol';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { IsArray, IsIn, IsOptional, Validate } from 'class-validator'; import { IsArray, IsIn, IsOptional } from 'class-validator';
import { CreateAssetProfileDto } from './create-asset-profile.dto'; import { CreateAssetProfileDto } from './create-asset-profile.dto';
@ -15,7 +14,4 @@ export class CreateAssetProfileWithMarketDataDto extends CreateAssetProfileDto {
@IsArray() @IsArray()
@IsOptional() @IsOptional()
marketData?: MarketData[]; marketData?: MarketData[];
@Validate(IsCustomAssetProfileSymbolConstraint)
override symbol: string;
} }

18
libs/common/src/lib/validator-constraints/is-custom-asset-profile-symbol.ts

@ -1,18 +0,0 @@
import {
ValidatorConstraint,
ValidatorConstraintInterface
} from 'class-validator';
import { ghostfolioPrefix } from '../config';
import { isValidCustomAssetProfileSymbol } from '../helper';
@ValidatorConstraint({ name: 'isCustomAssetProfileSymbol' })
export class IsCustomAssetProfileSymbolConstraint implements ValidatorConstraintInterface {
public defaultMessage() {
return `$property must be a UUID or start with the prefix "${ghostfolioPrefix}_"`;
}
public validate(aSymbol: string) {
return isValidCustomAssetProfileSymbol(aSymbol);
}
}

27
test/import/not-ok/invalid-symbol-with-manual-data-source.json

@ -0,0 +1,27 @@
{
"meta": {
"date": "2023-02-05T00:00:00.000Z",
"version": "dev"
},
"activities": [
{
"accountId": null,
"comment": null,
"currency": "USD",
"dataSource": "MANUAL",
"date": "2022-01-01T00:00:00.000Z",
"fee": 0,
"quantity": 1,
"symbol": "Penthouse Apartment",
"tags": [],
"type": "BUY",
"unitPrice": 500000
}
],
"user": {
"settings": {
"currency": "USD",
"performanceCalculationType": "ROAI"
}
}
}

24
test/import/ok/without-accounts.json

@ -3,6 +3,28 @@
"date": "2022-04-01T00:00:00.000Z", "date": "2022-04-01T00:00:00.000Z",
"version": "dev" "version": "dev"
}, },
"assetProfiles": [
{
"assetClass": null,
"assetSubClass": null,
"comment": null,
"countries": [],
"currency": "USD",
"cusip": null,
"dataSource": "MANUAL",
"figi": null,
"figiComposite": null,
"figiShareClass": null,
"holdings": [],
"isActive": true,
"isin": null,
"marketData": [],
"name": "Penthouse Apartment",
"sectors": [],
"symbol": "7e91b7d4-1430-4212-8380-289a06c9bbc1",
"url": null
}
],
"activities": [ "activities": [
{ {
"fee": 0, "fee": 0,
@ -22,7 +44,7 @@
"currency": "USD", "currency": "USD",
"dataSource": "MANUAL", "dataSource": "MANUAL",
"date": "2022-01-01T00:00:00.000Z", "date": "2022-01-01T00:00:00.000Z",
"symbol": "Penthouse Apartment" "symbol": "7e91b7d4-1430-4212-8380-289a06c9bbc1"
}, },
{ {
"fee": 0, "fee": 0,

Loading…
Cancel
Save