diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a6e164eb..01c98d885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added the position detail dialog to the _Top 3_ and _Bottom 3_ performers of the analysis page +- Added the `dryRun` option to the import activities endpoint ### Changed diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts index 422e1cb9f..4976e9511 100644 --- a/apps/api/src/app/import/import.controller.ts +++ b/apps/api/src/app/import/import.controller.ts @@ -1,4 +1,5 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { ImportResponse } from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -7,6 +8,7 @@ import { Inject, Logger, Post, + Query, UseGuards } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; @@ -26,7 +28,10 @@ export class ImportController { @Post() @UseGuards(AuthGuard('jwt')) - public async import(@Body() importData: ImportDataDto): Promise { + public async import( + @Body() importData: ImportDataDto, + @Query('dryRun') isDryRun?: boolean + ): Promise { if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), @@ -45,12 +50,18 @@ export class ImportController { maxActivitiesToImport = Number.MAX_SAFE_INTEGER; } + const userCurrency = this.request.user.Settings.settings.baseCurrency; + try { - return await this.importService.import({ + const activities = await this.importService.import({ maxActivitiesToImport, - activities: importData.activities, + isDryRun, + userCurrency, + activitiesDto: importData.activities, userId: this.request.user.id }); + + return { activities }; } catch (error) { Logger.error(error, ImportController); diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts index 62d227bf5..64b3a79f3 100644 --- a/apps/api/src/app/import/import.module.ts +++ b/apps/api/src/app/import/import.module.ts @@ -5,6 +5,7 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { Module } from '@nestjs/common'; @@ -19,6 +20,7 @@ import { ImportService } from './import.service'; ConfigurationModule, DataGatheringModule, DataProviderModule, + ExchangeRateDataModule, OrderModule, PrismaModule, RedisCacheModule diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 7617b8cb3..ea6eea9ba 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -1,30 +1,38 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; -import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; +import { OrderWithAccount } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; -import { isSameDay, parseISO } from 'date-fns'; +import Big from 'big.js'; +import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns'; +import { v4 as uuidv4 } from 'uuid'; @Injectable() export class ImportService { public constructor( private readonly accountService: AccountService, - private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, + private readonly exchangeRateDataService: ExchangeRateDataService, private readonly orderService: OrderService ) {} public async import({ - activities, + activitiesDto, + isDryRun = false, maxActivitiesToImport, + userCurrency, userId }: { - activities: Partial[]; + activitiesDto: Partial[]; + isDryRun?: boolean; maxActivitiesToImport: number; + userCurrency: string; userId: string; - }): Promise { - for (const activity of activities) { + }): Promise { + for (const activity of activitiesDto) { if (!activity.dataSource) { if (activity.type === 'ITEM') { activity.dataSource = 'MANUAL'; @@ -35,7 +43,7 @@ export class ImportService { } await this.validateActivities({ - activities, + activitiesDto, maxActivitiesToImport, userId }); @@ -46,57 +54,121 @@ export class ImportService { } ); + const activities: Activity[] = []; + for (const { accountId, comment, currency, dataSource, - date, + date: dateString, fee, quantity, symbol, type, unitPrice - } of activities) { - await this.orderService.createOrder({ - comment, - fee, - quantity, - type, - unitPrice, - userId, - accountId: accountIds.includes(accountId) ? accountId : undefined, - date: parseISO((date)), - SymbolProfile: { - connectOrCreate: { - create: { - currency, - dataSource, - symbol - }, - where: { - dataSource_symbol: { + } of activitiesDto) { + const date = parseISO((dateString)); + const validatedAccountId = accountIds.includes(accountId) + ? accountId + : undefined; + + let order: OrderWithAccount; + + if (isDryRun) { + order = { + comment, + date, + fee, + quantity, + type, + unitPrice, + userId, + accountId: validatedAccountId, + accountUserId: undefined, + createdAt: new Date(), + id: uuidv4(), + isDraft: isAfter(date, endOfToday()), + SymbolProfile: { + currency, + dataSource, + symbol, + assetClass: null, + assetSubClass: null, + comment: null, + countries: null, + createdAt: undefined, + id: undefined, + name: null, + scraperConfiguration: null, + sectors: null, + symbolMapping: null, + updatedAt: undefined, + url: null + }, + symbolProfileId: undefined, + updatedAt: new Date() + }; + } else { + order = await this.orderService.createOrder({ + comment, + date, + fee, + quantity, + type, + unitPrice, + userId, + accountId: validatedAccountId, + SymbolProfile: { + connectOrCreate: { + create: { + currency, dataSource, symbol + }, + where: { + dataSource_symbol: { + dataSource, + symbol + } } } - } - }, - User: { connect: { id: userId } } + }, + User: { connect: { id: userId } } + }); + } + + const value = new Big(quantity).mul(unitPrice).toNumber(); + + activities.push({ + ...order, + value, + feeInBaseCurrency: this.exchangeRateDataService.toCurrency( + fee, + currency, + userCurrency + ), + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + value, + currency, + userCurrency + ) }); } + + return activities; } private async validateActivities({ - activities, + activitiesDto, maxActivitiesToImport, userId }: { - activities: Partial[]; + activitiesDto: Partial[]; maxActivitiesToImport: number; userId: string; }) { - if (activities?.length > maxActivitiesToImport) { + if (activitiesDto?.length > maxActivitiesToImport) { throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); } @@ -109,7 +181,7 @@ export class ImportService { for (const [ index, { currency, dataSource, date, fee, quantity, symbol, type, unitPrice } - ] of activities.entries()) { + ] of activitiesDto.entries()) { const duplicateActivity = existingActivities.find((activity) => { return ( activity.SymbolProfile.currency === currency && diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 416721a40..a8ff96e0f 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -32,6 +32,7 @@ import { PortfolioSummary } from './portfolio-summary.interface'; import { Position } from './position.interface'; import { BenchmarkResponse } from './responses/benchmark-response.interface'; import { ResponseError } from './responses/errors.interface'; +import { ImportResponse } from './responses/import-response.interface'; import { OAuthResponse } from './responses/oauth-response.interface'; import { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface'; import { ScraperConfiguration } from './scraper-configuration.interface'; @@ -58,6 +59,7 @@ export { Filter, FilterGroup, HistoricalDataItem, + ImportResponse, InfoItem, LineChartItem, OAuthResponse, diff --git a/libs/common/src/lib/interfaces/responses/import-response.interface.ts b/libs/common/src/lib/interfaces/responses/import-response.interface.ts new file mode 100644 index 000000000..be2da9837 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/import-response.interface.ts @@ -0,0 +1,5 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; + +export interface ImportResponse { + activities: Activity[]; +}