parent
b5f565c054
commit
a850e8ca22
@ -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
|
||||
|
@ -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<ImportResponse> {
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
const activities = await this.importService.getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
userCurrency
|
||||
});
|
||||
|
||||
return { activities };
|
||||
}
|
||||
}
|
||||
|
@ -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]
|
||||
})
|
||||
|
@ -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<Activity[]> {
|
||||
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: <SymbolProfile>(<unknown>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<string>();
|
||||
|
||||
for (const account of accounts) {
|
||||
uniqueAccountIds.add(account.id);
|
||||
}
|
||||
|
||||
return uniqueAccountIds.size === 1;
|
||||
}
|
||||
|
||||
private async validateActivities({
|
||||
activitiesDto,
|
||||
maxActivitiesToImport,
|
||||
|
@ -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
|
||||
});
|
||||
|
||||
|
@ -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',
|
||||
|
@ -59,6 +59,10 @@ import { DataProviderService } from './data-provider.service';
|
||||
]
|
||||
}
|
||||
],
|
||||
exports: [DataProviderService, GhostfolioScraperApiService]
|
||||
exports: [
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
})
|
||||
export class DataProviderModule {}
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -11,6 +11,18 @@ export interface DataProviderInterface {
|
||||
|
||||
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
|
||||
|
||||
getDividends({
|
||||
from,
|
||||
granularity,
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}): Promise<{ [date: string]: IDataProviderHistoricalResponse }>;
|
||||
|
||||
getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity,
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -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: <ImportActivitiesDialogParams>{
|
||||
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 }
|
||||
|
@ -17,6 +17,7 @@
|
||||
(export)="onExport($event)"
|
||||
(exportDrafts)="onExportDrafts($event)"
|
||||
(import)="onImport()"
|
||||
(importDividends)="onImportDividends()"
|
||||
></gf-activities-table>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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<void>();
|
||||
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
@Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams,
|
||||
private dataService: DataService,
|
||||
private formBuilder: FormBuilder,
|
||||
public dialogRef: MatDialogRef<ImportActivitiesDialog>,
|
||||
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);
|
||||
|
@ -7,6 +7,31 @@
|
||||
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<ng-container *ngIf="!isFileSelected">
|
||||
<ng-container *ngIf="mode === 'DIVIDEND'; else selectFile">
|
||||
<form [formGroup]="uniqueAssetForm" (ngSubmit)="onLoadDividends()">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Holding</mat-label>
|
||||
<mat-select formControlName="uniqueAsset">
|
||||
<mat-option
|
||||
*ngFor="let holding of holdings"
|
||||
[value]="{dataSource: holding.dataSource, symbol: holding.symbol}"
|
||||
>{{ holding.name }}</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<div class="d-flex justify-content-center flex-column">
|
||||
<button
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
[disabled]="!uniqueAssetForm.valid"
|
||||
>
|
||||
<span i18n>Load Dividends</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</ng-container>
|
||||
<ng-template #selectFile>
|
||||
<div class="d-flex justify-content-center flex-column">
|
||||
<button
|
||||
class="py-3"
|
||||
@ -18,7 +43,9 @@
|
||||
<span i18n>Choose File</span>
|
||||
</button>
|
||||
<p class="mb-0 mt-4 text-center">
|
||||
<span class="mr-1" i18n>The following file formats are supported:</span>
|
||||
<span class="mr-1" i18n
|
||||
>The following file formats are supported:</span
|
||||
>
|
||||
<a
|
||||
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv"
|
||||
target="_blank"
|
||||
@ -32,6 +59,7 @@
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="isFileSelected">
|
||||
<ng-container *ngIf="errorMessages.length === 0; else errorMessage">
|
||||
@ -47,6 +75,7 @@
|
||||
[locale]="data?.user?.settings?.locale"
|
||||
[showActions]="false"
|
||||
[showCheckbox]="true"
|
||||
[showFooter]="false"
|
||||
[showSymbolColumn]="false"
|
||||
(selectedActivities)="updateSelection($event)"
|
||||
></gf-activities-table>
|
||||
|
@ -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]
|
||||
})
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { Type } from '@prisma/client';
|
||||
|
||||
export interface ImportActivitiesDialogParams {
|
||||
activityTypes: Type[];
|
||||
deviceType: string;
|
||||
user: User;
|
||||
}
|
||||
|
@ -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<ImportResponse>(
|
||||
`/api/v1/import/dividends/${dataSource}/${symbol}`
|
||||
);
|
||||
}
|
||||
|
||||
public fetchExchangeRateForDate({
|
||||
date,
|
||||
symbol
|
||||
|
@ -90,13 +90,16 @@ export class ImportActivitiesService {
|
||||
selectedActivities: Activity[]
|
||||
): Promise<Activity[]> {
|
||||
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,
|
||||
|
@ -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;
|
||||
|
@ -4,6 +4,7 @@ export interface HistoricalDataItem {
|
||||
grossPerformancePercent?: number;
|
||||
netPerformance?: number;
|
||||
netPerformanceInPercentage?: number;
|
||||
quantity?: number;
|
||||
totalInvestment?: number;
|
||||
value?: number;
|
||||
}
|
||||
|
3
libs/common/src/lib/types/account-with-platform.type.ts
Normal file
3
libs/common/src/lib/types/account-with-platform.type.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { Account, Platform } from '@prisma/client';
|
||||
|
||||
export type AccountWithPlatform = Account & { Platform?: Platform };
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -117,7 +117,7 @@
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex align-items-center">
|
||||
<div>
|
||||
<span class="text-truncate">{{ element.SymbolProfile.name }}</span>
|
||||
<span class="text-truncate">{{ element.SymbolProfile?.name }}</span>
|
||||
<span
|
||||
*ngIf="element.isDraft"
|
||||
class="badge badge-secondary ml-1"
|
||||
@ -126,9 +126,9 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="!isUUID(element.SymbolProfile.symbol)">
|
||||
<div *ngIf="!isUUID(element.SymbolProfile?.symbol)">
|
||||
<small class="text-muted">{{
|
||||
element.SymbolProfile.symbol | gfSymbol
|
||||
element.SymbolProfile?.symbol | gfSymbol
|
||||
}}</small>
|
||||
</div>
|
||||
</td>
|
||||
@ -149,7 +149,7 @@
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-cell
|
||||
>
|
||||
{{ element.SymbolProfile.currency }}
|
||||
{{ element.SymbolProfile?.currency }}
|
||||
</td>
|
||||
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
|
||||
{{ baseCurrency }}
|
||||
@ -388,6 +388,14 @@
|
||||
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
|
||||
<span i18n>Import Activities</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="hasPermissionToImportActivities"
|
||||
mat-menu-item
|
||||
(click)="onImportDividends()"
|
||||
>
|
||||
<ion-icon class="mr-2" name="color-wand-outline"></ion-icon>
|
||||
<span i18n>Import Dividends</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="hasPermissionToExportActivities"
|
||||
class="align-items-center d-flex"
|
||||
@ -459,7 +467,10 @@
|
||||
<tr
|
||||
*matFooterRowDef="displayedColumns"
|
||||
mat-footer-row
|
||||
[ngClass]="{ 'd-none': isLoading || dataSource.data.length === 0 }"
|
||||
[ngClass]="{
|
||||
'd-none':
|
||||
isLoading || dataSource.data.length === 0 || showFooter === false
|
||||
}"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -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<string>();
|
||||
@ -51,6 +52,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
@Output() export = new EventEmitter<string[]>();
|
||||
@Output() exportDrafts = new EventEmitter<string[]>();
|
||||
@Output() import = new EventEmitter<void>();
|
||||
@Output() importDividends = new EventEmitter<UniqueAsset>();
|
||||
@Output() selectedActivities = new EventEmitter<Activity[]>();
|
||||
|
||||
@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 {
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user