From 616586c8f33a0f27ad578884c0a220e4ab57e6f6 Mon Sep 17 00:00:00 2001 From: Terry Casper Date: Tue, 27 May 2025 04:18:28 +0600 Subject: [PATCH] Add tag support for activities import --- apps/api/src/app/import/import.module.ts | 2 + apps/api/src/app/import/import.service.ts | 181 +++++++++++------- .../app/services/import-activities.service.ts | 24 +++ 3 files changed, 140 insertions(+), 67 deletions(-) diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts index 142a939a6..4b6893ef3 100644 --- a/apps/api/src/app/import/import.module.ts +++ b/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 ], diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 28c49ca70..7b5e4e491 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/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 { 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[]> { - const { activities: existingActivities } = - await this.orderService.getOrders({ - userCurrency, - userId, - includeDrafts: true, - withExcludedAccounts: true + const activities: Partial[] = []; + + 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[]) { diff --git a/apps/client/src/app/services/import-activities.service.ts b/apps/client/src/app/services/import-activities.service.ts index 2164bd248..d8562a409 100644 --- a/apps/client/src/app/services/import-activities.service.ts +++ b/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