Browse Source

Task/improve symbol validation for assets with manual data source (#7467)

* Improve symbol validation for assets with manual data source

* Update changelog
pull/7535/head^2
Thomas Kaul 4 days ago
committed by GitHub
parent
commit
61cac99d54
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 12
      apps/api/src/app/activities/activities.service.ts
  3. 10
      apps/api/src/app/admin/admin.service.ts
  4. 140
      apps/api/src/app/import/import.service.ts
  5. 2
      apps/api/src/services/data-provider/data-provider.service.ts
  6. 21
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  7. 59
      apps/client/src/app/services/import-activities.service.ts
  8. 31
      libs/common/src/lib/helper.spec.ts
  9. 15
      libs/common/src/lib/helper.ts
  10. 34
      package-lock.json
  11. 1
      package.json
  12. 27
      test/import/not-ok/invalid-symbol-with-manual-data-source.json
  13. 24
      test/import/ok/without-accounts.json

2
CHANGELOG.md

@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Extended the support of the _Exclude from Analysis_ tag from accounts to activities - Extended the support of the _Exclude from Analysis_ tag from accounts to activities
- Optimized the performance of the search in the assistant by reusing the cached portfolio snapshot - Optimized the performance of the search in the assistant by reusing the cached portfolio snapshot
- Improved the validation of the import functionality when referencing an asset profile with the data source `MANUAL`
- Improved the validation of the endpoint to add a custom asset profile in the admin control panel
### Fixed ### Fixed

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

@ -20,12 +20,12 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
ghostfolioPrefix,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
getAssetProfileIdentifier getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse, ActivitiesResponse,
@ -48,7 +48,6 @@ import {
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 { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -204,10 +203,9 @@ export class ActivitiesService {
let symbol: string; let symbol: string;
if ( if (
data.SymbolProfile.connectOrCreate.create.symbol.startsWith( isValidCustomAssetProfileSymbol(
`${ghostfolioPrefix}_` data.SymbolProfile.connectOrCreate.create.symbol
) || )
isUUID(data.SymbolProfile.connectOrCreate.create.symbol)
) { ) {
// Connect custom asset profile (clone) // Connect custom asset profile (clone)
symbol = data.SymbolProfile.connectOrCreate.create.symbol; symbol = data.SymbolProfile.connectOrCreate.create.symbol;

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

@ -7,6 +7,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
ghostfolioPrefix,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
@ -14,7 +15,8 @@ import {
import { import {
applyAssetProfileOverrides, applyAssetProfileOverrides,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCurrencyFromSymbol getCurrencyFromSymbol,
hasGhostfolioPrefix
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
@ -63,6 +65,12 @@ export class AdminService {
> { > {
try { try {
if (dataSource === 'MANUAL') { if (dataSource === 'MANUAL') {
if (!hasGhostfolioPrefix(symbol)) {
throw new BadRequestException(
`symbol ("${symbol}") must start with the prefix "${ghostfolioPrefix}_" for the data source ("${dataSource}")`
);
}
return this.symbolProfileService.add({ return this.symbolProfileService.add({
currency, currency,
dataSource, dataSource,

140
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 {
@ -196,6 +198,41 @@ export class ImportService {
const tagIdMapping: { [oldTagId: string]: string } = {}; const tagIdMapping: { [oldTagId: string]: string } = {};
const userCurrency = user.settings.settings.baseCurrency; 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}")`
);
}
}
// Validate the symbols before any data is persisted. Activities without a
// data source are excluded, since a symbol is generated in
// createActivity() if needed.
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();
}
} else 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 (platformsDto?.length) { if (platformsDto?.length) {
const canCreatePlatform = hasPermission( const canCreatePlatform = hasPermission(
user.permissions, user.permissions,
@ -384,39 +421,77 @@ export class ImportService {
} }
} }
if (!isDryRun && assetProfilesWithMarketDataDto?.length) { if (assetProfilesWithMarketDataDto?.length) {
const existingAssetProfiles = const customAssetProfileNames = assetProfilesWithMarketDataDto
await this.symbolProfileService.getSymbolProfiles( .filter(({ dataSource, name }) => {
return dataSource === DataSource.MANUAL && Boolean(name);
})
.map(({ name }) => {
return name;
});
const [existingAssetProfiles, existingCustomAssetProfilesOfUser] =
await Promise.all([
this.symbolProfileService.getSymbolProfiles(
assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => { assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}) })
); ),
this.symbolProfileService.getCustomSymbolProfilesByNames({
names: customAssetProfileNames,
userId: user.id
})
]);
for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) {
let symbol = assetProfileWithMarketData.symbol;
// 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 }) => { (assetProfile) => {
return ( return (
dataSource === assetProfileWithMarketData.dataSource && assetProfile.dataSource ===
symbol === assetProfileWithMarketData.symbol 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) { if (!existingAssetProfile || existingAssetProfile.userId !== user.id) {
// Check if the user has a custom asset profile with the same name.
// Skip asset profiles with a legacy free-text symbol as they would
// fail the symbol validation on a future import.
const existingCustomAssetProfileOfUser =
assetProfileWithMarketData.dataSource === DataSource.MANUAL
? existingCustomAssetProfilesOfUser.find((customAssetProfile) => {
return (
customAssetProfile.name ===
assetProfileWithMarketData.name &&
isValidCustomAssetProfileSymbol(customAssetProfile.symbol)
);
})
: undefined;
if (existingCustomAssetProfileOfUser) {
// Reuse the custom asset profile of the user instead of creating a duplicate
symbol = existingCustomAssetProfileOfUser.symbol;
} else {
const assetProfile: CreateAssetProfileDto = omit( const assetProfile: CreateAssetProfileDto = omit(
assetProfileWithMarketData, assetProfileWithMarketData,
'marketData' 'marketData'
); );
// Asset profile belongs to a different user // Asset profile belongs to a different user, generate a new symbol
if (existingAssetProfile) { if (existingAssetProfile && !isDryRun) {
const symbol = randomUUID(); symbol = randomUUID();
assetProfileSymbolMapping[assetProfile.symbol] = symbol;
assetProfile.symbol = symbol;
} }
assetProfile.symbol = symbol;
if (!isDryRun) {
// Create a new asset profile // Create a new asset profile
const assetProfileObject: Prisma.SymbolProfileCreateInput = { const assetProfileObject: Prisma.SymbolProfileCreateInput = {
...assetProfile, ...assetProfile,
@ -425,30 +500,38 @@ export class ImportService {
await this.symbolProfileService.add(assetProfileObject); await this.symbolProfileService.add(assetProfileObject);
} }
}
if (symbol !== assetProfileWithMarketData.symbol) {
assetProfileSymbolMapping[assetProfileWithMarketData.symbol] =
symbol;
// Keep the asset profile in sync with the activities to validate
assetProfileWithMarketData.symbol = symbol;
}
}
if (!isDryRun) {
// Insert or update market data // Insert or update market data
const marketDataObjects = assetProfileWithMarketData.marketData.map( const marketDataObjects = (
(marketData) => { assetProfileWithMarketData.marketData ?? []
).map((marketData) => {
return { return {
...marketData, ...marketData,
dataSource: assetProfileWithMarketData.dataSource, symbol,
symbol: assetProfileWithMarketData.symbol dataSource: assetProfileWithMarketData.dataSource
} as Prisma.MarketDataUpdateInput; } as Prisma.MarketDataUpdateInput;
} });
);
await this.marketDataService.updateMany({ data: marketDataObjects }); await this.marketDataService.updateMany({ data: marketDataObjects });
} }
} }
}
for (const activity of activitiesDto) { for (const activity of activitiesDto) {
if (!activity.dataSource) { // If an asset profile is created or reused, then update the symbol in all activities
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { if (assetProfileSymbolMapping[activity.symbol]) {
activity.dataSource = DataSource.MANUAL; activity.symbol = assetProfileSymbolMapping[activity.symbol];
} else {
activity.dataSource =
this.dataProviderService.getDataSourceForImport();
}
} }
if (!isDryRun) { if (!isDryRun) {
@ -457,11 +540,6 @@ export class ImportService {
activity.accountId = accountIdMapping[activity.accountId]; 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 // If a new tag is created, then update the tag ID in all activities
activity.tags = (activity.tags ?? []).map((tagId) => { activity.tags = (activity.tags ?? []).map((tagId) => {
return tagIdMapping[tagId] ?? tagId; return tagIdMapping[tagId] ?? tagId;

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

@ -318,7 +318,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}")`
); );
} }

21
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<Pick<SymbolProfile, 'name' | 'symbol'>[]> {
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( public async getSymbolProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedAssetProfile[]> { ): Promise<EnhancedAssetProfile[]> {

59
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';
@ -14,6 +17,7 @@ import { Account, DataSource, Type as ActivityType } from '@prisma/client';
import { isFinite, isNumber, isString } from 'lodash'; import { isFinite, isNumber, isString } from 'lodash';
import { parse as csvToJson } from 'papaparse'; import { parse as csvToJson } from 'papaparse';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -57,31 +61,38 @@ export class ImportActivitiesService {
const activities: CreateOrderDto[] = []; const activities: CreateOrderDto[] = [];
const assetProfiles: CreateAssetProfileWithMarketDataDto[] = []; const assetProfiles: CreateAssetProfileWithMarketDataDto[] = [];
const assetProfileSymbolMapping = new Map<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 symbol = this.parseSymbol({ content, index, item });
const type = this.parseType({ content, index, item }); const type = this.parseType({ content, index, item });
activities.push({ let dataSource = this.parseDataSource({ item });
currency, let symbol = this.parseSymbol({ content, index, item });
dataSource,
symbol, if (!dataSource && ['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
type, // Apply the same data source as the import service
accountId: this.parseAccount({ item, userAccounts }), dataSource = DataSource.MANUAL;
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
symbol = assetProfileSymbolMapping.get(name) ?? uuidv4();
assetProfileSymbolMapping.set(name, symbol);
}
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 +107,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,

31
libs/common/src/lib/helper.spec.ts

@ -10,7 +10,8 @@ import {
isAccountExcluded, isAccountExcluded,
isCurrency, isCurrency,
isCurrencySymbol, isCurrencySymbol,
isSplitRatio isSplitRatio,
isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
describe('Helper', () => { describe('Helper', () => {
@ -326,4 +327,32 @@ describe('Helper', () => {
); );
}); });
}); });
describe('Is valid custom asset profile symbol', () => {
it('Empty symbol', () => {
expect(isValidCustomAssetProfileSymbol('')).toEqual(false);
});
it('Free-text symbol', () => {
expect(isValidCustomAssetProfileSymbol('Penthouse Apartment')).toEqual(
false
);
});
it('Stock symbol', () => {
expect(isValidCustomAssetProfileSymbol('AAPL')).toEqual(false);
});
it('Symbol with Ghostfolio prefix', () => {
expect(isValidCustomAssetProfileSymbol('GF_PENTHOUSE_APARTMENT')).toEqual(
true
);
});
it('UUID', () => {
expect(
isValidCustomAssetProfileSymbol('7e91b7d4-1430-4212-8380-289a06c9bbc1')
).toEqual(true);
});
});
}); });

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

@ -8,7 +8,7 @@ import {
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isISO4217CurrencyCode } from 'class-validator'; import { isISO4217CurrencyCode, isUUID } from 'class-validator';
import { import {
getDate, getDate,
getMonth, getMonth,
@ -41,6 +41,7 @@ import {
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks, ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioPrefix,
SEARCH_QUERY_MINIMUM_LENGTH, SEARCH_QUERY_MINIMUM_LENGTH,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from './config'; } from './config';
@ -466,6 +467,14 @@ export function getYesterday() {
return subDays(new Date(Date.UTC(year, month, day)), 1); return subDays(new Date(Date.UTC(year, month, day)), 1);
} }
export function hasGhostfolioPrefix(aSymbol: string) {
if (!aSymbol) {
return false;
}
return aSymbol.startsWith(`${ghostfolioPrefix}_`);
}
export function interpolate(template: string, context: any) { export function interpolate(template: string, context: any) {
return template?.replace(/[$]{([^}]+)}/g, (_, objectPath) => { return template?.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
const properties = objectPath.split('.'); const properties = objectPath.split('.');
@ -548,6 +557,10 @@ export function isSplitRatio({
); );
} }
export function isValidCustomAssetProfileSymbol(aSymbol: string) {
return hasGhostfolioPrefix(aSymbol) || isUUID(aSymbol);
}
export function isValidSearchQuery(aQuery: string) { export function isValidSearchQuery(aQuery: string) {
return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH; return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH;
} }

34
package-lock.json

@ -97,6 +97,7 @@
"tablemark": "4.1.0", "tablemark": "4.1.0",
"twitter-api-v2": "1.29.0", "twitter-api-v2": "1.29.0",
"undici": "8.5.0", "undici": "8.5.0",
"uuid": "14.0.1",
"yahoo-finance2": "4.0.0", "yahoo-finance2": "4.0.0",
"zod": "4.4.3", "zod": "4.4.3",
"zone.js": "0.16.1" "zone.js": "0.16.1"
@ -15985,6 +15986,16 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/bull/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/bundle-name": { "node_modules/bundle-name": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@ -32154,6 +32165,17 @@
"websocket-driver": "^0.7.4" "websocket-driver": "^0.7.4"
} }
}, },
"node_modules/sockjs/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"dev": true,
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/socks": { "node_modules/socks": {
"version": "2.8.7", "version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
@ -34664,12 +34686,16 @@
} }
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "8.3.2", "version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist-node/bin/uuid"
} }
}, },
"node_modules/v8-compile-cache-lib": { "node_modules/v8-compile-cache-lib": {

1
package.json

@ -141,6 +141,7 @@
"tablemark": "4.1.0", "tablemark": "4.1.0",
"twitter-api-v2": "1.29.0", "twitter-api-v2": "1.29.0",
"undici": "8.5.0", "undici": "8.5.0",
"uuid": "14.0.1",
"yahoo-finance2": "4.0.0", "yahoo-finance2": "4.0.0",
"zod": "4.4.3", "zod": "4.4.3",
"zone.js": "0.16.1" "zone.js": "0.16.1"

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