diff --git a/CHANGELOG.md b/CHANGELOG.md index 1467f117a..2389ec01e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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 (wealth) items + +### Todo + +- Apply data migration (`yarn database:migrate`) + ## 1.113.0 - 09.02.2022 ### Changed diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index 301f13cea..b540fe363 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -59,7 +59,7 @@ export class ExportService { type, unitPrice, dataSource: SymbolProfile.dataSource, - symbol: SymbolProfile.symbol + symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol }; } ) diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 92ffcca2c..c365c22f5 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -21,8 +21,13 @@ export class ImportService { userId: string; }): Promise { for (const order of orders) { - order.dataSource = - order.dataSource ?? this.dataProviderService.getPrimaryDataSource(); + if (!order.dataSource) { + if (order.type === 'ITEM') { + order.dataSource = 'MANUAL'; + } else { + order.dataSource = this.dataProviderService.getPrimaryDataSource(); + } + } } await this.validateOrders({ orders, userId }); @@ -111,20 +116,22 @@ export class ImportService { throw new Error(`orders.${index} is a duplicate transaction`); } - const result = await this.dataProviderService.get([ - { dataSource, symbol } - ]); + if (dataSource !== 'MANUAL') { + const result = await this.dataProviderService.get([ + { dataSource, symbol } + ]); - if (result[symbol] === undefined) { - throw new Error( - `orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` - ); - } + if (result[symbol] === undefined) { + throw new Error( + `orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` + ); + } - if (result[symbol].currency !== currency) { - throw new Error( - `orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"` - ); + if (result[symbol].currency !== currency) { + throw new Error( + `orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"` + ); + } } } } diff --git a/apps/api/src/app/order/order.module.ts b/apps/api/src/app/order/order.module.ts index 3f896dc5e..f2c790ce8 100644 --- a/apps/api/src/app/order/order.module.ts +++ b/apps/api/src/app/order/order.module.ts @@ -8,6 +8,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data- import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { Module } from '@nestjs/common'; import { OrderController } from './order.controller'; @@ -22,6 +23,7 @@ import { OrderService } from './order.service'; ImpersonationModule, PrismaModule, RedisCacheModule, + SymbolProfileModule, UserModule ], controllers: [OrderController], diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 2a5e9f230..16971ee38 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -3,11 +3,13 @@ import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { OrderWithAccount } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client'; import Big from 'big.js'; import { endOfToday, isAfter } from 'date-fns'; +import { v4 as uuidv4 } from 'uuid'; import { Activity } from './interfaces/activities.interface'; @@ -18,7 +20,8 @@ export class OrderService { private readonly cacheService: CacheService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly dataGatheringService: DataGatheringService, - private readonly prismaService: PrismaService + private readonly prismaService: PrismaService, + private readonly symbolProfileService: SymbolProfileService ) {} public async order( @@ -58,7 +61,7 @@ export class OrderService { return account.isDefault === true; }); - const Account = { + let Account = { connect: { id_userId: { userId: data.userId, @@ -67,24 +70,47 @@ export class OrderService { } }; - const isDraft = isAfter(data.date as Date, endOfToday()); + if (data.type === 'ITEM') { + const currency = data.currency; + const dataSource: DataSource = 'MANUAL'; + const id = uuidv4(); + const name = data.SymbolProfile.connectOrCreate.create.symbol; + + Account = undefined; + data.dataSource = dataSource; + data.id = id; + data.symbol = null; + data.SymbolProfile.connectOrCreate.create.currency = currency; + data.SymbolProfile.connectOrCreate.create.dataSource = dataSource; + data.SymbolProfile.connectOrCreate.create.name = name; + data.SymbolProfile.connectOrCreate.create.symbol = id; + data.SymbolProfile.connectOrCreate.where.dataSource_symbol = { + dataSource, + symbol: id + }; + } else { + data.SymbolProfile.connectOrCreate.create.symbol = + data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase(); + } - // Convert the symbol to uppercase to avoid case-sensitive duplicates - const symbol = data.symbol.toUpperCase(); + const isDraft = isAfter(data.date as Date, endOfToday()); if (!isDraft) { // Gather symbol data of order in the background, if not draft this.dataGatheringService.gatherSymbols([ { - symbol, dataSource: data.dataSource, - date: data.date + date: data.date, + symbol: data.SymbolProfile.connectOrCreate.create.symbol } ]); } this.dataGatheringService.gatherProfileData([ - { symbol, dataSource: data.dataSource } + { + dataSource: data.dataSource, + symbol: data.SymbolProfile.connectOrCreate.create.symbol + } ]); await this.cacheService.flush(); @@ -98,8 +124,7 @@ export class OrderService { data: { ...orderData, Account, - isDraft, - symbol + isDraft } }); } @@ -107,9 +132,15 @@ export class OrderService { public async deleteOrder( where: Prisma.OrderWhereUniqueInput ): Promise { - return this.prismaService.order.delete({ + const order = await this.prismaService.order.delete({ where }); + + if (order.type === 'ITEM') { + await this.symbolProfileService.deleteById(order.symbolProfileId); + } + + return order; } public async getOrders({ @@ -180,6 +211,17 @@ export class OrderService { }): Promise { const { data, where } = params; + if (data.Account.connect.id_userId.id === null) { + delete data.Account; + } + + if (data.type === 'ITEM') { + const name = data.symbol; + + data.symbol = null; + data.SymbolProfile = { update: { name } }; + } + const isDraft = isAfter(data.date as Date, endOfToday()); if (!isDraft) { diff --git a/apps/api/src/app/order/update-order.dto.ts b/apps/api/src/app/order/update-order.dto.ts index 58a046c5b..180da6dbf 100644 --- a/apps/api/src/app/order/update-order.dto.ts +++ b/apps/api/src/app/order/update-order.dto.ts @@ -1,7 +1,8 @@ import { DataSource, Type } from '@prisma/client'; -import { IsISO8601, IsNumber, IsString } from 'class-validator'; +import { IsISO8601, IsNumber, IsOptional, IsString } from 'class-validator'; export class UpdateOrderDto { + @IsOptional() @IsString() accountId: string; diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 6a2351a49..db4786527 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -332,6 +332,7 @@ export class PortfolioController { 'currentValue', 'dividend', 'fees', + 'items', 'netWorth', 'totalBuy', 'totalSell' diff --git a/apps/api/src/app/portfolio/portfolio.service-new.ts b/apps/api/src/app/portfolio/portfolio.service-new.ts index 4fbbeaff0..04803d14b 100644 --- a/apps/api/src/app/portfolio/portfolio.service-new.ts +++ b/apps/api/src/app/portfolio/portfolio.service-new.ts @@ -891,6 +891,7 @@ export class PortfolioServiceNew { const dividend = this.getDividend(orders).toNumber(); const fees = this.getFees(orders).toNumber(); const firstOrderDate = orders[0]?.date; + const items = this.getItems(orders).toNumber(); const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY'); const totalSell = this.getTotalByType(orders, userCurrency, 'SELL'); @@ -899,6 +900,7 @@ export class PortfolioServiceNew { const netWorth = new Big(balance) .plus(performanceInformation.performance.currentValue) + .plus(items) .toNumber(); const daysInMarket = differenceInDays(new Date(), firstOrderDate); @@ -922,6 +924,7 @@ export class PortfolioServiceNew { dividend, fees, firstOrderDate, + items, netWorth, totalBuy, totalSell, @@ -1043,6 +1046,28 @@ export class PortfolioServiceNew { ); } + private getItems(orders: OrderWithAccount[], date = new Date(0)) { + return orders + .filter((order) => { + // Filter out all orders before given date and type item + return ( + isBefore(date, new Date(order.date)) && + order.type === TypeOfOrder.ITEM + ); + }) + .map((order) => { + return this.exchangeRateDataService.toCurrency( + new Big(order.quantity).mul(order.unitPrice).toNumber(), + order.currency, + this.request.user.Settings.currency + ); + }) + .reduce( + (previous, current) => new Big(previous).plus(current), + new Big(0) + ); + } + private getStartDate(aDateRange: DateRange, portfolioStart: Date) { switch (aDateRange) { case '1d': diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 84ac4c846..4b02e4d0a 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -869,6 +869,7 @@ export class PortfolioService { const dividend = this.getDividend(orders).toNumber(); const fees = this.getFees(orders).toNumber(); const firstOrderDate = orders[0]?.date; + const items = this.getItems(orders).toNumber(); const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY'); const totalSell = this.getTotalByType(orders, userCurrency, 'SELL'); @@ -877,6 +878,7 @@ export class PortfolioService { const netWorth = new Big(balance) .plus(performanceInformation.performance.currentValue) + .plus(items) .toNumber(); return { @@ -884,6 +886,7 @@ export class PortfolioService { dividend, fees, firstOrderDate, + items, netWorth, totalBuy, totalSell, @@ -1007,6 +1010,28 @@ export class PortfolioService { ); } + private getItems(orders: OrderWithAccount[], date = new Date(0)) { + return orders + .filter((order) => { + // Filter out all orders before given date and type item + return ( + isBefore(date, new Date(order.date)) && + order.type === TypeOfOrder.ITEM + ); + }) + .map((order) => { + return this.exchangeRateDataService.toCurrency( + new Big(order.quantity).mul(order.unitPrice).toNumber(), + order.currency, + this.request.user.Settings.currency + ); + }) + .reduce( + (previous, current) => new Big(previous).plus(current), + new Big(0) + ); + } + private getStartDate(aDateRange: DateRange, portfolioStart: Date) { switch (aDateRange) { case '1d': diff --git a/apps/api/src/services/data-gathering.service.ts b/apps/api/src/services/data-gathering.service.ts index 8fe2c4835..81c9c884d 100644 --- a/apps/api/src/services/data-gathering.service.ts +++ b/apps/api/src/services/data-gathering.service.ts @@ -445,6 +445,11 @@ export class DataGatheringService { }, scraperConfiguration: true, symbol: true + }, + where: { + dataSource: { + not: 'MANUAL' + } } }) ).map((symbolProfile) => { @@ -479,6 +484,11 @@ export class DataGatheringService { dataSource: true, scraperConfiguration: true, symbol: true + }, + where: { + dataSource: { + not: 'MANUAL' + } } }); @@ -537,6 +547,7 @@ export class DataGatheringService { return distinctOrders.filter((distinctOrder) => { return ( distinctOrder.dataSource !== DataSource.GHOSTFOLIO && + distinctOrder.dataSource !== DataSource.MANUAL && distinctOrder.dataSource !== DataSource.RAKUTEN ); }); 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 c05570932..e2a77af4a 100644 --- a/apps/api/src/services/data-provider/data-provider.module.ts +++ b/apps/api/src/services/data-provider/data-provider.module.ts @@ -2,6 +2,7 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration.modu import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; +import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; @@ -23,6 +24,7 @@ import { DataProviderService } from './data-provider.service'; DataProviderService, GhostfolioScraperApiService, GoogleSheetsService, + ManualService, RakutenRapidApiService, YahooFinanceService, { @@ -30,6 +32,7 @@ import { DataProviderService } from './data-provider.service'; AlphaVantageService, GhostfolioScraperApiService, GoogleSheetsService, + ManualService, RakutenRapidApiService, YahooFinanceService ], @@ -38,12 +41,14 @@ import { DataProviderService } from './data-provider.service'; alphaVantageService, ghostfolioScraperApiService, googleSheetsService, + manualService, rakutenRapidApiService, yahooFinanceService ) => [ alphaVantageService, ghostfolioScraperApiService, googleSheetsService, + manualService, rakutenRapidApiService, yahooFinanceService ] 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 f051927f9..6ffd5b2dd 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -194,6 +194,7 @@ export class DataProviderService { return dataProviderInterface; } } + throw new Error('No data provider has been found.'); } } diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts new file mode 100644 index 000000000..3a486f897 --- /dev/null +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -0,0 +1,43 @@ +import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; +import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { + IDataProviderHistoricalResponse, + IDataProviderResponse +} from '@ghostfolio/api/services/interfaces/interfaces'; +import { Granularity } from '@ghostfolio/common/types'; +import { Injectable } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; + +@Injectable() +export class ManualService implements DataProviderInterface { + public constructor() {} + + public canHandle(symbol: string) { + return false; + } + + public async get( + aSymbols: string[] + ): Promise<{ [symbol: string]: IDataProviderResponse }> { + return {}; + } + + public async getHistorical( + aSymbols: string[], + aGranularity: Granularity = 'day', + from: Date, + to: Date + ): Promise<{ + [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; + }> { + return {}; + } + + public getName(): DataSource { + return DataSource.MANUAL; + } + + public async search(aQuery: string): Promise<{ items: LookupItem[] }> { + return { items: [] }; + } +} diff --git a/apps/api/src/services/symbol-profile.service.ts b/apps/api/src/services/symbol-profile.service.ts index 0b2857338..b3c6a4c21 100644 --- a/apps/api/src/services/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile.service.ts @@ -25,6 +25,12 @@ export class SymbolProfileService { }); } + public async deleteById(id: string) { + return this.prismaService.symbolProfile.delete({ + where: { id } + }); + } + public async getSymbolProfiles( symbols: string[] ): Promise { diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index c9c34cd03..dd2847c37 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -142,6 +142,17 @@ > +
+
Items
+
+ +
+

diff --git a/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.component.ts b/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.component.ts index 974bbcce1..05c02bcb4 100644 --- a/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.component.ts @@ -6,11 +6,15 @@ import { OnDestroy, ViewChild } from '@angular/core'; -import { FormControl, Validators } from '@angular/forms'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; +import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { DataService } from '@ghostfolio/client/services/data.service'; +import { Type } from '@prisma/client'; +import { isUUID } from 'class-validator'; import { isString } from 'lodash'; import { EMPTY, Observable, Subject } from 'rxjs'; import { @@ -34,19 +38,15 @@ import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces'; export class CreateOrUpdateTransactionDialog implements OnDestroy { @ViewChild('autocomplete') autocomplete; + public activityForm: FormGroup; + public currencies: string[] = []; public currentMarketPrice = null; public filteredLookupItems: LookupItem[]; public filteredLookupItemsObservable: Observable; public isLoading = false; public platforms: { id: string; name: string }[]; - public searchSymbolCtrl = new FormControl( - { - dataSource: this.data.transaction.dataSource, - symbol: this.data.transaction.symbol - }, - Validators.required - ); + public Validators = Validators; private unsubscribeSubject = new Subject(); @@ -54,6 +54,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, public dialogRef: MatDialogRef, + private formBuilder: FormBuilder, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams ) {} @@ -63,36 +64,105 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy { this.currencies = currencies; this.platforms = platforms; - this.filteredLookupItemsObservable = - this.searchSymbolCtrl.valueChanges.pipe( - startWith(''), - debounceTime(400), - distinctUntilChanged(), - switchMap((query: string) => { - if (isString(query)) { - const filteredLookupItemsObservable = - this.dataService.fetchSymbols(query); - - filteredLookupItemsObservable.subscribe((filteredLookupItems) => { - this.filteredLookupItems = filteredLookupItems; - }); + this.activityForm = this.formBuilder.group({ + accountId: [this.data.activity?.accountId, Validators.required], + currency: [ + this.data.activity?.SymbolProfile?.currency, + Validators.required + ], + dataSource: [ + this.data.activity?.SymbolProfile?.dataSource, + Validators.required + ], + date: [this.data.activity?.date, Validators.required], + fee: [this.data.activity?.fee, Validators.required], + name: [this.data.activity?.SymbolProfile?.name, Validators.required], + quantity: [this.data.activity?.quantity, Validators.required], + searchSymbol: [ + { + dataSource: this.data.activity?.SymbolProfile?.dataSource, + symbol: this.data.activity?.SymbolProfile?.symbol + }, + Validators.required + ], + type: [undefined, Validators.required], // Set after value changes subscription + unitPrice: [this.data.activity?.unitPrice, Validators.required] + }); - return filteredLookupItemsObservable; - } + this.filteredLookupItemsObservable = this.activityForm.controls[ + 'searchSymbol' + ].valueChanges.pipe( + startWith(''), + debounceTime(400), + distinctUntilChanged(), + switchMap((query: string) => { + if (isString(query)) { + const filteredLookupItemsObservable = + this.dataService.fetchSymbols(query); + + filteredLookupItemsObservable.subscribe((filteredLookupItems) => { + this.filteredLookupItems = filteredLookupItems; + }); + + return filteredLookupItemsObservable; + } + + return []; + }) + ); + + this.activityForm.controls['type'].valueChanges.subscribe((type: Type) => { + if (type === 'ITEM') { + this.activityForm.controls['accountId'].removeValidators( + Validators.required + ); + this.activityForm.controls['accountId'].updateValueAndValidity(); + this.activityForm.controls['currency'].setValue( + this.data.user.settings.baseCurrency + ); + this.activityForm.controls['dataSource'].removeValidators( + Validators.required + ); + this.activityForm.controls['dataSource'].updateValueAndValidity(); + this.activityForm.controls['name'].setValidators(Validators.required); + this.activityForm.controls['name'].updateValueAndValidity(); + this.activityForm.controls['quantity'].setValue(1); + this.activityForm.controls['searchSymbol'].removeValidators( + Validators.required + ); + this.activityForm.controls['searchSymbol'].updateValueAndValidity(); + } else { + this.activityForm.controls['accountId'].setValidators( + Validators.required + ); + this.activityForm.controls['accountId'].updateValueAndValidity(); + this.activityForm.controls['dataSource'].setValidators( + Validators.required + ); + this.activityForm.controls['dataSource'].updateValueAndValidity(); + this.activityForm.controls['name'].removeValidators( + Validators.required + ); + this.activityForm.controls['name'].updateValueAndValidity(); + this.activityForm.controls['searchSymbol'].setValidators( + Validators.required + ); + this.activityForm.controls['searchSymbol'].updateValueAndValidity(); + } + }); - return []; - }) - ); + this.activityForm.controls['type'].setValue(this.data.activity?.type); - if (this.data.transaction.id) { - this.searchSymbolCtrl.disable(); + if (this.data.activity?.id) { + this.activityForm.controls['searchSymbol'].disable(); + this.activityForm.controls['type'].disable(); } - if (this.data.transaction.symbol) { + if (this.data.activity?.symbol) { this.dataService .fetchSymbolItem({ - dataSource: this.data.transaction.dataSource, - symbol: this.data.transaction.symbol + dataSource: this.data.activity?.dataSource, + symbol: this.data.activity?.symbol }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ marketPrice }) => { @@ -104,7 +174,9 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy { } public applyCurrentMarketPrice() { - this.data.transaction.unitPrice = this.currentMarketPrice; + this.activityForm.patchValue({ + unitPrice: this.currentMarketPrice + }); } public displayFn(aLookupItem: LookupItem) { @@ -113,17 +185,20 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy { public onBlurSymbol() { const currentLookupItem = this.filteredLookupItems.find((lookupItem) => { - return lookupItem.symbol === this.data.transaction.symbol; + return ( + lookupItem.symbol === + this.activityForm.controls['searchSymbol'].value.symbol + ); }); if (currentLookupItem) { this.updateSymbol(currentLookupItem.symbol); } else { - this.searchSymbolCtrl.setErrors({ incorrect: true }); + this.activityForm.controls['searchSymbol'].setErrors({ incorrect: true }); - this.data.transaction.currency = null; - this.data.transaction.dataSource = null; - this.data.transaction.symbol = null; + this.data.activity.currency = null; + this.data.activity.dataSource = null; + this.data.activity.symbol = null; } this.changeDetectorRef.markForCheck(); @@ -133,8 +208,32 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy { this.dialogRef.close(); } + public onSubmit() { + const activity: CreateOrderDto | UpdateOrderDto = { + accountId: this.activityForm.controls['accountId'].value, + currency: this.activityForm.controls['currency'].value, + date: this.activityForm.controls['date'].value, + dataSource: this.activityForm.controls['dataSource'].value, + fee: this.activityForm.controls['fee'].value, + quantity: this.activityForm.controls['quantity'].value, + symbol: isUUID(this.activityForm.controls['searchSymbol'].value.symbol) + ? this.activityForm.controls['name'].value + : this.activityForm.controls['searchSymbol'].value.symbol, + type: this.activityForm.controls['type'].value, + unitPrice: this.activityForm.controls['unitPrice'].value + }; + + if (this.data.activity.id) { + (activity as UpdateOrderDto).id = this.data.activity.id; + } + + this.dialogRef.close({ activity }); + } + public onUpdateSymbol(event: MatAutocompleteSelectedEvent) { - this.data.transaction.dataSource = event.option.value.dataSource; + this.activityForm.controls['dataSource'].setValue( + event.option.value.dataSource + ); this.updateSymbol(event.option.value.symbol); } @@ -146,20 +245,21 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy { private updateSymbol(symbol: string) { this.isLoading = true; - this.searchSymbolCtrl.setErrors(null); + this.activityForm.controls['searchSymbol'].setErrors(null); + this.activityForm.controls['searchSymbol'].setValue({ symbol }); - this.data.transaction.symbol = symbol; + this.changeDetectorRef.markForCheck(); this.dataService .fetchSymbolItem({ - dataSource: this.data.transaction.dataSource, - symbol: this.data.transaction.symbol + dataSource: this.activityForm.controls['dataSource'].value, + symbol: this.activityForm.controls['searchSymbol'].value.symbol }) .pipe( catchError(() => { - this.data.transaction.currency = null; - this.data.transaction.dataSource = null; - this.data.transaction.unitPrice = null; + this.data.activity.currency = null; + this.data.activity.dataSource = null; + this.data.activity.unitPrice = null; this.isLoading = false; @@ -170,8 +270,9 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy { takeUntil(this.unsubscribeSubject) ) .subscribe(({ currency, dataSource, marketPrice }) => { - this.data.transaction.currency = currency; - this.data.transaction.dataSource = dataSource; + this.activityForm.controls['currency'].setValue(currency); + this.activityForm.controls['dataSource'].setValue(dataSource); + this.currentMarketPrice = marketPrice; this.isLoading = false; diff --git a/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.html b/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.html index 11f7415fb..212b3c245 100644 --- a/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.html +++ b/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.html @@ -1,31 +1,45 @@ -
-

Update activity

-

Add activity

+ +

Update activity

+

Add activity

+ + Type + + BUY + DIVIDEND + ITEM + SELL + + +
+
Account - + {{ account.name }}
-
+
Symbol or ISIN @@ -48,26 +62,18 @@
-
+
- Type - - BUY - DIVIDEND - SELL - + Name +
Currency - + {{ currency }} @@ -77,26 +83,13 @@
Data Source - +
Date - + Quantity - +
Unit Price - - {{ data.transaction.currency }} + + {{ activityForm.controls['currency'].value }}
- + diff --git a/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/interfaces/interfaces.ts b/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/interfaces/interfaces.ts index da122b585..b4b15dfdb 100644 --- a/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/interfaces/interfaces.ts @@ -1,9 +1,10 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { User } from '@ghostfolio/common/interfaces'; -import { Account, Order } from '@prisma/client'; +import { Account } from '@prisma/client'; export interface CreateOrUpdateTransactionDialogParams { accountId: string; accounts: Account[]; - transaction: Order; + activity: Activity; user: User; } diff --git a/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts b/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts index 5d165c40f..a40d44a4f 100644 --- a/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts +++ b/apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts @@ -132,8 +132,8 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { }); } - public onCloneTransaction(aTransaction: OrderModel) { - this.openCreateTransactionDialog(aTransaction); + public onCloneTransaction(aActivity: Activity) { + this.openCreateTransactionDialog(aActivity); } public onDeleteTransaction(aId: string) { @@ -242,35 +242,13 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { }); } - public openUpdateTransactionDialog({ - accountId, - currency, - dataSource, - date, - fee, - id, - quantity, - symbol, - type, - unitPrice - }: OrderModel): void { + public openUpdateTransactionDialog(activity: Activity): void { const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, { data: { + activity, accounts: this.user?.accounts?.filter((account) => { return account.accountType === 'SECURITIES'; }), - transaction: { - accountId, - currency, - dataSource, - date, - fee, - id, - quantity, - symbol, - type, - unitPrice - }, user: this.user }, height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', @@ -281,7 +259,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { .afterClosed() .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((data: any) => { - const transaction: UpdateOrderDto = data?.transaction; + const transaction: UpdateOrderDto = data?.activity; if (transaction) { this.dataService @@ -324,7 +302,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { }); } - private openCreateTransactionDialog(aTransaction?: OrderModel): void { + private openCreateTransactionDialog(aActivity?: Activity): void { this.userService .get() .pipe(takeUntil(this.unsubscribeSubject)) @@ -336,15 +314,14 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { accounts: this.user?.accounts?.filter((account) => { return account.accountType === 'SECURITIES'; }), - transaction: { - accountId: aTransaction?.accountId ?? this.defaultAccountId, - currency: aTransaction?.currency ?? null, - dataSource: aTransaction?.dataSource ?? null, + activity: { + ...aActivity, + accountId: aActivity?.accountId ?? this.defaultAccountId, date: new Date(), + id: null, fee: 0, quantity: null, - symbol: aTransaction?.symbol ?? null, - type: aTransaction?.type ?? 'BUY', + type: aActivity?.type ?? 'BUY', unitPrice: null }, user: this.user @@ -357,7 +334,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { .afterClosed() .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((data: any) => { - const transaction: CreateOrderDto = data?.transaction; + const transaction: CreateOrderDto = data?.activity; if (transaction) { this.dataService.postOrder(transaction).subscribe({ diff --git a/apps/client/src/app/services/admin.service.ts b/apps/client/src/app/services/admin.service.ts index 8b75473d1..ff6e624d0 100644 --- a/apps/client/src/app/services/admin.service.ts +++ b/apps/client/src/app/services/admin.service.ts @@ -6,7 +6,7 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { AdminMarketDataDetails } from '@ghostfolio/common/interfaces'; import { DataSource, MarketData } from '@prisma/client'; import { format, parseISO } from 'date-fns'; -import { map, Observable } from 'rxjs'; +import { Observable, map } from 'rxjs'; @Injectable({ providedIn: 'root' diff --git a/apps/client/src/app/services/import-transactions.service.ts b/apps/client/src/app/services/import-transactions.service.ts index b39d4ac65..8fe829d55 100644 --- a/apps/client/src/app/services/import-transactions.service.ts +++ b/apps/client/src/app/services/import-transactions.service.ts @@ -245,6 +245,8 @@ export class ImportTransactionsService { return Type.BUY; case 'dividend': return Type.DIVIDEND; + case 'item': + return Type.ITEM; case 'sell': return Type.SELL; default: diff --git a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts index 10025f694..7ffec5d4d 100644 --- a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts @@ -7,6 +7,7 @@ export interface PortfolioSummary extends PortfolioPerformance { committedFunds: number; fees: number; firstOrderDate: Date; + items: number; netWorth: number; ordersCount: number; totalBuy: number; 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 5fd343a0a..4cb943f55 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -87,15 +87,21 @@ [ngClass]="{ buy: element.type === 'BUY', dividend: element.type === 'DIVIDEND', + item: element.type === 'ITEM', sell: element.type === 'SELL' }" > + + {{ element.type }}
@@ -109,7 +115,12 @@
- {{ element.symbol | gfSymbol }} + + {{ element.SymbolProfile.name }} + + + {{ element.SymbolProfile.symbol | gfSymbol }} + Draft @@ -349,13 +360,15 @@ (click)=" hasPermissionToOpenDetails && !row.isDraft && + row.type !== 'ITEM' && onOpenPositionDialog({ dataSource: row.dataSource, - symbol: row.symbol + symbol: row.SymbolProfile.symbol }) " [ngClass]="{ - 'cursor-pointer': hasPermissionToOpenDetails && !row.isDraft + 'cursor-pointer': + hasPermissionToOpenDetails && !row.isDraft && row.type !== 'ITEM' }" > = this.filters$.asObservable(); public isAfter = isAfter; public isLoading = true; + public isUUID = isUUID; public placeholder = ''; public routeQueryParams: Subscription; public searchControl = new FormControl(); @@ -271,11 +273,15 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { activity: OrderWithAccount, fieldValues: Set = new Set() ): string[] { - fieldValues.add(activity.currency); - fieldValues.add(activity.symbol); - fieldValues.add(activity.type); fieldValues.add(activity.Account?.name); fieldValues.add(activity.Account?.Platform?.name); + fieldValues.add(activity.SymbolProfile.currency); + + if (!isUUID(activity.SymbolProfile.symbol)) { + fieldValues.add(activity.SymbolProfile.symbol); + } + + fieldValues.add(activity.type); fieldValues.add(format(activity.date, 'yyyy')); return [...fieldValues].filter((item) => { @@ -302,7 +308,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy { for (const activity of this.dataSource.filteredData) { if (isNumber(activity.valueInBaseCurrency)) { - if (activity.type === 'BUY') { + if (activity.type === 'BUY' || activity.type === 'ITEM') { totalValue = totalValue.plus(activity.valueInBaseCurrency); } else if (activity.type === 'SELL') { totalValue = totalValue.minus(activity.valueInBaseCurrency); diff --git a/prisma/migrations/20220209194930_added_manual_to_data_source/migration.sql b/prisma/migrations/20220209194930_added_manual_to_data_source/migration.sql new file mode 100644 index 000000000..3b8e61250 --- /dev/null +++ b/prisma/migrations/20220209194930_added_manual_to_data_source/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "DataSource" ADD VALUE 'MANUAL'; diff --git a/prisma/migrations/20220209195038_added_item_to_order_type/migration.sql b/prisma/migrations/20220209195038_added_item_to_order_type/migration.sql new file mode 100644 index 000000000..5275e96d8 --- /dev/null +++ b/prisma/migrations/20220209195038_added_item_to_order_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "Type" ADD VALUE 'ITEM'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 53467d7b7..f0a1a173b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -185,6 +185,7 @@ enum DataSource { ALPHA_VANTAGE GHOSTFOLIO GOOGLE_SHEETS + MANUAL RAKUTEN YAHOO } @@ -208,5 +209,6 @@ enum Role { enum Type { BUY DIVIDEND + ITEM SELL } diff --git a/test/import/ok.csv b/test/import/ok.csv index 0f67f2d4f..ddefbc93f 100644 --- a/test/import/ok.csv +++ b/test/import/ok.csv @@ -1,3 +1,4 @@ Date,Code,Currency,Price,Quantity,Action,Fee 17/11/2021,MSFT,USD,0.62,5,dividend,0.00 16/09/2021,MSFT,USD,298.580,5,buy,19.00 +01/01/2022,Penthouse Apartment,USD,500000.0,1,item,0.00