diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eababa9..0c0adb98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added support for importing dividends from a data provider + ## 1.224.0 - 2023-01-04 ### Added diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts index 4976e951..4a9ef509 100644 --- a/apps/api/src/app/import/import.controller.ts +++ b/apps/api/src/app/import/import.controller.ts @@ -1,19 +1,26 @@ +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ImportResponse } from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, Controller, + Get, HttpException, Inject, Logger, + Param, Post, Query, - UseGuards + UseGuards, + UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +import { DataSource } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { isEmpty } from 'lodash'; import { ImportDataDto } from './import-data.dto'; import { ImportService } from './import.service'; @@ -74,4 +81,23 @@ export class ImportController { ); } } + + @Get('dividends/:dataSource/:symbol') + @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async gatherDividends( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const userCurrency = this.request.user.Settings.settings.baseCurrency; + + const activities = await this.importService.getDividends({ + dataSource, + symbol, + userCurrency + }); + + return { activities }; + } } diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts index 64b3a79f..b344abff 100644 --- a/apps/api/src/app/import/import.module.ts +++ b/apps/api/src/app/import/import.module.ts @@ -1,12 +1,14 @@ import { AccountModule } from '@ghostfolio/api/app/account/account.module'; import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; 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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { Module } from '@nestjs/common'; import { ImportController } from './import.controller'; @@ -22,8 +24,10 @@ import { ImportService } from './import.service'; DataProviderModule, ExchangeRateDataModule, OrderModule, + PortfolioModule, PrismaModule, - RedisCacheModule + RedisCacheModule, + SymbolProfileModule ], providers: [ImportService] }) diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 78e857e7..d3be33bb 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -2,9 +2,16 @@ 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 { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; +import { parseDate } from '@ghostfolio/common/helper'; +import { UniqueAsset } from '@ghostfolio/common/interfaces'; +import { + AccountWithPlatform, + OrderWithAccount +} from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { SymbolProfile } from '@prisma/client'; import Big from 'big.js'; @@ -17,9 +24,81 @@ export class ImportService { private readonly accountService: AccountService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, - private readonly orderService: OrderService + private readonly orderService: OrderService, + private readonly portfolioService: PortfolioService, + private readonly symbolProfileService: SymbolProfileService ) {} + public async getDividends({ + dataSource, + symbol, + userCurrency + }: UniqueAsset & { userCurrency: string }): Promise { + try { + const { firstBuyDate, historicalData, orders } = + await this.portfolioService.getPosition(dataSource, undefined, symbol); + + const [[assetProfile], dividends] = await Promise.all([ + this.symbolProfileService.getSymbolProfiles([ + { + dataSource, + symbol + } + ]), + await this.dataProviderService.getDividends({ + dataSource, + symbol, + from: parseDate(firstBuyDate), + granularity: 'day', + to: new Date() + }) + ]); + + const accounts = orders.map((order) => { + return order.Account; + }); + + const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; + + return Object.entries(dividends).map(([dateString, { marketPrice }]) => { + const quantity = + historicalData.find((historicalDataItem) => { + return historicalDataItem.date === dateString; + })?.quantity ?? 0; + + const value = new Big(quantity).mul(marketPrice).toNumber(); + + return { + Account, + quantity, + value, + accountId: Account?.id, + accountUserId: undefined, + comment: undefined, + createdAt: undefined, + date: parseDate(dateString), + fee: 0, + feeInBaseCurrency: 0, + id: assetProfile.id, + isDraft: false, + SymbolProfile: (assetProfile), + symbolProfileId: assetProfile.id, + type: 'DIVIDEND', + unitPrice: marketPrice, + updatedAt: undefined, + userId: Account?.userId, + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + value, + assetProfile.currency, + userCurrency + ) + }; + }); + } catch { + return []; + } + } + public async import({ activitiesDto, isDryRun = false, @@ -161,6 +240,16 @@ export class ImportService { return activities; } + private isUniqueAccount(accounts: AccountWithPlatform[]) { + const uniqueAccountIds = new Set(); + + for (const account of accounts) { + uniqueAccountIds.add(account.id); + } + + return uniqueAccountIds.size === 1; + } + private async validateActivities({ activitiesDto, maxActivitiesToImport, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 02b95ab5..37fae84d 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -660,8 +660,9 @@ export class PortfolioService { } const positionCurrency = orders[0].SymbolProfile.currency; - const [SymbolProfile] = - await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]); + const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource: aDataSource, symbol: aSymbol } + ]); const portfolioOrders: PortfolioOrder[] = orders .filter((order) => { @@ -745,6 +746,7 @@ export class PortfolioService { historicalDataArray.push({ averagePrice: orders[0].unitPrice, date: firstBuyDate, + quantity: orders[0].quantity, value: orders[0].unitPrice }); } @@ -761,6 +763,7 @@ export class PortfolioService { j++; } let currentAveragePrice = 0; + let currentQuantity = 0; const currentSymbol = transactionPoints[j].items.find( (item) => item.symbol === aSymbol ); @@ -768,11 +771,13 @@ export class PortfolioService { currentAveragePrice = currentSymbol.quantity.eq(0) ? 0 : currentSymbol.investment.div(currentSymbol.quantity).toNumber(); + currentQuantity = currentSymbol.quantity.toNumber(); } historicalDataArray.push({ date, averagePrice: currentAveragePrice, + quantity: currentQuantity, value: marketPrice }); diff --git a/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts index 41bd715b..481bd0cc 100644 --- a/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts +++ b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts @@ -37,6 +37,20 @@ export class AlphaVantageService implements DataProviderInterface { }; } + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return {}; + } + public async getHistorical( aSymbol: string, aGranularity: Granularity = 'day', diff --git a/apps/api/src/services/data-provider/data-provider.module.ts b/apps/api/src/services/data-provider/data-provider.module.ts index 3fa56e06..07982e69 100644 --- a/apps/api/src/services/data-provider/data-provider.module.ts +++ b/apps/api/src/services/data-provider/data-provider.module.ts @@ -59,6 +59,10 @@ import { DataProviderService } from './data-provider.service'; ] } ], - exports: [DataProviderService, GhostfolioScraperApiService] + exports: [ + DataProviderService, + GhostfolioScraperApiService, + YahooFinanceService + ] }) export class DataProviderModule {} diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 7092e111..0173dc82 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -23,6 +23,27 @@ export class DataProviderService { private readonly prismaService: PrismaService ) {} + public async getDividends({ + dataSource, + from, + granularity = 'day', + symbol, + to + }: { + dataSource: DataSource; + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return this.getDataProvider(DataSource[dataSource]).getDividends({ + from, + granularity, + symbol, + to + }); + } + public async getHistorical( aItems: IDataGatheringItem[], aGranularity: Granularity = 'month', diff --git a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts index c87c6ec3..cbfd67e2 100644 --- a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts +++ b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts @@ -37,6 +37,20 @@ export class EodHistoricalDataService implements DataProviderInterface { }; } + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return {}; + } + public async getHistorical( aSymbol: string, aGranularity: Granularity = 'day', diff --git a/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts b/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts index 8da34410..7412fec7 100644 --- a/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts +++ b/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts @@ -37,6 +37,20 @@ export class GhostfolioScraperApiService implements DataProviderInterface { }; } + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return {}; + } + public async getHistorical( aSymbol: string, aGranularity: Granularity = 'day', diff --git a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts index cc6af524..201d57aa 100644 --- a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts +++ b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts @@ -34,6 +34,20 @@ export class GoogleSheetsService implements DataProviderInterface { }; } + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return {}; + } + public async getHistorical( aSymbol: string, aGranularity: Granularity = 'day', diff --git a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts index 6719f309..c51adb98 100644 --- a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts +++ b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts @@ -11,6 +11,18 @@ export interface DataProviderInterface { getAssetProfile(aSymbol: string): Promise>; + getDividends({ + from, + granularity, + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }): Promise<{ [date: string]: IDataProviderHistoricalResponse }>; + getHistorical( aSymbol: string, aGranularity: Granularity, diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts index a364276e..7b605108 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -29,6 +29,20 @@ export class ManualService implements DataProviderInterface { }; } + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return {}; + } + public async getHistorical( aSymbol: string, aGranularity: Granularity = 'day', diff --git a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts index 1bf05774..f5119e0b 100644 --- a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts +++ b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts @@ -31,6 +31,20 @@ export class RapidApiService implements DataProviderInterface { }; } + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return {}; + } + public async getHistorical( aSymbol: string, aGranularity: Granularity = 'day', diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts index 4e62b251..bccc5c64 100644 --- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts @@ -160,6 +160,59 @@ export class YahooFinanceService implements DataProviderInterface { return response; } + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: { + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + if (isSameDay(from, to)) { + to = addDays(to, 1); + } + + try { + const historicalResult = await yahooFinance.historical( + this.convertToYahooFinanceSymbol(symbol), + { + events: 'dividends', + interval: granularity === 'month' ? '1mo' : '1d', + period1: format(from, DATE_FORMAT), + period2: format(to, DATE_FORMAT) + } + ); + + const response: { + [date: string]: IDataProviderHistoricalResponse; + } = {}; + + for (const historicalItem of historicalResult) { + response[format(historicalItem.date, DATE_FORMAT)] = { + marketPrice: this.getConvertedValue({ + symbol, + value: historicalItem.dividends + }) + }; + } + + return response; + } catch (error) { + Logger.error( + `Could not get dividends for ${symbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, + 'YahooFinanceService' + ); + + return {}; + } + } + public async getHistorical( aSymbol: string, aGranularity: Granularity = 'day', @@ -172,11 +225,9 @@ export class YahooFinanceService implements DataProviderInterface { to = addDays(to, 1); } - const yahooFinanceSymbol = this.convertToYahooFinanceSymbol(aSymbol); - try { const historicalResult = await yahooFinance.historical( - yahooFinanceSymbol, + this.convertToYahooFinanceSymbol(aSymbol), { interval: '1d', period1: format(from, DATE_FORMAT), @@ -188,27 +239,14 @@ export class YahooFinanceService implements DataProviderInterface { [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; } = {}; - // Convert symbol back - const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol); - - response[symbol] = {}; + response[aSymbol] = {}; for (const historicalItem of historicalResult) { - let marketPrice = historicalItem.close; - - if (symbol === `${this.baseCurrency}GBp`) { - // Convert GPB to GBp (pence) - marketPrice = new Big(marketPrice).mul(100).toNumber(); - } else if (symbol === `${this.baseCurrency}ILA`) { - // Convert ILS to ILA - marketPrice = new Big(marketPrice).mul(100).toNumber(); - } else if (symbol === `${this.baseCurrency}ZAc`) { - // Convert ZAR to ZAc (cents) - marketPrice = new Big(marketPrice).mul(100).toNumber(); - } - - response[symbol][format(historicalItem.date, DATE_FORMAT)] = { - marketPrice, + response[aSymbol][format(historicalItem.date, DATE_FORMAT)] = { + marketPrice: this.getConvertedValue({ + symbol: aSymbol, + value: historicalItem.close + }), performance: historicalItem.open - historicalItem.close }; } @@ -423,6 +461,27 @@ export class YahooFinanceService implements DataProviderInterface { return name || shortName || symbol; } + private getConvertedValue({ + symbol, + value + }: { + symbol: string; + value: number; + }) { + if (symbol === `${this.baseCurrency}GBp`) { + // Convert GPB to GBp (pence) + return new Big(value).mul(100).toNumber(); + } else if (symbol === `${this.baseCurrency}ILA`) { + // Convert ILS to ILA + return new Big(value).mul(100).toNumber(); + } else if (symbol === `${this.baseCurrency}ZAc`) { + // Convert ZAR to ZAc (cents) + return new Big(value).mul(100).toNumber(); + } + + return value; + } + private parseAssetClass(aPrice: Price): { assetClass: AssetClass; assetSubClass: AssetSubClass; diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts index ef0a21d4..41d8abae 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts @@ -11,7 +11,7 @@ import { IcsService } from '@ghostfolio/client/services/ics/ics.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { downloadAsFile } from '@ghostfolio/common/helper'; -import { User } from '@ghostfolio/common/interfaces'; +import { UniqueAsset, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { DataSource, Order as OrderModel } from '@prisma/client'; import { format, parseISO } from 'date-fns'; @@ -198,6 +198,24 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit { }); } + public onImportDividends() { + const dialogRef = this.dialog.open(ImportActivitiesDialog, { + data: { + activityTypes: ['DIVIDEND'], + deviceType: this.deviceType, + user: this.user + }, + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef + .afterClosed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.fetchActivities(); + }); + } + public onUpdateActivity(aActivity: OrderModel) { this.router.navigate([], { queryParams: { activityId: aActivity.id, editDialog: true } diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.html b/apps/client/src/app/pages/portfolio/activities/activities-page.html index b810d777..cec456c5 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.html +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.html @@ -17,6 +17,7 @@ (export)="onExport($event)" (exportDrafts)="onExportDrafts($event)" (import)="onImport()" + (importDividends)="onImportDividends()" > diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index 6a0acfb6..77ef8dfe 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -5,12 +5,16 @@ import { Inject, OnDestroy } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { DataService } from '@ghostfolio/client/services/data.service'; import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service'; -import { isArray } from 'lodash'; -import { Subject } from 'rxjs'; +import { Position } from '@ghostfolio/common/interfaces'; +import { AssetClass } from '@prisma/client'; +import { isArray, sortBy } from 'lodash'; +import { Subject, takeUntil } from 'rxjs'; import { ImportActivitiesDialogParams } from './interfaces/interfaces'; @@ -24,20 +28,55 @@ export class ImportActivitiesDialog implements OnDestroy { public activities: Activity[] = []; public details: any[] = []; public errorMessages: string[] = []; + public holdings: Position[] = []; public isFileSelected = false; + public mode: 'DIVIDEND'; public selectedActivities: Activity[] = []; + public uniqueAssetForm: FormGroup; private unsubscribeSubject = new Subject(); public constructor( private changeDetectorRef: ChangeDetectorRef, @Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams, + private dataService: DataService, + private formBuilder: FormBuilder, public dialogRef: MatDialogRef, private importActivitiesService: ImportActivitiesService, private snackBar: MatSnackBar ) {} - public ngOnInit() {} + public ngOnInit() { + this.uniqueAssetForm = this.formBuilder.group({ + uniqueAsset: [undefined, Validators.required] + }); + + if ( + this.data?.activityTypes?.length === 1 && + this.data?.activityTypes?.[0] === 'DIVIDEND' + ) { + this.mode = 'DIVIDEND'; + + this.dataService + .fetchPositions({ + filters: [ + { + id: AssetClass.EQUITY, + type: 'ASSET_CLASS' + } + ], + range: 'max' + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ positions }) => { + this.holdings = sortBy(positions, ({ name }) => { + return name.toLowerCase(); + }); + + this.changeDetectorRef.markForCheck(); + }); + } + } public onCancel(): void { this.dialogRef.close(); @@ -71,6 +110,24 @@ export class ImportActivitiesDialog implements OnDestroy { } } + public onLoadDividends() { + const { dataSource, symbol } = + this.uniqueAssetForm.controls['uniqueAsset'].value; + + this.dataService + .fetchDividendsImport({ + dataSource, + symbol + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ activities }) => { + this.activities = activities; + this.isFileSelected = true; + + this.changeDetectorRef.markForCheck(); + }); + } + public onReset() { this.details = []; this.errorMessages = []; @@ -95,8 +152,6 @@ export class ImportActivitiesDialog implements OnDestroy { reader.onload = async (readerEvent) => { const fileContent = readerEvent.target.result as string; - console.log(fileContent); - try { if (file.name.endsWith('.json')) { const content = JSON.parse(fileContent); diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html index 7b5ad648..7f143f90 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html @@ -7,31 +7,59 @@
-
- -

- The following file formats are supported: - CSV +

+ + Holding + + {{ holding.name }} + + +
+ +
+
+ + +
+
+ + Choose File + +

+ The following file formats are supported: + CSV + or + JSON +

+
+
@@ -47,6 +75,7 @@ [locale]="data?.user?.settings?.locale" [showActions]="false" [showCheckbox]="true" + [showFooter]="false" [showSymbolColumn]="false" (selectedActivities)="updateSelection($event)" > diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.module.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.module.ts index cba5842f..fdae625f 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.module.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.module.ts @@ -1,8 +1,11 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatDialogModule } from '@angular/material/dialog'; import { MatExpansionModule } from '@angular/material/expansion'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module'; @@ -13,12 +16,16 @@ import { ImportActivitiesDialog } from './import-activities-dialog.component'; declarations: [ImportActivitiesDialog], imports: [ CommonModule, + FormsModule, GfActivitiesTableModule, GfDialogFooterModule, GfDialogHeaderModule, MatButtonModule, MatDialogModule, - MatExpansionModule + MatExpansionModule, + MatFormFieldModule, + MatSelectModule, + ReactiveFormsModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/interfaces/interfaces.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/interfaces/interfaces.ts index 5141ed11..755a50ba 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/interfaces/interfaces.ts @@ -1,6 +1,8 @@ import { User } from '@ghostfolio/common/interfaces'; +import { Type } from '@prisma/client'; export interface ImportActivitiesDialogParams { + activityTypes: Type[]; deviceType: string; user: User; } diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 70c15d39..67a3e18c 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -24,6 +24,7 @@ import { BenchmarkResponse, Export, Filter, + ImportResponse, InfoItem, OAuthResponse, PortfolioDetails, @@ -119,6 +120,12 @@ export class DataService { }); } + public fetchDividendsImport({ dataSource, symbol }: UniqueAsset) { + return this.http.get( + `/api/v1/import/dividends/${dataSource}/${symbol}` + ); + } + public fetchExchangeRateForDate({ date, symbol diff --git a/apps/client/src/app/services/import-activities.service.ts b/apps/client/src/app/services/import-activities.service.ts index 17a6879a..2e15f367 100644 --- a/apps/client/src/app/services/import-activities.service.ts +++ b/apps/client/src/app/services/import-activities.service.ts @@ -90,13 +90,16 @@ export class ImportActivitiesService { selectedActivities: Activity[] ): Promise { const importData: CreateOrderDto[] = []; + for (const activity of selectedActivities) { importData.push(this.convertToCreateOrderDto(activity)); } + return this.importJson({ content: importData }); } private convertToCreateOrderDto({ + accountId, date, fee, quantity, @@ -105,6 +108,7 @@ export class ImportActivitiesService { unitPrice }: Activity): CreateOrderDto { return { + accountId, fee, quantity, type, diff --git a/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts b/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts index d2053bb7..5b72d9ce 100644 --- a/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts +++ b/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts @@ -8,7 +8,7 @@ export interface EnhancedSymbolProfile { activitiesCount: number; assetClass: AssetClass; assetSubClass: AssetSubClass; - comment?: string; + comment: string | null; countries: Country[]; createdAt: Date; currency: string | null; diff --git a/libs/common/src/lib/interfaces/historical-data-item.interface.ts b/libs/common/src/lib/interfaces/historical-data-item.interface.ts index dd7bb84d..59a53ee9 100644 --- a/libs/common/src/lib/interfaces/historical-data-item.interface.ts +++ b/libs/common/src/lib/interfaces/historical-data-item.interface.ts @@ -4,6 +4,7 @@ export interface HistoricalDataItem { grossPerformancePercent?: number; netPerformance?: number; netPerformanceInPercentage?: number; + quantity?: number; totalInvestment?: number; value?: number; } diff --git a/libs/common/src/lib/types/account-with-platform.type.ts b/libs/common/src/lib/types/account-with-platform.type.ts new file mode 100644 index 00000000..b0730abc --- /dev/null +++ b/libs/common/src/lib/types/account-with-platform.type.ts @@ -0,0 +1,3 @@ +import { Account, Platform } from '@prisma/client'; + +export type AccountWithPlatform = Account & { Platform?: Platform }; diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts index d70295b5..255a1c3f 100644 --- a/libs/common/src/lib/types/index.ts +++ b/libs/common/src/lib/types/index.ts @@ -1,4 +1,5 @@ import type { AccessWithGranteeUser } from './access-with-grantee-user.type'; +import { AccountWithPlatform } from './account-with-platform.type'; import { AccountWithValue } from './account-with-value.type'; import type { ColorScheme } from './color-scheme'; import type { DateRange } from './date-range.type'; @@ -13,6 +14,7 @@ import type { ViewMode } from './view-mode.type'; export type { AccessWithGranteeUser, + AccountWithPlatform, AccountWithValue, ColorScheme, DateRange, diff --git a/libs/common/src/lib/types/order-with-account.type.ts b/libs/common/src/lib/types/order-with-account.type.ts index 09c64a28..af880309 100644 --- a/libs/common/src/lib/types/order-with-account.type.ts +++ b/libs/common/src/lib/types/order-with-account.type.ts @@ -1,6 +1,6 @@ -import { Account, Order, Platform, SymbolProfile, Tag } from '@prisma/client'; +import { Order, SymbolProfile, Tag } from '@prisma/client'; -type AccountWithPlatform = Account & { Platform?: Platform }; +import { AccountWithPlatform } from './account-with-platform.type'; export type OrderWithAccount = Order & { Account?: AccountWithPlatform; diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index 462b412b..d2dbb95e 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -117,7 +117,7 @@
- {{ element.SymbolProfile.name }} + {{ element.SymbolProfile?.name }}
-
+
{{ - element.SymbolProfile.symbol | gfSymbol + element.SymbolProfile?.symbol | gfSymbol }}
@@ -149,7 +149,7 @@ class="d-none d-lg-table-cell px-1" mat-cell > - {{ element.SymbolProfile.currency }} + {{ element.SymbolProfile?.currency }} {{ baseCurrency }} @@ -388,6 +388,14 @@ Import Activities +
diff --git a/libs/ui/src/lib/activities-table/activities-table.component.ts b/libs/ui/src/lib/activities-table/activities-table.component.ts index 1930072e..167450d2 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.ts +++ b/libs/ui/src/lib/activities-table/activities-table.component.ts @@ -41,8 +41,9 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { @Input() hasPermissionToOpenDetails = true; @Input() locale: string; @Input() pageSize = DEFAULT_PAGE_SIZE; - @Input() showActions: boolean; + @Input() showActions = true; @Input() showCheckbox = false; + @Input() showFooter = true; @Input() showNameColumn = true; @Output() activityDeleted = new EventEmitter(); @@ -51,6 +52,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { @Output() export = new EventEmitter(); @Output() exportDrafts = new EventEmitter(); @Output() import = new EventEmitter(); + @Output() importDividends = new EventEmitter(); @Output() selectedActivities = new EventEmitter(); @ViewChild(MatPaginator) paginator: MatPaginator; @@ -233,6 +235,10 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { this.import.emit(); } + public onImportDividends() { + this.importDividends.emit(); + } + public onOpenComment(aComment: string) { alert(aComment); } @@ -272,13 +278,18 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { }; } - fieldValueMap[activity.SymbolProfile.currency] = { - id: activity.SymbolProfile.currency, - label: activity.SymbolProfile.currency, - type: 'TAG' - }; + if (activity.SymbolProfile?.currency) { + fieldValueMap[activity.SymbolProfile.currency] = { + id: activity.SymbolProfile.currency, + label: activity.SymbolProfile.currency, + type: 'TAG' + }; + } - if (!isUUID(activity.SymbolProfile.symbol)) { + if ( + activity.SymbolProfile?.symbol && + !isUUID(activity.SymbolProfile.symbol) + ) { fieldValueMap[activity.SymbolProfile.symbol] = { id: activity.SymbolProfile.symbol, label: activity.SymbolProfile.symbol,