Browse Source

Feature/add support for wealth items (#666)

* Add support for wealth items

* Update changelog
pull/688/head
Thomas Kaul 3 years ago
committed by GitHub
parent
commit
76f70598e2
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      CHANGELOG.md
  2. 2
      apps/api/src/app/export/export.service.ts
  3. 35
      apps/api/src/app/import/import.service.ts
  4. 2
      apps/api/src/app/order/order.module.ts
  5. 64
      apps/api/src/app/order/order.service.ts
  6. 3
      apps/api/src/app/order/update-order.dto.ts
  7. 1
      apps/api/src/app/portfolio/portfolio.controller.ts
  8. 25
      apps/api/src/app/portfolio/portfolio.service-new.ts
  9. 25
      apps/api/src/app/portfolio/portfolio.service.ts
  10. 11
      apps/api/src/services/data-gathering.service.ts
  11. 5
      apps/api/src/services/data-provider/data-provider.module.ts
  12. 1
      apps/api/src/services/data-provider/data-provider.service.ts
  13. 43
      apps/api/src/services/data-provider/manual/manual.service.ts
  14. 6
      apps/api/src/services/symbol-profile.service.ts
  15. 11
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  16. 195
      apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.component.ts
  17. 118
      apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.html
  18. 5
      apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/interfaces/interfaces.ts
  19. 47
      apps/client/src/app/pages/portfolio/transactions/transactions-page.component.ts
  20. 2
      apps/client/src/app/services/admin.service.ts
  21. 2
      apps/client/src/app/services/import-transactions.service.ts
  22. 1
      libs/common/src/lib/interfaces/portfolio-summary.interface.ts
  23. 29
      libs/ui/src/lib/activities-table/activities-table.component.html
  24. 4
      libs/ui/src/lib/activities-table/activities-table.component.scss
  25. 14
      libs/ui/src/lib/activities-table/activities-table.component.ts
  26. 2
      prisma/migrations/20220209194930_added_manual_to_data_source/migration.sql
  27. 2
      prisma/migrations/20220209195038_added_item_to_order_type/migration.sql
  28. 2
      prisma/schema.prisma
  29. 1
      test/import/ok.csv

10
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/), 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). 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 ## 1.113.0 - 09.02.2022
### Changed ### Changed

2
apps/api/src/app/export/export.service.ts

@ -59,7 +59,7 @@ export class ExportService {
type, type,
unitPrice, unitPrice,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
}; };
} }
) )

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

@ -21,8 +21,13 @@ export class ImportService {
userId: string; userId: string;
}): Promise<void> { }): Promise<void> {
for (const order of orders) { for (const order of orders) {
order.dataSource = if (!order.dataSource) {
order.dataSource ?? this.dataProviderService.getPrimaryDataSource(); if (order.type === 'ITEM') {
order.dataSource = 'MANUAL';
} else {
order.dataSource = this.dataProviderService.getPrimaryDataSource();
}
}
} }
await this.validateOrders({ orders, userId }); await this.validateOrders({ orders, userId });
@ -111,20 +116,22 @@ export class ImportService {
throw new Error(`orders.${index} is a duplicate transaction`); throw new Error(`orders.${index} is a duplicate transaction`);
} }
const result = await this.dataProviderService.get([ if (dataSource !== 'MANUAL') {
{ dataSource, symbol } const result = await this.dataProviderService.get([
]); { dataSource, symbol }
]);
if (result[symbol] === undefined) { if (result[symbol] === undefined) {
throw new Error( throw new Error(
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
} }
if (result[symbol].currency !== currency) { if (result[symbol].currency !== currency) {
throw new Error( throw new Error(
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"` `orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"`
); );
}
} }
} }
} }

2
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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { OrderController } from './order.controller'; import { OrderController } from './order.controller';
@ -22,6 +23,7 @@ import { OrderService } from './order.service';
ImpersonationModule, ImpersonationModule,
PrismaModule, PrismaModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule,
UserModule UserModule
], ],
controllers: [OrderController], controllers: [OrderController],

64
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 { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.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 { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client'; import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { v4 as uuidv4 } from 'uuid';
import { Activity } from './interfaces/activities.interface'; import { Activity } from './interfaces/activities.interface';
@ -18,7 +20,8 @@ export class OrderService {
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async order( public async order(
@ -58,7 +61,7 @@ export class OrderService {
return account.isDefault === true; return account.isDefault === true;
}); });
const Account = { let Account = {
connect: { connect: {
id_userId: { id_userId: {
userId: data.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 isDraft = isAfter(data.date as Date, endOfToday());
const symbol = data.symbol.toUpperCase();
if (!isDraft) { if (!isDraft) {
// Gather symbol data of order in the background, if not draft // Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
symbol,
dataSource: data.dataSource, dataSource: data.dataSource,
date: <Date>data.date date: <Date>data.date,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
} }
]); ]);
} }
this.dataGatheringService.gatherProfileData([ this.dataGatheringService.gatherProfileData([
{ symbol, dataSource: data.dataSource } {
dataSource: data.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]); ]);
await this.cacheService.flush(); await this.cacheService.flush();
@ -98,8 +124,7 @@ export class OrderService {
data: { data: {
...orderData, ...orderData,
Account, Account,
isDraft, isDraft
symbol
} }
}); });
} }
@ -107,9 +132,15 @@ export class OrderService {
public async deleteOrder( public async deleteOrder(
where: Prisma.OrderWhereUniqueInput where: Prisma.OrderWhereUniqueInput
): Promise<Order> { ): Promise<Order> {
return this.prismaService.order.delete({ const order = await this.prismaService.order.delete({
where where
}); });
if (order.type === 'ITEM') {
await this.symbolProfileService.deleteById(order.symbolProfileId);
}
return order;
} }
public async getOrders({ public async getOrders({
@ -180,6 +211,17 @@ export class OrderService {
}): Promise<Order> { }): Promise<Order> {
const { data, where } = params; 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()); const isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) { if (!isDraft) {

3
apps/api/src/app/order/update-order.dto.ts

@ -1,7 +1,8 @@
import { DataSource, Type } from '@prisma/client'; import { DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString } from 'class-validator'; import { IsISO8601, IsNumber, IsOptional, IsString } from 'class-validator';
export class UpdateOrderDto { export class UpdateOrderDto {
@IsOptional()
@IsString() @IsString()
accountId: string; accountId: string;

1
apps/api/src/app/portfolio/portfolio.controller.ts

@ -332,6 +332,7 @@ export class PortfolioController {
'currentValue', 'currentValue',
'dividend', 'dividend',
'fees', 'fees',
'items',
'netWorth', 'netWorth',
'totalBuy', 'totalBuy',
'totalSell' 'totalSell'

25
apps/api/src/app/portfolio/portfolio.service-new.ts

@ -891,6 +891,7 @@ export class PortfolioServiceNew {
const dividend = this.getDividend(orders).toNumber(); const dividend = this.getDividend(orders).toNumber();
const fees = this.getFees(orders).toNumber(); const fees = this.getFees(orders).toNumber();
const firstOrderDate = orders[0]?.date; const firstOrderDate = orders[0]?.date;
const items = this.getItems(orders).toNumber();
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY'); const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL'); const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
@ -899,6 +900,7 @@ export class PortfolioServiceNew {
const netWorth = new Big(balance) const netWorth = new Big(balance)
.plus(performanceInformation.performance.currentValue) .plus(performanceInformation.performance.currentValue)
.plus(items)
.toNumber(); .toNumber();
const daysInMarket = differenceInDays(new Date(), firstOrderDate); const daysInMarket = differenceInDays(new Date(), firstOrderDate);
@ -922,6 +924,7 @@ export class PortfolioServiceNew {
dividend, dividend,
fees, fees,
firstOrderDate, firstOrderDate,
items,
netWorth, netWorth,
totalBuy, totalBuy,
totalSell, 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) { private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) { switch (aDateRange) {
case '1d': case '1d':

25
apps/api/src/app/portfolio/portfolio.service.ts

@ -869,6 +869,7 @@ export class PortfolioService {
const dividend = this.getDividend(orders).toNumber(); const dividend = this.getDividend(orders).toNumber();
const fees = this.getFees(orders).toNumber(); const fees = this.getFees(orders).toNumber();
const firstOrderDate = orders[0]?.date; const firstOrderDate = orders[0]?.date;
const items = this.getItems(orders).toNumber();
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY'); const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL'); const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
@ -877,6 +878,7 @@ export class PortfolioService {
const netWorth = new Big(balance) const netWorth = new Big(balance)
.plus(performanceInformation.performance.currentValue) .plus(performanceInformation.performance.currentValue)
.plus(items)
.toNumber(); .toNumber();
return { return {
@ -884,6 +886,7 @@ export class PortfolioService {
dividend, dividend,
fees, fees,
firstOrderDate, firstOrderDate,
items,
netWorth, netWorth,
totalBuy, totalBuy,
totalSell, 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) { private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) { switch (aDateRange) {
case '1d': case '1d':

11
apps/api/src/services/data-gathering.service.ts

@ -445,6 +445,11 @@ export class DataGatheringService {
}, },
scraperConfiguration: true, scraperConfiguration: true,
symbol: true symbol: true
},
where: {
dataSource: {
not: 'MANUAL'
}
} }
}) })
).map((symbolProfile) => { ).map((symbolProfile) => {
@ -479,6 +484,11 @@ export class DataGatheringService {
dataSource: true, dataSource: true,
scraperConfiguration: true, scraperConfiguration: true,
symbol: true symbol: true
},
where: {
dataSource: {
not: 'MANUAL'
}
} }
}); });
@ -537,6 +547,7 @@ export class DataGatheringService {
return distinctOrders.filter((distinctOrder) => { return distinctOrders.filter((distinctOrder) => {
return ( return (
distinctOrder.dataSource !== DataSource.GHOSTFOLIO && distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
distinctOrder.dataSource !== DataSource.MANUAL &&
distinctOrder.dataSource !== DataSource.RAKUTEN distinctOrder.dataSource !== DataSource.RAKUTEN
); );
}); });

5
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 { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; 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 { 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 { 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 { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
@ -23,6 +24,7 @@ import { DataProviderService } from './data-provider.service';
DataProviderService, DataProviderService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
GoogleSheetsService, GoogleSheetsService,
ManualService,
RakutenRapidApiService, RakutenRapidApiService,
YahooFinanceService, YahooFinanceService,
{ {
@ -30,6 +32,7 @@ import { DataProviderService } from './data-provider.service';
AlphaVantageService, AlphaVantageService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
GoogleSheetsService, GoogleSheetsService,
ManualService,
RakutenRapidApiService, RakutenRapidApiService,
YahooFinanceService YahooFinanceService
], ],
@ -38,12 +41,14 @@ import { DataProviderService } from './data-provider.service';
alphaVantageService, alphaVantageService,
ghostfolioScraperApiService, ghostfolioScraperApiService,
googleSheetsService, googleSheetsService,
manualService,
rakutenRapidApiService, rakutenRapidApiService,
yahooFinanceService yahooFinanceService
) => [ ) => [
alphaVantageService, alphaVantageService,
ghostfolioScraperApiService, ghostfolioScraperApiService,
googleSheetsService, googleSheetsService,
manualService,
rakutenRapidApiService, rakutenRapidApiService,
yahooFinanceService yahooFinanceService
] ]

1
apps/api/src/services/data-provider/data-provider.service.ts

@ -194,6 +194,7 @@ export class DataProviderService {
return dataProviderInterface; return dataProviderInterface;
} }
} }
throw new Error('No data provider has been found.'); throw new Error('No data provider has been found.');
} }
} }

43
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: [] };
}
}

6
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( public async getSymbolProfiles(
symbols: string[] symbols: string[]
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {

11
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -142,6 +142,17 @@
></gf-value> ></gf-value>
</div> </div>
</div> </div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Items</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.items"
></gf-value>
</div>
</div>
<div class="row"> <div class="row">
<div class="col"><hr /></div> <div class="col"><hr /></div>
</div> </div>

195
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, OnDestroy,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { FormControl, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; 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 { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { Type } from '@prisma/client';
import { isUUID } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
import { EMPTY, Observable, Subject } from 'rxjs'; import { EMPTY, Observable, Subject } from 'rxjs';
import { import {
@ -34,19 +38,15 @@ import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces';
export class CreateOrUpdateTransactionDialog implements OnDestroy { export class CreateOrUpdateTransactionDialog implements OnDestroy {
@ViewChild('autocomplete') autocomplete; @ViewChild('autocomplete') autocomplete;
public activityForm: FormGroup;
public currencies: string[] = []; public currencies: string[] = [];
public currentMarketPrice = null; public currentMarketPrice = null;
public filteredLookupItems: LookupItem[]; public filteredLookupItems: LookupItem[];
public filteredLookupItemsObservable: Observable<LookupItem[]>; public filteredLookupItemsObservable: Observable<LookupItem[]>;
public isLoading = false; public isLoading = false;
public platforms: { id: string; name: string }[]; public platforms: { id: string; name: string }[];
public searchSymbolCtrl = new FormControl( public Validators = Validators;
{
dataSource: this.data.transaction.dataSource,
symbol: this.data.transaction.symbol
},
Validators.required
);
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -54,6 +54,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>, public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>,
private formBuilder: FormBuilder,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams
) {} ) {}
@ -63,36 +64,105 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.currencies = currencies; this.currencies = currencies;
this.platforms = platforms; this.platforms = platforms;
this.filteredLookupItemsObservable = this.activityForm = this.formBuilder.group({
this.searchSymbolCtrl.valueChanges.pipe( accountId: [this.data.activity?.accountId, Validators.required],
startWith(''), currency: [
debounceTime(400), this.data.activity?.SymbolProfile?.currency,
distinctUntilChanged(), Validators.required
switchMap((query: string) => { ],
if (isString(query)) { dataSource: [
const filteredLookupItemsObservable = this.data.activity?.SymbolProfile?.dataSource,
this.dataService.fetchSymbols(query); Validators.required
],
filteredLookupItemsObservable.subscribe((filteredLookupItems) => { date: [this.data.activity?.date, Validators.required],
this.filteredLookupItems = filteredLookupItems; 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) { if (this.data.activity?.id) {
this.searchSymbolCtrl.disable(); this.activityForm.controls['searchSymbol'].disable();
this.activityForm.controls['type'].disable();
} }
if (this.data.transaction.symbol) { if (this.data.activity?.symbol) {
this.dataService this.dataService
.fetchSymbolItem({ .fetchSymbolItem({
dataSource: this.data.transaction.dataSource, dataSource: this.data.activity?.dataSource,
symbol: this.data.transaction.symbol symbol: this.data.activity?.symbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => { .subscribe(({ marketPrice }) => {
@ -104,7 +174,9 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
} }
public applyCurrentMarketPrice() { public applyCurrentMarketPrice() {
this.data.transaction.unitPrice = this.currentMarketPrice; this.activityForm.patchValue({
unitPrice: this.currentMarketPrice
});
} }
public displayFn(aLookupItem: LookupItem) { public displayFn(aLookupItem: LookupItem) {
@ -113,17 +185,20 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
public onBlurSymbol() { public onBlurSymbol() {
const currentLookupItem = this.filteredLookupItems.find((lookupItem) => { const currentLookupItem = this.filteredLookupItems.find((lookupItem) => {
return lookupItem.symbol === this.data.transaction.symbol; return (
lookupItem.symbol ===
this.activityForm.controls['searchSymbol'].value.symbol
);
}); });
if (currentLookupItem) { if (currentLookupItem) {
this.updateSymbol(currentLookupItem.symbol); this.updateSymbol(currentLookupItem.symbol);
} else { } else {
this.searchSymbolCtrl.setErrors({ incorrect: true }); this.activityForm.controls['searchSymbol'].setErrors({ incorrect: true });
this.data.transaction.currency = null; this.data.activity.currency = null;
this.data.transaction.dataSource = null; this.data.activity.dataSource = null;
this.data.transaction.symbol = null; this.data.activity.symbol = null;
} }
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
@ -133,8 +208,32 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.dialogRef.close(); 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) { 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); this.updateSymbol(event.option.value.symbol);
} }
@ -146,20 +245,21 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
private updateSymbol(symbol: string) { private updateSymbol(symbol: string) {
this.isLoading = true; 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 this.dataService
.fetchSymbolItem({ .fetchSymbolItem({
dataSource: this.data.transaction.dataSource, dataSource: this.activityForm.controls['dataSource'].value,
symbol: this.data.transaction.symbol symbol: this.activityForm.controls['searchSymbol'].value.symbol
}) })
.pipe( .pipe(
catchError(() => { catchError(() => {
this.data.transaction.currency = null; this.data.activity.currency = null;
this.data.transaction.dataSource = null; this.data.activity.dataSource = null;
this.data.transaction.unitPrice = null; this.data.activity.unitPrice = null;
this.isLoading = false; this.isLoading = false;
@ -170,8 +270,9 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
takeUntil(this.unsubscribeSubject) takeUntil(this.unsubscribeSubject)
) )
.subscribe(({ currency, dataSource, marketPrice }) => { .subscribe(({ currency, dataSource, marketPrice }) => {
this.data.transaction.currency = currency; this.activityForm.controls['currency'].setValue(currency);
this.data.transaction.dataSource = dataSource; this.activityForm.controls['dataSource'].setValue(dataSource);
this.currentMarketPrice = marketPrice; this.currentMarketPrice = marketPrice;
this.isLoading = false; this.isLoading = false;

118
apps/client/src/app/pages/portfolio/transactions/create-or-update-transaction-dialog/create-or-update-transaction-dialog.html

@ -1,31 +1,45 @@
<form #addTransactionForm="ngForm" class="d-flex flex-column h-100"> <form
<h1 *ngIf="data.transaction.id" mat-dialog-title i18n>Update activity</h1> class="d-flex flex-column h-100"
<h1 *ngIf="!data.transaction.id" mat-dialog-title i18n>Add activity</h1> [formGroup]="activityForm"
(ngSubmit)="onSubmit()"
>
<h1 *ngIf="data.activity.id" mat-dialog-title i18n>Update activity</h1>
<h1 *ngIf="!data.activity.id" mat-dialog-title i18n>Add activity</h1>
<div class="flex-grow-1" mat-dialog-content> <div class="flex-grow-1" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select formControlName="type">
<mat-option value="BUY" i18n>BUY</mat-option>
<mat-option value="DIVIDEND" i18n>DIVIDEND</mat-option>
<mat-option value="ITEM" i18n>ITEM</mat-option>
<mat-option value="SELL" i18n>SELL</mat-option>
</mat-select>
</mat-form-field>
</div>
<div
[ngClass]="{ 'd-none': !activityForm.controls['accountId'].hasValidator(Validators.required) }"
>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Account</mat-label> <mat-label i18n>Account</mat-label>
<mat-select <mat-select formControlName="accountId">
name="accountId"
required
[(value)]="data.transaction.accountId"
>
<mat-option *ngFor="let account of data.accounts" [value]="account.id" <mat-option *ngFor="let account of data.accounts" [value]="account.id"
>{{ account.name }}</mat-option >{{ account.name }}</mat-option
> >
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div
[ngClass]="{ 'd-none': !activityForm.controls['searchSymbol'].hasValidator(Validators.required) }"
>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Symbol or ISIN</mat-label> <mat-label i18n>Symbol or ISIN</mat-label>
<input <input
autocapitalize="off" autocapitalize="off"
autocomplete="off" autocomplete="off"
autocorrect="off" autocorrect="off"
formControlName="searchSymbol"
matInput matInput
required
[formControl]="searchSymbolCtrl"
[matAutocomplete]="autocomplete" [matAutocomplete]="autocomplete"
(blur)="onBlurSymbol()" (blur)="onBlurSymbol()"
/> />
@ -48,26 +62,18 @@
<mat-spinner *ngIf="isLoading" matSuffix [diameter]="20"></mat-spinner> <mat-spinner *ngIf="isLoading" matSuffix [diameter]="20"></mat-spinner>
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div
[ngClass]="{ 'd-none': !activityForm.controls['name'].hasValidator(Validators.required) }"
>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label> <mat-label i18n>Name</mat-label>
<mat-select name="type" required [(value)]="data.transaction.type"> <input formControlName="name" matInput />
<mat-option value="BUY" i18n>BUY</mat-option>
<mat-option value="DIVIDEND" i18n>DIVIDEND</mat-option>
<mat-option value="SELL" i18n>SELL</mat-option>
</mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="d-none"> <div class="d-none">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label> <mat-label i18n>Currency</mat-label>
<mat-select <mat-select class="no-arrow" formControlName="currency">
class="no-arrow"
disabled
name="currency"
required
[(value)]="data.transaction.currency"
>
<mat-option *ngFor="let currency of currencies" [value]="currency" <mat-option *ngFor="let currency of currencies" [value]="currency"
>{{ currency }}</mat-option >{{ currency }}</mat-option
> >
@ -77,26 +83,13 @@
<div class="d-none"> <div class="d-none">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Data Source</mat-label> <mat-label i18n>Data Source</mat-label>
<input <input formControlName="dataSource" matInput />
disabled
matInput
name="dataSource"
required
[(ngModel)]="data.transaction.dataSource"
/>
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Date</mat-label> <mat-label i18n>Date</mat-label>
<input <input formControlName="date" matInput [matDatepicker]="date" />
disabled
matInput
name="date"
required
[matDatepicker]="date"
[(ngModel)]="data.transaction.date"
/>
<mat-datepicker-toggle matSuffix [for]="date"> <mat-datepicker-toggle matSuffix [for]="date">
<ion-icon <ion-icon
class="text-muted" class="text-muted"
@ -110,31 +103,22 @@
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Quantity</mat-label> <mat-label i18n>Quantity</mat-label>
<input <input formControlName="quantity" matInput type="number" />
matInput
name="quantity"
required
type="number"
[(ngModel)]="data.transaction.quantity"
/>
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Unit Price</mat-label> <mat-label i18n>Unit Price</mat-label>
<input <input formControlName="unitPrice" matInput type="number" />
matInput <span class="ml-2" matSuffix
name="unitPrice" >{{ activityForm.controls['currency'].value }}</span
required >
type="number"
[(ngModel)]="data.transaction.unitPrice"
/>
<span class="ml-2" matSuffix>{{ data.transaction.currency }}</span>
<button <button
*ngIf="currentMarketPrice && (data.transaction.type === 'BUY' || data.transaction.type === 'SELL')" *ngIf="currentMarketPrice && (data.activity.type === 'BUY' || data.activity.type === 'SELL')"
mat-icon-button mat-icon-button
matSuffix matSuffix
title="Apply current market price" title="Apply current market price"
type="button"
(click)="applyCurrentMarketPrice()" (click)="applyCurrentMarketPrice()"
> >
<ion-icon class="text-muted" name="refresh-outline"></ion-icon> <ion-icon class="text-muted" name="refresh-outline"></ion-icon>
@ -144,32 +128,28 @@
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label> <mat-label i18n>Fee</mat-label>
<input <input formControlName="fee" matInput type="number" />
matInput <span class="ml-2" matSuffix
name="fee" >{{ activityForm.controls['currency'].value }}</span
required >
type="number"
[(ngModel)]="data.transaction.fee"
/>
<span class="ml-2" matSuffix>{{ data.transaction.currency }}</span>
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div class="d-flex" mat-dialog-actions> <div class="d-flex" mat-dialog-actions>
<gf-value <gf-value
class="flex-grow-1" class="flex-grow-1"
[currency]="data.transaction.currency" [currency]="activityForm.controls['currency'].value"
[locale]="data.user?.settings?.locale" [locale]="data.user?.settings?.locale"
[value]="data.transaction.fee + (data.transaction.quantity * data.transaction.unitPrice)" [value]="activityForm.controls['fee'].value + (activityForm.controls['quantity'].value * activityForm.controls['unitPrice'].value) ?? 0"
></gf-value> ></gf-value>
<div> <div>
<button i18n mat-button (click)="onCancel()">Cancel</button> <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button <button
color="primary" color="primary"
i18n i18n
mat-flat-button mat-flat-button
[disabled]="!(addTransactionForm.form.valid && data.transaction.currency && data.transaction.symbol)" type="submit"
[mat-dialog-close]="data" [disabled]="!activityForm.valid"
> >
Save Save
</button> </button>

5
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 { User } from '@ghostfolio/common/interfaces';
import { Account, Order } from '@prisma/client'; import { Account } from '@prisma/client';
export interface CreateOrUpdateTransactionDialogParams { export interface CreateOrUpdateTransactionDialogParams {
accountId: string; accountId: string;
accounts: Account[]; accounts: Account[];
transaction: Order; activity: Activity;
user: User; user: User;
} }

47
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) { public onCloneTransaction(aActivity: Activity) {
this.openCreateTransactionDialog(aTransaction); this.openCreateTransactionDialog(aActivity);
} }
public onDeleteTransaction(aId: string) { public onDeleteTransaction(aId: string) {
@ -242,35 +242,13 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
}); });
} }
public openUpdateTransactionDialog({ public openUpdateTransactionDialog(activity: Activity): void {
accountId,
currency,
dataSource,
date,
fee,
id,
quantity,
symbol,
type,
unitPrice
}: OrderModel): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, { const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
data: { data: {
activity,
accounts: this.user?.accounts?.filter((account) => { accounts: this.user?.accounts?.filter((account) => {
return account.accountType === 'SECURITIES'; return account.accountType === 'SECURITIES';
}), }),
transaction: {
accountId,
currency,
dataSource,
date,
fee,
id,
quantity,
symbol,
type,
unitPrice
},
user: this.user user: this.user
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
@ -281,7 +259,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => { .subscribe((data: any) => {
const transaction: UpdateOrderDto = data?.transaction; const transaction: UpdateOrderDto = data?.activity;
if (transaction) { if (transaction) {
this.dataService this.dataService
@ -324,7 +302,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
}); });
} }
private openCreateTransactionDialog(aTransaction?: OrderModel): void { private openCreateTransactionDialog(aActivity?: Activity): void {
this.userService this.userService
.get() .get()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -336,15 +314,14 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
accounts: this.user?.accounts?.filter((account) => { accounts: this.user?.accounts?.filter((account) => {
return account.accountType === 'SECURITIES'; return account.accountType === 'SECURITIES';
}), }),
transaction: { activity: {
accountId: aTransaction?.accountId ?? this.defaultAccountId, ...aActivity,
currency: aTransaction?.currency ?? null, accountId: aActivity?.accountId ?? this.defaultAccountId,
dataSource: aTransaction?.dataSource ?? null,
date: new Date(), date: new Date(),
id: null,
fee: 0, fee: 0,
quantity: null, quantity: null,
symbol: aTransaction?.symbol ?? null, type: aActivity?.type ?? 'BUY',
type: aTransaction?.type ?? 'BUY',
unitPrice: null unitPrice: null
}, },
user: this.user user: this.user
@ -357,7 +334,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => { .subscribe((data: any) => {
const transaction: CreateOrderDto = data?.transaction; const transaction: CreateOrderDto = data?.activity;
if (transaction) { if (transaction) {
this.dataService.postOrder(transaction).subscribe({ this.dataService.postOrder(transaction).subscribe({

2
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 { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { map, Observable } from 'rxjs'; import { Observable, map } from 'rxjs';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'

2
apps/client/src/app/services/import-transactions.service.ts

@ -245,6 +245,8 @@ export class ImportTransactionsService {
return Type.BUY; return Type.BUY;
case 'dividend': case 'dividend':
return Type.DIVIDEND; return Type.DIVIDEND;
case 'item':
return Type.ITEM;
case 'sell': case 'sell':
return Type.SELL; return Type.SELL;
default: default:

1
libs/common/src/lib/interfaces/portfolio-summary.interface.ts

@ -7,6 +7,7 @@ export interface PortfolioSummary extends PortfolioPerformance {
committedFunds: number; committedFunds: number;
fees: number; fees: number;
firstOrderDate: Date; firstOrderDate: Date;
items: number;
netWorth: number; netWorth: number;
ordersCount: number; ordersCount: number;
totalBuy: number; totalBuy: number;

29
libs/ui/src/lib/activities-table/activities-table.component.html

@ -87,15 +87,21 @@
[ngClass]="{ [ngClass]="{
buy: element.type === 'BUY', buy: element.type === 'BUY',
dividend: element.type === 'DIVIDEND', dividend: element.type === 'DIVIDEND',
item: element.type === 'ITEM',
sell: element.type === 'SELL' sell: element.type === 'SELL'
}" }"
> >
<ion-icon <ion-icon
[name]=" *ngIf="element.type === 'BUY' || element.type === 'DIVIDEND'"
element.type === 'BUY' || element.type === 'DIVIDEND' name="arrow-forward-circle-outline"
? 'arrow-forward-circle-outline' ></ion-icon>
: 'arrow-back-circle-outline' <ion-icon
" *ngIf="element.type === 'ITEM'"
name="cube-outline"
></ion-icon>
<ion-icon
*ngIf="element.type === 'SELL'"
name="arrow-back-circle-outline"
></ion-icon> ></ion-icon>
<span class="d-none d-lg-block mx-1">{{ element.type }}</span> <span class="d-none d-lg-block mx-1">{{ element.type }}</span>
</div> </div>
@ -109,7 +115,12 @@
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
{{ element.symbol | gfSymbol }} <span *ngIf="isUUID(element.SymbolProfile.symbol); else symbol">
{{ element.SymbolProfile.name }}
</span>
<ng-template #symbol>
{{ element.SymbolProfile.symbol | gfSymbol }}
</ng-template>
<span *ngIf="element.isDraft" class="badge badge-secondary ml-1" i18n <span *ngIf="element.isDraft" class="badge badge-secondary ml-1" i18n
>Draft</span >Draft</span
> >
@ -349,13 +360,15 @@
(click)=" (click)="
hasPermissionToOpenDetails && hasPermissionToOpenDetails &&
!row.isDraft && !row.isDraft &&
row.type !== 'ITEM' &&
onOpenPositionDialog({ onOpenPositionDialog({
dataSource: row.dataSource, dataSource: row.dataSource,
symbol: row.symbol symbol: row.SymbolProfile.symbol
}) })
" "
[ngClass]="{ [ngClass]="{
'cursor-pointer': hasPermissionToOpenDetails && !row.isDraft 'cursor-pointer':
hasPermissionToOpenDetails && !row.isDraft && row.type !== 'ITEM'
}" }"
></tr> ></tr>
<tr <tr

4
libs/ui/src/lib/activities-table/activities-table.component.scss

@ -54,6 +54,10 @@
color: var(--blue); color: var(--blue);
} }
&.item {
color: var(--purple);
}
&.sell { &.sell {
color: var(--orange); color: var(--orange);
} }

14
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -24,6 +24,7 @@ import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { isUUID } from 'class-validator';
import { endOfToday, format, isAfter } from 'date-fns'; import { endOfToday, format, isAfter } from 'date-fns';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
@ -69,6 +70,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
public filters: Observable<string[]> = this.filters$.asObservable(); public filters: Observable<string[]> = this.filters$.asObservable();
public isAfter = isAfter; public isAfter = isAfter;
public isLoading = true; public isLoading = true;
public isUUID = isUUID;
public placeholder = ''; public placeholder = '';
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
public searchControl = new FormControl(); public searchControl = new FormControl();
@ -271,11 +273,15 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
activity: OrderWithAccount, activity: OrderWithAccount,
fieldValues: Set<string> = new Set<string>() fieldValues: Set<string> = new Set<string>()
): string[] { ): string[] {
fieldValues.add(activity.currency);
fieldValues.add(activity.symbol);
fieldValues.add(activity.type);
fieldValues.add(activity.Account?.name); fieldValues.add(activity.Account?.name);
fieldValues.add(activity.Account?.Platform?.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')); fieldValues.add(format(activity.date, 'yyyy'));
return [...fieldValues].filter((item) => { return [...fieldValues].filter((item) => {
@ -302,7 +308,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
for (const activity of this.dataSource.filteredData) { for (const activity of this.dataSource.filteredData) {
if (isNumber(activity.valueInBaseCurrency)) { if (isNumber(activity.valueInBaseCurrency)) {
if (activity.type === 'BUY') { if (activity.type === 'BUY' || activity.type === 'ITEM') {
totalValue = totalValue.plus(activity.valueInBaseCurrency); totalValue = totalValue.plus(activity.valueInBaseCurrency);
} else if (activity.type === 'SELL') { } else if (activity.type === 'SELL') {
totalValue = totalValue.minus(activity.valueInBaseCurrency); totalValue = totalValue.minus(activity.valueInBaseCurrency);

2
prisma/migrations/20220209194930_added_manual_to_data_source/migration.sql

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "DataSource" ADD VALUE 'MANUAL';

2
prisma/migrations/20220209195038_added_item_to_order_type/migration.sql

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Type" ADD VALUE 'ITEM';

2
prisma/schema.prisma

@ -185,6 +185,7 @@ enum DataSource {
ALPHA_VANTAGE ALPHA_VANTAGE
GHOSTFOLIO GHOSTFOLIO
GOOGLE_SHEETS GOOGLE_SHEETS
MANUAL
RAKUTEN RAKUTEN
YAHOO YAHOO
} }
@ -208,5 +209,6 @@ enum Role {
enum Type { enum Type {
BUY BUY
DIVIDEND DIVIDEND
ITEM
SELL SELL
} }

1
test/import/ok.csv

@ -1,3 +1,4 @@
Date,Code,Currency,Price,Quantity,Action,Fee Date,Code,Currency,Price,Quantity,Action,Fee
17/11/2021,MSFT,USD,0.62,5,dividend,0.00 17/11/2021,MSFT,USD,0.62,5,dividend,0.00
16/09/2021,MSFT,USD,298.580,5,buy,19.00 16/09/2021,MSFT,USD,298.580,5,buy,19.00
01/01/2022,Penthouse Apartment,USD,500000.0,1,item,0.00

1 Date Code Currency Price Quantity Action Fee
2 17/11/2021 MSFT USD 0.62 5 dividend 0.00
3 16/09/2021 MSFT USD 298.580 5 buy 19.00
4 01/01/2022 Penthouse Apartment USD 500000.0 1 item 0.00
Loading…
Cancel
Save