You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1179 lines
36 KiB

import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { getTagsWithDraftTag } from '@ghostfolio/api/helper/activity.helper';
import { getMaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
ghostfolioPrefix,
NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import {
CreateAccountWithBalancesDto,
CreateAssetProfileDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
import {
getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol,
parseDate
} from '@ghostfolio/common/helper';
import {
Activity,
ActivityError,
AssetProfileIdentifier
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
AccountWithValue,
OrderWithAccount,
UserWithSettings
} from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Account, DataSource, Prisma } from '@prisma/client';
import { Big } from 'big.js';
import { isISIN } from 'class-validator';
import { isSameSecond, parseISO } from 'date-fns';
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 {
public constructor(
private readonly accountService: AccountService,
private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService,
private readonly symbolProfileService: SymbolProfileService,
private readonly tagService: TagService
) {}
public async getDividends({
dataSource,
symbol,
userCurrency,
userId
}: AssetProfileIdentifier & {
userCurrency: string;
userId: string;
}): Promise<Activity[]> {
try {
const holding = await this.portfolioService.getHolding({
dataSource,
symbol,
userId
});
if (!holding) {
return [];
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByDataSource: dataSource,
filterBySymbol: symbol
});
const { dateOfFirstActivity, historicalData } = holding;
const [{ accounts }, { activities }, [assetProfile], dividends] =
await Promise.all([
this.portfolioService.getAccountsWithAggregations({
filters,
userId,
withExcludedAccounts: true
}),
this.activitiesService.getActivities({
filters,
userCurrency,
userId,
includeDrafts: true,
startDate: parseDate(dateOfFirstActivity),
withExcludedAccountsAndActivities: true
}),
this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]),
await this.dataProviderService.getDividends({
dataSource,
symbol,
from: parseDate(dateOfFirstActivity),
granularity: 'day',
to: new Date()
})
]);
const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
return await Promise.all(
Object.entries(dividends).map(([dateString, { marketPrice }]) => {
const date = parseDate(dateString);
const quantity =
historicalData.find((historicalDataItem) => {
return historicalDataItem.date === dateString;
})?.quantity ?? 0;
const value = new Big(quantity).mul(marketPrice).toNumber();
const isDuplicate = activities.some((activity) => {
return (
activity.assetProfile.currency === assetProfile.currency &&
activity.assetProfile.dataSource === assetProfile.dataSource &&
isSameSecond(activity.date, date) &&
activity.quantity === quantity &&
activity.assetProfile.symbol === assetProfile.symbol &&
activity.type === 'DIVIDEND' &&
activity.unitPrice === marketPrice
);
});
const error: ActivityError = isDuplicate
? { code: 'IS_DUPLICATE' }
: undefined;
return {
account,
assetProfile,
date,
error,
quantity,
value,
accountId: account?.id,
accountUserId: undefined,
comment: undefined,
currency: undefined,
createdAt: undefined,
fee: 0,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
id: assetProfile.id,
symbolProfileId: assetProfile.id,
type: 'DIVIDEND',
unitPrice: marketPrice,
unitPriceInAssetProfileCurrency: marketPrice,
updatedAt: undefined,
userId: account?.userId,
valueInBaseCurrency: value
};
})
);
} catch {
return [];
}
}
public async import({
accountsWithBalancesDto,
activitiesDto,
assetProfilesWithMarketDataDto,
isDryRun = false,
maxActivitiesToImport,
platformsDto,
tagsDto,
user
}: {
accountsWithBalancesDto: ImportDataDto['accounts'];
activitiesDto: ImportDataDto['activities'];
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
isDryRun?: boolean;
maxActivitiesToImport: number;
platformsDto: ImportDataDto['platforms'];
tagsDto: ImportDataDto['tags'];
user: UserWithSettings;
}): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {};
const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {};
const ghostfolioDataSources = this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER'
);
const platformIdMapping: { [oldPlatformId: string]: string } = {};
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()) {
const { dataSource, symbol } = assetProfileWithMarketData;
if (
dataSource === DataSource.MANUAL &&
!isValidCustomAssetProfileSymbol(symbol)
) {
throw new Error(
`assetProfiles.${index}.symbol ("${symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")`
);
} else if (
dataSource !== DataSource.MANUAL &&
isValidCustomAssetProfileSymbol(symbol)
) {
const maskedDataSource = getMaskedGhostfolioDataSource({
dataSource,
ghostfolioDataSources
});
throw new Error(
`assetProfiles.${index}.symbol ("${symbol}") is not valid for the data source ("${maskedDataSource}")`
);
}
}
// Validate the symbols before any data is persisted. Activities without an
// explicit data source are excluded from the first check, since a symbol is
// generated in createActivity() if needed.
for (const [index, activity] of activitiesDto.entries()) {
const hasDataSource = Boolean(activity.dataSource);
if (!hasDataSource) {
activity.dataSource = NON_INVESTMENT_ACTIVITY_TYPES.includes(
activity.type
)
? DataSource.MANUAL
: this.dataProviderService.getDataSourceForImport();
}
if (
hasDataSource &&
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}")`
);
} else if (
activity.dataSource !== DataSource.MANUAL &&
isValidCustomAssetProfileSymbol(activity.symbol)
) {
const maskedDataSource = getMaskedGhostfolioDataSource({
ghostfolioDataSources,
dataSource: activity.dataSource
});
throw new Error(
`activities.${index}.symbol ("${activity.symbol}") is not valid for the data source ("${maskedDataSource}")`
);
}
}
// Resolve the symbols with an ISIN to the symbol of the data provider
// before the asset profiles are created and the duplicates are detected
const assetProfileIdentifiersWithIsin: AssetProfileIdentifier[] = uniqBy(
[...(assetProfilesWithMarketDataDto ?? []), ...activitiesDto]
.filter(({ dataSource, symbol }) => {
return dataSource !== DataSource.MANUAL && isISIN(symbol);
})
.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
getAssetProfileIdentifier
);
const resolvedAssetProfileIdentifiers = await Promise.all(
assetProfileIdentifiersWithIsin.map(async ({ dataSource, symbol }) => {
try {
const assetProfile = await this.dataProviderService
.getDataProvider(dataSource)
.getAssetProfile({ symbol });
return { dataSource, symbol, resolvedSymbol: assetProfile?.symbol };
} catch {
return { dataSource, symbol, resolvedSymbol: undefined };
}
})
);
for (const {
dataSource,
resolvedSymbol,
symbol
} of resolvedAssetProfileIdentifiers) {
if (!resolvedSymbol || resolvedSymbol === symbol) {
continue;
}
for (const activity of activitiesDto) {
if (activity.dataSource === dataSource && activity.symbol === symbol) {
activity.symbol = resolvedSymbol;
}
}
for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto ??
[]) {
if (
assetProfileWithMarketData.dataSource === dataSource &&
assetProfileWithMarketData.symbol === symbol
) {
assetProfileWithMarketData.symbol = resolvedSymbol;
}
}
}
if (platformsDto?.length) {
const canCreatePlatform = hasPermission(
user.permissions,
permissions.createPlatform
);
const existingPlatforms = await this.platformService.getPlatforms();
for (const platform of platformsDto) {
// Check if there is any existing platform with the same ID, otherwise
// fall back to a platform with the same URL
const existingPlatform =
existingPlatforms.find(({ id }) => {
return id === platform.id;
}) ??
existingPlatforms.find(({ url }) => {
return url === platform.url;
});
if (existingPlatform) {
// Store the new to old platform ID mappings for creating accounts
if (platform.id && existingPlatform.id !== platform.id) {
platformIdMapping[platform.id] = existingPlatform.id;
}
} else {
if (!canCreatePlatform) {
throw new Error(
`Insufficient permissions to create platform ("${platform.name}")`
);
}
if (!isDryRun) {
await this.platformService.createPlatform(platform);
}
}
}
}
const existingTagsOfUser =
tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length)
? await this.tagService.getTagsForUser(user.id)
: [];
if (tagsDto?.length) {
const canCreateOwnTag = hasPermission(
user.permissions,
permissions.createOwnTag
);
for (const tag of tagsDto) {
const existingTagOfUser = existingTagsOfUser.find(({ id }) => {
return id === tag.id;
});
if (!existingTagOfUser) {
if (!canCreateOwnTag) {
throw new Error(
`Insufficient permissions to create custom tag ("${tag.name}")`
);
}
if (!isDryRun) {
const existingTag = await this.tagService.getTag({ id: tag.id });
let oldTagId: string;
if (existingTag) {
oldTagId = tag.id;
delete tag.id;
}
const tagObject: Prisma.TagCreateInput = {
...tag,
user: { connect: { id: user.id } }
};
const newTag = await this.tagService.createTag(tagObject);
if (existingTag && oldTagId) {
tagIdMapping[oldTagId] = newTag.id;
}
existingTagsOfUser.push({
id: newTag.id,
isUsed: false,
name: newTag.name,
userId: newTag.userId
});
}
}
}
}
if (accountsWithBalancesDto?.length) {
const [
existingAccountsOfOtherUsers,
existingAccountsOfUser,
existingPlatforms
] = await Promise.all([
this.accountService.accounts({
where: {
id: {
in: accountsWithBalancesDto
.filter(({ id }) => {
return Boolean(id);
})
.map(({ id }) => {
return id;
})
},
userId: { not: user.id }
}
}),
this.accountService.accounts({
where: { userId: user.id }
}),
this.platformService.getPlatforms()
]);
const existingTagIds = new Set(
existingTagsOfUser.map(({ id }) => {
return id;
})
);
for (const accountWithBalances of accountsWithBalancesDto) {
// Skip the account if it already belongs to the user
if (
existingAccountsOfUser.some(({ id }) => {
return id === accountWithBalances.id;
})
) {
continue;
}
// If there is no account or if the account belongs to a different
// user, then reuse an existing account of the user with the same name
// and currency or create a new account
const accountToReuse = this.getAccountToReuse({
accountWithBalances,
accountsWithBalancesDto,
existingAccountsOfUser
});
if (accountToReuse) {
// Reuse the account of the user instead of creating a duplicate. The
// balances, the platform and the tags of the import are deliberately
// not applied to leave the existing account of the user untouched.
if (
accountWithBalances.id &&
accountWithBalances.id !== accountToReuse.id
) {
// Store the new to old account ID mappings for updating activities
accountIdMapping[accountWithBalances.id] = accountToReuse.id;
}
continue;
}
if (isDryRun) {
continue;
}
// Check if there is any existing account of a different user with the
// same ID, since the ID cannot be reused in this case
const accountWithSameIdOfOtherUser = existingAccountsOfOtherUsers.find(
({ id }) => {
return id === accountWithBalances.id;
}
);
const account = omit(accountWithBalances, [
'balance',
'balances',
'isExcluded',
'tags'
]);
let oldAccountId: string;
const platformId =
platformIdMapping[account.platformId] ?? account.platformId;
delete account.platformId;
if (accountWithSameIdOfOtherUser) {
oldAccountId = account.id;
delete account.id;
}
const tagIds = (accountWithBalances.tags ?? [])
.map((tagId) => {
return tagIdMapping[tagId] ?? tagId;
})
.filter((tagId) => {
return existingTagIds.has(tagId);
});
// Map the legacy isExcluded attribute of old export files to
// the "Exclude from Analysis" tag
if (
accountWithBalances.isExcluded &&
existingTagIds.has(TAG_ID_EXCLUDE_FROM_ANALYSIS) &&
!tagIds.includes(TAG_ID_EXCLUDE_FROM_ANALYSIS)
) {
tagIds.push(TAG_ID_EXCLUDE_FROM_ANALYSIS);
}
let accountObject: Prisma.AccountCreateInput = {
...account,
balances: {
create: accountWithBalances.balances ?? []
},
user: { connect: { id: user.id } }
};
if (
existingPlatforms.some(({ id }) => {
return id === platformId;
})
) {
accountObject = {
...accountObject,
platform: { connect: { id: platformId } }
};
}
const newAccount = await this.accountService.createAccount({
tagIds,
balance: accountWithBalances.balance,
data: accountObject,
userId: user.id
});
// Store the new to old account ID mappings for updating activities
if (accountWithSameIdOfOtherUser && oldAccountId) {
accountIdMapping[oldAccountId] = newAccount.id;
}
}
}
const assetProfilesToCreate: AssetProfileToCreate[] = [];
if (assetProfilesWithMarketDataDto?.length) {
const customAssetProfileNames = assetProfilesWithMarketDataDto
.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 }) => {
return { dataSource, symbol };
})
),
this.symbolProfileService.getCustomSymbolProfilesByNames({
names: customAssetProfileNames,
userId: user.id
})
]);
for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) {
let assetProfileToCreate: Prisma.SymbolProfileCreateInput;
let symbol = assetProfileWithMarketData.symbol;
// Check if there is any existing asset profile
const existingAssetProfile = existingAssetProfiles.find(
(assetProfile) => {
return (
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 reuse the custom asset profile of the user or
// create a new asset profile
if (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(
assetProfileWithMarketData,
'marketData'
);
// Asset profile belongs to a different user, generate a new symbol
if (existingAssetProfile && !isDryRun) {
symbol = randomUUID();
}
assetProfile.symbol = symbol;
if (!isDryRun) {
assetProfileToCreate = {
...assetProfile,
user: { connect: { id: user.id } }
};
}
}
if (symbol !== assetProfileWithMarketData.symbol) {
assetProfileSymbolMapping[assetProfileWithMarketData.symbol] =
symbol;
// Keep the asset profile in sync with the activities to validate
assetProfileWithMarketData.symbol = symbol;
}
}
if (!isDryRun) {
const marketDataObjects = (
assetProfileWithMarketData.marketData ?? []
).map((marketData) => {
return {
...marketData,
symbol,
dataSource: assetProfileWithMarketData.dataSource
} as Prisma.MarketDataUpdateInput;
});
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
});
}
}
}
}
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 an account is created or reused, then update the accountId in all activities
if (accountIdMapping[activity.accountId]) {
activity.accountId = accountIdMapping[activity.accountId];
}
if (!isDryRun) {
// If a new tag is created, then update the tag ID in all activities
activity.tags = (activity.tags ?? []).map((tagId) => {
return tagIdMapping[tagId] ?? tagId;
});
}
}
const assetProfiles = await this.dataProviderService.validateActivities({
activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport,
subscription: user.subscription
});
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
activitiesDto,
userCurrency,
userId: user.id
});
const accounts = (await this.accountService.getAccounts(user.id)).map(
({ id, name }) => {
return { id, name };
}
);
if (isDryRun) {
accountsWithBalancesDto
.filter(({ id }) => {
// Skip the accounts which are reused or which already belong to the
// user, since they are part of the accounts of the user above
return (
!accountIdMapping[id] &&
!accounts.some(({ id: accountId }) => {
return accountId === id;
})
);
})
.forEach(({ id, name }) => {
accounts.push({ id, name });
});
}
const tags = (await this.tagService.getTagsForUser(user.id)).map(
({ id, name }) => {
return { id, name };
}
);
if (isDryRun) {
tagsDto
.filter(({ id }) => {
return !tags.some(({ id: tagId }) => {
return tagId === id;
});
})
.forEach(({ id, name }) => {
tags.push({ id, name });
});
}
// Preview the "Draft" tag which createActivity() assigns in a real run
const draftTag = tags.find(({ id }) => {
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) {
const accountId = activity.accountId;
const comment = activity.comment;
const currency = activity.currency;
const date = activity.date;
const error = activity.error;
const fee = activity.fee;
const quantity = activity.quantity;
const tagIds = activity.tagIds ?? [];
const type = activity.type;
const unitPrice = activity.unitPrice;
const assetProfile = assetProfiles[
getAssetProfileIdentifier({
dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol
})
] ?? {
dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol
};
const {
assetClass,
assetSubClass,
countries,
createdAt,
cusip,
dataSource,
figi,
figiComposite,
figiShareClass,
holdings,
id,
isActive,
isin,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url,
updatedAt
} = assetProfile;
const validatedAccount = accounts.find(({ id }) => {
return id === accountId;
});
const validatedTags = tags.filter(({ id: tagId }) => {
return tagIds.some((activityTagId) => {
return activityTagId === tagId;
});
});
let order:
| OrderWithAccount
| (Omit<OrderWithAccount, 'account' | 'tags'> & {
account?: { id: string; name: string };
tags?: { id: string; name: string }[];
});
if (isDryRun) {
const previewTags = getTagsWithDraftTag({
date,
draftTag,
type,
tags: validatedTags
});
order = {
comment,
currency,
date,
fee,
quantity,
type,
unitPrice,
account: validatedAccount,
accountId: validatedAccount?.id,
accountUserId: undefined,
createdAt: new Date(),
id: randomUUID(),
SymbolProfile: {
assetClass,
assetSubClass,
countries,
createdAt,
cusip,
dataSource,
figi,
figiComposite,
figiShareClass,
holdings,
id,
isActive,
isin,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
updatedAt,
url,
comment: assetProfile.comment,
currency: assetProfile.currency,
dataGatheringFrequency:
assetProfile.dataGatheringFrequency ?? 'DAILY',
userId: dataSource === 'MANUAL' ? user.id : undefined
},
symbolProfileId: undefined,
tags: previewTags,
updatedAt: new Date(),
userId: user.id
};
} else {
if (error) {
continue;
}
order = await this.activitiesService.createActivity({
comment,
currency,
date,
fee,
quantity,
type,
unitPrice,
accountId: validatedAccount?.id,
SymbolProfile: {
connectOrCreate: {
create: {
dataSource,
name,
symbol,
currency: assetProfile.currency,
userId: dataSource === 'MANUAL' ? user.id : undefined
},
where: {
dataSource_symbol: {
dataSource,
symbol
}
}
}
},
tags: validatedTags.map(({ id }) => {
return { id };
}),
updateAccountBalance: false,
user: { connect: { id: user.id } },
userId: user.id
});
if (order.SymbolProfile?.symbol) {
// Update symbol that may have been assigned in createOrder()
assetProfile.symbol = order.SymbolProfile.symbol;
}
}
const value = new Big(quantity).mul(unitPrice).toNumber();
const valueInBaseCurrency =
(await this.exchangeRateDataService.toCurrencyAtDate(
value,
currency ?? assetProfile.currency,
userCurrency,
date
)) ?? 0;
activities.push({
...order,
// @ts-ignore
assetProfile,
error,
value,
valueInBaseCurrency
});
}
activities.sort((activity1, activity2) => {
return Number(activity1.date) - Number(activity2.date);
});
if (!isDryRun) {
// Gather symbol data in the background, if not dry run
const uniqueActivities = uniqBy(activities, ({ assetProfile }) => {
return getAssetProfileIdentifier({
dataSource: assetProfile.dataSource,
symbol: assetProfile.symbol
});
});
this.dataGatheringService.gatherSymbols({
dataGatheringItems: uniqueActivities.map(({ assetProfile, date }) => {
return {
date,
dataSource: assetProfile.dataSource,
symbol: assetProfile.symbol
};
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
}
return activities;
}
private async extendActivitiesWithErrors({
activitiesDto,
userCurrency,
userId
}: {
activitiesDto: Partial<CreateOrderDto>[];
userCurrency: string;
userId: string;
}): Promise<(Partial<Activity> & Pick<Activity, 'assetProfile'>)[]> {
const { activities: existingActivities } =
await this.activitiesService.getActivities({
userCurrency,
userId,
includeDrafts: true,
withExcludedAccountsAndActivities: true
});
return activitiesDto.map(
({
accountId,
comment,
currency,
dataSource,
date: dateString,
fee,
quantity,
symbol,
tags,
type,
unitPrice
}) => {
const date = parseISO(dateString);
const isDuplicate = existingActivities.some((activity) => {
return (
(activity.comment || null) === (comment || null) &&
(activity.currency === currency ||
activity.assetProfile.currency === currency) &&
activity.assetProfile.dataSource === dataSource &&
isSameSecond(activity.date, date) &&
activity.fee === fee &&
activity.quantity === quantity &&
activity.assetProfile.symbol === symbol &&
activity.type === type &&
activity.unitPrice === unitPrice
);
});
const error: ActivityError = isDuplicate
? { code: 'IS_DUPLICATE' }
: undefined;
return {
accountId,
comment,
currency,
date,
error,
fee,
quantity,
type,
unitPrice,
assetProfile: {
dataSource,
symbol,
activitiesCount: undefined,
assetClass: undefined,
assetSubClass: undefined,
countries: undefined,
createdAt: undefined,
currency: undefined,
holdings: undefined,
id: undefined,
isActive: true,
sectors: undefined,
updatedAt: undefined
},
tagIds: tags
};
}
);
}
/**
* Returns the account of the user to reuse for the given account of the
* import, based on the name and the currency. The currency is considered
* because the activities of the import would otherwise end up in an account
* of a different currency. The name is only considered if it is unambiguous,
* both in the accounts of the user and in the accounts of the import, since
* it is not unique. Otherwise, distinct accounts would be merged into a
* single one.
*/
private getAccountToReuse({
accountWithBalances,
accountsWithBalancesDto,
existingAccountsOfUser
}: {
accountWithBalances: CreateAccountWithBalancesDto;
accountsWithBalancesDto: ImportDataDto['accounts'];
existingAccountsOfUser: Account[];
}): Account {
const matchingAccountsOfUser = existingAccountsOfUser.filter(
({ currency, name }) => {
return (
currency === accountWithBalances.currency &&
name === accountWithBalances.name
);
}
);
const matchingAccountsToImport = accountsWithBalancesDto.filter(
({ currency, name }) => {
return (
currency === accountWithBalances.currency &&
name === accountWithBalances.name
);
}
);
if (
matchingAccountsOfUser.length !== 1 ||
matchingAccountsToImport.length !== 1
) {
return undefined;
}
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[]) {
const uniqueAccountIds = new Set<string>();
for (const { id } of accounts) {
uniqueAccountIds.add(id);
}
return uniqueAccountIds.size === 1;
}
}