Browse Source

Add tag support for activities import

pull/4771/head
Terry Casper 1 month ago
parent
commit
616586c8f3
  1. 2
      apps/api/src/app/import/import.module.ts
  2. 181
      apps/api/src/app/import/import.service.ts
  3. 24
      apps/client/src/app/services/import-activities.service.ts

2
apps/api/src/app/import/import.module.ts

@ -12,6 +12,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
@ -33,6 +34,7 @@ import { ImportService } from './import.service';
PrismaModule,
RedisCacheModule,
SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],

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

@ -12,12 +12,14 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 } from '@ghostfolio/common/config';
import {
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
AccountWithPlatform,
OrderWithAccount,
@ -25,7 +27,7 @@ import {
} from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { DataSource, Prisma, SymbolProfile, Tag } from '@prisma/client';
import { Big } from 'big.js';
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
import { uniqBy } from 'lodash';
@ -41,7 +43,8 @@ export class ImportService {
private readonly orderService: OrderService,
private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService,
private readonly symbolProfileService: SymbolProfileService
private readonly symbolProfileService: SymbolProfileService,
private readonly tagService: TagService
) {}
public async getDividends({
@ -151,6 +154,7 @@ export class ImportService {
user: UserWithSettings;
}): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {};
const tagIdMapping: { [oldTagId: string]: string } = {};
const userCurrency = user.Settings.settings.baseCurrency;
if (!isDryRun && accountsDto?.length) {
@ -214,6 +218,50 @@ export class ImportService {
}
}
// Handle tags before activities
if (!isDryRun) {
const existingTags = await this.tagService.getTags({
where: {
OR: [
{ userId: user.id },
{ userId: null }
]
}
});
const canCreateOwnTag = hasPermission(user.permissions, permissions.createOwnTag);
const canCreateTag = hasPermission(user.permissions, permissions.createTag);
for (const activity of activitiesDto) {
if (activity.tags?.length > 0) {
const newTags = [];
for (const tag of activity.tags) {
// Check if tag exists
const existingTag = existingTags.find((t) =>
t.id === tag.id || t.name.toLowerCase() === tag.name?.toLowerCase()
);
if (existingTag) {
// Map existing tag ID
if (tag.id && tag.id !== existingTag.id) {
tagIdMapping[tag.id] = existingTag.id;
}
} else if (tag.name) {
// Create new tag if permissions allow
if (canCreateOwnTag || canCreateTag) {
const newTag = await this.tagService.createTag({
name: tag.name,
userId: canCreateOwnTag ? user.id : null
});
tagIdMapping[tag.id] = newTag.id;
existingTags.push(newTag);
}
}
}
}
}
}
for (const activity of activitiesDto) {
if (!activity.dataSource) {
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(activity.type)) {
@ -229,6 +277,16 @@ export class ImportService {
if (Object.keys(accountIdMapping).includes(activity.accountId)) {
activity.accountId = accountIdMapping[activity.accountId];
}
// Update tag IDs with new mappings
if (activity.tags?.length > 0) {
activity.tags = activity.tags.map(tag => {
if (tag.id && tagIdMapping[tag.id]) {
return { ...tag, id: tagIdMapping[tag.id] };
}
return tag;
});
}
}
}
@ -444,76 +502,65 @@ export class ImportService {
userCurrency: string;
userId: string;
}): Promise<Partial<Activity>[]> {
const { activities: existingActivities } =
await this.orderService.getOrders({
userCurrency,
userId,
includeDrafts: true,
withExcludedAccounts: true
const activities: Partial<Activity>[] = [];
const assetProfiles = await this.validateActivities({
activitiesDto,
maxActivitiesToImport: activitiesDto.length,
user: { id: userId } as UserWithSettings
});
const accounts = await this.accountService.getAccounts(userId);
for (const activity of activitiesDto) {
const assetProfile =
assetProfiles[
getAssetProfileIdentifier({
dataSource: activity.dataSource,
symbol: activity.symbol
})
];
const account = accounts.find(({ id }) => {
return id === activity.accountId;
});
return activitiesDto.map(
({
accountId,
comment,
currency,
dataSource,
date: dateString,
fee,
quantity,
symbol,
type,
unitPrice
}) => {
const date = parseISO(dateString);
const isDuplicate = existingActivities.some((activity) => {
return (
activity.accountId === accountId &&
activity.comment === comment &&
(activity.currency === currency ||
activity.SymbolProfile.currency === currency) &&
activity.SymbolProfile.dataSource === dataSource &&
isSameSecond(activity.date, date) &&
activity.fee === fee &&
activity.quantity === quantity &&
activity.SymbolProfile.symbol === symbol &&
activity.type === type &&
activity.unitPrice === unitPrice
);
let error: ActivityError;
if (activity.type === 'DIVIDEND') {
const isDuplicate = await this.orderService.hasDuplicateOrder({
accountId: activity.accountId,
date: parseISO(activity.date),
fee: activity.fee,
quantity: activity.quantity,
symbol: activity.symbol,
type: activity.type,
unitPrice: activity.unitPrice,
userId
});
const error: ActivityError = isDuplicate
? { code: 'IS_DUPLICATE' }
: undefined;
return {
accountId,
comment,
currency,
date,
error,
fee,
quantity,
type,
unitPrice,
SymbolProfile: {
dataSource,
symbol,
activitiesCount: undefined,
assetClass: undefined,
assetSubClass: undefined,
countries: undefined,
createdAt: undefined,
currency: undefined,
holdings: undefined,
id: undefined,
isActive: true,
sectors: undefined,
updatedAt: undefined
}
};
if (isDuplicate) {
error = { code: 'IS_DUPLICATE' };
}
}
);
const value = new Big(activity.quantity).mul(activity.unitPrice).toNumber();
activities.push({
...activity,
Account: account,
error,
feeInBaseCurrency: activity.fee,
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: assetProfile,
tags: activity.tags,
unitPriceInAssetProfileCurrency: activity.unitPrice,
value,
valueInBaseCurrency: value
});
}
return activities;
}
private isUniqueAccount(accounts: AccountWithPlatform[]) {

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

@ -23,6 +23,7 @@ export class ImportActivitiesService {
private static FEE_KEYS = ['commission', 'fee', 'ibcommission'];
private static QUANTITY_KEYS = ['qty', 'quantity', 'shares', 'units'];
private static SYMBOL_KEYS = ['code', 'symbol', 'ticker'];
private static TAGS_KEYS = ['tags', 'labels'];
private static TYPE_KEYS = ['action', 'buy/sell', 'type'];
private static UNIT_PRICE_KEYS = [
'price',
@ -61,6 +62,7 @@ export class ImportActivitiesService {
fee: this.parseFee({ content, index, item }),
quantity: this.parseQuantity({ content, index, item }),
symbol: this.parseSymbol({ content, index, item }),
tags: this.parseTags({ item }),
type: this.parseType({ content, index, item }),
unitPrice: this.parseUnitPrice({ content, index, item }),
updateAccountBalance: false
@ -131,6 +133,7 @@ export class ImportActivitiesService {
fee,
quantity,
SymbolProfile,
tags,
type,
unitPrice,
updateAccountBalance
@ -140,6 +143,7 @@ export class ImportActivitiesService {
comment,
fee,
quantity,
tags,
type,
unitPrice,
updateAccountBalance,
@ -384,6 +388,26 @@ export class ImportActivitiesService {
};
}
private parseTags({ item }: { item: any }): { name: string }[] {
item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.TAGS_KEYS) {
if (item[key]) {
// Handle both comma-separated strings and arrays
const tagValues = Array.isArray(item[key])
? item[key]
: item[key].toString().split(',');
return tagValues
.map(tag => tag.trim())
.filter(tag => tag.length > 0)
.map(tag => ({ name: tag }));
}
}
return [];
}
private postImport(
aImportData: { accounts: CreateAccountDto[]; activities: CreateOrderDto[] },
aIsDryRun = false

Loading…
Cancel
Save