mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
821 changed files with 116550 additions and 24849 deletions
@ -0,0 +1 @@ |
|||||
|
14d4daf73eefed7da7c32ec19bc37e678be0244fb46c8f4965bfe9ece7384706ed58222ad9b96323893c1d845bc33a308e7524c2c79636062cbb095e0780cb51 |
@ -0,0 +1 @@ |
|||||
|
v18 |
@ -1,11 +0,0 @@ |
|||||
module.exports = { |
|
||||
stories: [], |
|
||||
addons: ['@storybook/addon-essentials'] |
|
||||
// uncomment the property below if you want to apply some webpack config globally
|
|
||||
// webpackFinal: async (config, { configType }) => {
|
|
||||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
|
||||
|
|
||||
// // Return the altered config
|
|
||||
// return config;
|
|
||||
// },
|
|
||||
}; |
|
@ -1,10 +0,0 @@ |
|||||
{ |
|
||||
"extends": "../tsconfig.base.json", |
|
||||
"exclude": [ |
|
||||
"../**/*.spec.js", |
|
||||
"../**/*.spec.ts", |
|
||||
"../**/*.spec.tsx", |
|
||||
"../**/*.spec.jsx" |
|
||||
], |
|
||||
"include": ["../**/*"] |
|
||||
} |
|
File diff suppressed because it is too large
@ -0,0 +1,37 @@ |
|||||
|
# Ghostfolio Development Guide |
||||
|
|
||||
|
## Git |
||||
|
|
||||
|
### Rebase |
||||
|
|
||||
|
`git rebase -i --autosquash main` |
||||
|
|
||||
|
## Dependencies |
||||
|
|
||||
|
### Nx |
||||
|
|
||||
|
#### Upgrade |
||||
|
|
||||
|
1. Run `yarn nx migrate latest` |
||||
|
1. Make sure `package.json` changes make sense and then run `yarn install` |
||||
|
1. Run `yarn nx migrate --run-migrations` |
||||
|
|
||||
|
### Prisma |
||||
|
|
||||
|
#### Access database via GUI |
||||
|
|
||||
|
Run `yarn database:gui` |
||||
|
|
||||
|
https://www.prisma.io/studio |
||||
|
|
||||
|
#### Synchronize schema with database for prototyping |
||||
|
|
||||
|
Run `yarn database:push` |
||||
|
|
||||
|
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push |
||||
|
|
||||
|
#### Create schema migration |
||||
|
|
||||
|
Run `yarn prisma migrate dev --name added_job_title` |
||||
|
|
||||
|
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate |
@ -0,0 +1,12 @@ |
|||||
|
import { IsNumber, IsString } from 'class-validator'; |
||||
|
|
||||
|
export class TransferBalanceDto { |
||||
|
@IsString() |
||||
|
accountIdFrom: string; |
||||
|
|
||||
|
@IsString() |
||||
|
accountIdTo: string; |
||||
|
|
||||
|
@IsNumber() |
||||
|
balance: number; |
||||
|
} |
@ -0,0 +1,18 @@ |
|||||
|
import { Prisma } from '@prisma/client'; |
||||
|
import { IsObject, IsOptional, IsString } from 'class-validator'; |
||||
|
|
||||
|
export class UpdateAssetProfileDto { |
||||
|
@IsString() |
||||
|
@IsOptional() |
||||
|
comment?: string; |
||||
|
|
||||
|
@IsObject() |
||||
|
@IsOptional() |
||||
|
scraperConfiguration?: Prisma.InputJsonObject; |
||||
|
|
||||
|
@IsObject() |
||||
|
@IsOptional() |
||||
|
symbolMapping?: { |
||||
|
[dataProvider: string]: string; |
||||
|
}; |
||||
|
} |
@ -0,0 +1,43 @@ |
|||||
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; |
||||
|
import { |
||||
|
Controller, |
||||
|
Get, |
||||
|
HttpException, |
||||
|
Param, |
||||
|
UseGuards |
||||
|
} from '@nestjs/common'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
import { parseISO } from 'date-fns'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
import { ExchangeRateService } from './exchange-rate.service'; |
||||
|
|
||||
|
@Controller('exchange-rate') |
||||
|
export class ExchangeRateController { |
||||
|
public constructor( |
||||
|
private readonly exchangeRateService: ExchangeRateService |
||||
|
) {} |
||||
|
|
||||
|
@Get(':symbol/:dateString') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async getExchangeRate( |
||||
|
@Param('dateString') dateString: string, |
||||
|
@Param('symbol') symbol: string |
||||
|
): Promise<IDataProviderHistoricalResponse> { |
||||
|
const date = parseISO(dateString); |
||||
|
|
||||
|
const exchangeRate = await this.exchangeRateService.getExchangeRate({ |
||||
|
date, |
||||
|
symbol |
||||
|
}); |
||||
|
|
||||
|
if (exchangeRate) { |
||||
|
return { marketPrice: exchangeRate }; |
||||
|
} |
||||
|
|
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
} |
@ -0,0 +1,13 @@ |
|||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; |
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { ExchangeRateController } from './exchange-rate.controller'; |
||||
|
import { ExchangeRateService } from './exchange-rate.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [ExchangeRateController], |
||||
|
exports: [ExchangeRateService], |
||||
|
imports: [ExchangeRateDataModule], |
||||
|
providers: [ExchangeRateService] |
||||
|
}) |
||||
|
export class ExchangeRateModule {} |
@ -0,0 +1,26 @@ |
|||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class ExchangeRateService { |
||||
|
public constructor( |
||||
|
private readonly exchangeRateDataService: ExchangeRateDataService |
||||
|
) {} |
||||
|
|
||||
|
public async getExchangeRate({ |
||||
|
date, |
||||
|
symbol |
||||
|
}: { |
||||
|
date: Date; |
||||
|
symbol: string; |
||||
|
}): Promise<number> { |
||||
|
const [currency1, currency2] = symbol.split('-'); |
||||
|
|
||||
|
return this.exchangeRateDataService.toCurrencyAtDate( |
||||
|
1, |
||||
|
currency1, |
||||
|
currency2, |
||||
|
date |
||||
|
); |
||||
|
} |
||||
|
} |
@ -1,146 +0,0 @@ |
|||||
import * as fs from 'fs'; |
|
||||
import * as path from 'path'; |
|
||||
|
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; |
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; |
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common'; |
|
||||
import { ConfigService } from '@nestjs/config'; |
|
||||
import { NextFunction, Request, Response } from 'express'; |
|
||||
|
|
||||
@Injectable() |
|
||||
export class FrontendMiddleware implements NestMiddleware { |
|
||||
public indexHtmlDe = ''; |
|
||||
public indexHtmlEn = ''; |
|
||||
public indexHtmlEs = ''; |
|
||||
public indexHtmlIt = ''; |
|
||||
public indexHtmlNl = ''; |
|
||||
public isProduction: boolean; |
|
||||
|
|
||||
public constructor( |
|
||||
private readonly configService: ConfigService, |
|
||||
private readonly configurationService: ConfigurationService |
|
||||
) { |
|
||||
const NODE_ENV = |
|
||||
this.configService.get<'development' | 'production'>('NODE_ENV') ?? |
|
||||
'development'; |
|
||||
|
|
||||
this.isProduction = NODE_ENV === 'production'; |
|
||||
|
|
||||
try { |
|
||||
this.indexHtmlDe = fs.readFileSync( |
|
||||
this.getPathOfIndexHtmlFile('de'), |
|
||||
'utf8' |
|
||||
); |
|
||||
this.indexHtmlEn = fs.readFileSync( |
|
||||
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE), |
|
||||
'utf8' |
|
||||
); |
|
||||
this.indexHtmlEs = fs.readFileSync( |
|
||||
this.getPathOfIndexHtmlFile('es'), |
|
||||
'utf8' |
|
||||
); |
|
||||
this.indexHtmlIt = fs.readFileSync( |
|
||||
this.getPathOfIndexHtmlFile('it'), |
|
||||
'utf8' |
|
||||
); |
|
||||
this.indexHtmlNl = fs.readFileSync( |
|
||||
this.getPathOfIndexHtmlFile('nl'), |
|
||||
'utf8' |
|
||||
); |
|
||||
} catch {} |
|
||||
} |
|
||||
|
|
||||
public use(req: Request, res: Response, next: NextFunction) { |
|
||||
let featureGraphicPath = 'assets/cover.png'; |
|
||||
|
|
||||
if ( |
|
||||
req.path === '/en/blog/2022/08/500-stars-on-github' || |
|
||||
req.path === '/en/blog/2022/08/500-stars-on-github/' |
|
||||
) { |
|
||||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg'; |
|
||||
} else if ( |
|
||||
req.path === '/en/blog/2022/10/hacktoberfest-2022' || |
|
||||
req.path === '/en/blog/2022/10/hacktoberfest-2022/' |
|
||||
) { |
|
||||
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png'; |
|
||||
} |
|
||||
|
|
||||
if ( |
|
||||
req.path.startsWith('/api/') || |
|
||||
this.isFileRequest(req.url) || |
|
||||
!this.isProduction |
|
||||
) { |
|
||||
// Skip
|
|
||||
next(); |
|
||||
} else if (req.path === '/de' || req.path.startsWith('/de/')) { |
|
||||
res.send( |
|
||||
this.interpolate(this.indexHtmlDe, { |
|
||||
featureGraphicPath, |
|
||||
languageCode: 'de', |
|
||||
path: req.path, |
|
||||
rootUrl: this.configurationService.get('ROOT_URL') |
|
||||
}) |
|
||||
); |
|
||||
} else if (req.path === '/es' || req.path.startsWith('/es/')) { |
|
||||
res.send( |
|
||||
this.interpolate(this.indexHtmlEs, { |
|
||||
featureGraphicPath, |
|
||||
languageCode: 'es', |
|
||||
path: req.path, |
|
||||
rootUrl: this.configurationService.get('ROOT_URL') |
|
||||
}) |
|
||||
); |
|
||||
} else if (req.path === '/it' || req.path.startsWith('/it/')) { |
|
||||
res.send( |
|
||||
this.interpolate(this.indexHtmlIt, { |
|
||||
featureGraphicPath, |
|
||||
languageCode: 'it', |
|
||||
path: req.path, |
|
||||
rootUrl: this.configurationService.get('ROOT_URL') |
|
||||
}) |
|
||||
); |
|
||||
} else if (req.path === '/nl' || req.path.startsWith('/nl/')) { |
|
||||
res.send( |
|
||||
this.interpolate(this.indexHtmlNl, { |
|
||||
featureGraphicPath, |
|
||||
languageCode: 'nl', |
|
||||
path: req.path, |
|
||||
rootUrl: this.configurationService.get('ROOT_URL') |
|
||||
}) |
|
||||
); |
|
||||
} else { |
|
||||
res.send( |
|
||||
this.interpolate(this.indexHtmlEn, { |
|
||||
featureGraphicPath, |
|
||||
languageCode: DEFAULT_LANGUAGE_CODE, |
|
||||
path: req.path, |
|
||||
rootUrl: this.configurationService.get('ROOT_URL') |
|
||||
}) |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private getPathOfIndexHtmlFile(aLocale: string) { |
|
||||
return path.join(__dirname, '..', 'client', aLocale, 'index.html'); |
|
||||
} |
|
||||
|
|
||||
private interpolate(template: string, context: any) { |
|
||||
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => { |
|
||||
const properties = objectPath.split('.'); |
|
||||
return properties.reduce( |
|
||||
(previous, current) => previous?.[current], |
|
||||
context |
|
||||
); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
private isFileRequest(filename: string) { |
|
||||
if (filename === '/assets/LICENSE') { |
|
||||
return true; |
|
||||
} else if (filename.includes('auth/ey')) { |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
return filename.split('.').pop() !== filename; |
|
||||
} |
|
||||
} |
|
@ -0,0 +1,56 @@ |
|||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; |
||||
|
import { |
||||
|
Controller, |
||||
|
Get, |
||||
|
HttpException, |
||||
|
Param, |
||||
|
UseInterceptors |
||||
|
} from '@nestjs/common'; |
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
import { HealthService } from './health.service'; |
||||
|
|
||||
|
@Controller('health') |
||||
|
export class HealthController { |
||||
|
public constructor(private readonly healthService: HealthService) {} |
||||
|
|
||||
|
@Get() |
||||
|
public async getHealth() {} |
||||
|
|
||||
|
@Get('data-enhancer/:name') |
||||
|
public async getHealthOfDataEnhancer(@Param('name') name: string) { |
||||
|
const hasResponse = |
||||
|
await this.healthService.hasResponseFromDataEnhancer(name); |
||||
|
|
||||
|
if (hasResponse !== true) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), |
||||
|
StatusCodes.SERVICE_UNAVAILABLE |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Get('data-provider/:dataSource') |
||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
||||
|
public async getHealthOfDataProvider( |
||||
|
@Param('dataSource') dataSource: DataSource |
||||
|
) { |
||||
|
if (!DataSource[dataSource]) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const hasResponse = |
||||
|
await this.healthService.hasResponseFromDataProvider(dataSource); |
||||
|
|
||||
|
if (hasResponse !== true) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), |
||||
|
StatusCodes.SERVICE_UNAVAILABLE |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,14 @@ |
|||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
||||
|
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; |
||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; |
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { HealthController } from './health.controller'; |
||||
|
import { HealthService } from './health.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [HealthController], |
||||
|
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule], |
||||
|
providers: [HealthService] |
||||
|
}) |
||||
|
export class HealthModule {} |
@ -0,0 +1,20 @@ |
|||||
|
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service'; |
||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class HealthService { |
||||
|
public constructor( |
||||
|
private readonly dataEnhancerService: DataEnhancerService, |
||||
|
private readonly dataProviderService: DataProviderService |
||||
|
) {} |
||||
|
|
||||
|
public async hasResponseFromDataEnhancer(aName: string) { |
||||
|
return this.dataEnhancerService.enhance(aName); |
||||
|
} |
||||
|
|
||||
|
public async hasResponseFromDataProvider(aDataSource: DataSource) { |
||||
|
return this.dataProviderService.checkQuote(aDataSource); |
||||
|
} |
||||
|
} |
@ -1,149 +1,594 @@ |
|||||
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
||||
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; |
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
||||
|
import { |
||||
|
Activity, |
||||
|
ActivityError |
||||
|
} from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service'; |
import { OrderService } from '@ghostfolio/api/app/order/order.service'; |
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; |
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; |
||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
||||
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; |
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
||||
|
import { |
||||
|
DATE_FORMAT, |
||||
|
getAssetProfileIdentifier, |
||||
|
parseDate |
||||
|
} from '@ghostfolio/common/helper'; |
||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces'; |
||||
|
import { |
||||
|
AccountWithPlatform, |
||||
|
OrderWithAccount |
||||
|
} from '@ghostfolio/common/types'; |
||||
import { Injectable } from '@nestjs/common'; |
import { Injectable } from '@nestjs/common'; |
||||
import { isSameDay, parseISO } from 'date-fns'; |
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; |
||||
|
import Big from 'big.js'; |
||||
|
import { endOfToday, format, isAfter, isSameDay, parseISO } from 'date-fns'; |
||||
|
import { uniqBy } from 'lodash'; |
||||
|
import { v4 as uuidv4 } from 'uuid'; |
||||
|
|
||||
@Injectable() |
@Injectable() |
||||
export class ImportService { |
export class ImportService { |
||||
public constructor( |
public constructor( |
||||
private readonly accountService: AccountService, |
private readonly accountService: AccountService, |
||||
private readonly configurationService: ConfigurationService, |
private readonly dataGatheringService: DataGatheringService, |
||||
private readonly dataProviderService: DataProviderService, |
private readonly dataProviderService: DataProviderService, |
||||
private readonly orderService: OrderService |
private readonly exchangeRateDataService: ExchangeRateDataService, |
||||
|
private readonly orderService: OrderService, |
||||
|
private readonly platformService: PlatformService, |
||||
|
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(); |
||||
|
|
||||
|
const isDuplicate = orders.some((activity) => { |
||||
|
return ( |
||||
|
activity.SymbolProfile.currency === assetProfile.currency && |
||||
|
activity.SymbolProfile.dataSource === assetProfile.dataSource && |
||||
|
isSameDay(activity.date, parseDate(dateString)) && |
||||
|
activity.quantity === quantity && |
||||
|
activity.SymbolProfile.symbol === assetProfile.symbol && |
||||
|
activity.type === 'DIVIDEND' && |
||||
|
activity.unitPrice === marketPrice |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
const error: ActivityError = isDuplicate |
||||
|
? { code: 'IS_DUPLICATE' } |
||||
|
: undefined; |
||||
|
|
||||
|
return { |
||||
|
Account, |
||||
|
error, |
||||
|
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({ |
public async import({ |
||||
activities, |
accountsDto, |
||||
|
activitiesDto, |
||||
|
isDryRun = false, |
||||
maxActivitiesToImport, |
maxActivitiesToImport, |
||||
|
userCurrency, |
||||
userId |
userId |
||||
}: { |
}: { |
||||
activities: Partial<CreateOrderDto>[]; |
accountsDto: Partial<CreateAccountDto>[]; |
||||
|
activitiesDto: Partial<CreateOrderDto>[]; |
||||
|
isDryRun?: boolean; |
||||
maxActivitiesToImport: number; |
maxActivitiesToImport: number; |
||||
|
userCurrency: string; |
||||
userId: string; |
userId: string; |
||||
}): Promise<void> { |
}): Promise<Activity[]> { |
||||
for (const activity of activities) { |
const accountIdMapping: { [oldAccountId: string]: string } = {}; |
||||
|
|
||||
|
if (!isDryRun && accountsDto?.length) { |
||||
|
const [existingAccounts, existingPlatforms] = await Promise.all([ |
||||
|
this.accountService.accounts({ |
||||
|
where: { |
||||
|
id: { |
||||
|
in: accountsDto.map(({ id }) => { |
||||
|
return id; |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
}), |
||||
|
this.platformService.getPlatforms() |
||||
|
]); |
||||
|
|
||||
|
for (const account of accountsDto) { |
||||
|
// Check if there is any existing account with the same ID
|
||||
|
const accountWithSameId = existingAccounts.find( |
||||
|
(existingAccount) => existingAccount.id === account.id |
||||
|
); |
||||
|
|
||||
|
// If there is no account or if the account belongs to a different user then create a new account
|
||||
|
if (!accountWithSameId || accountWithSameId.userId !== userId) { |
||||
|
let oldAccountId: string; |
||||
|
const platformId = account.platformId; |
||||
|
|
||||
|
delete account.platformId; |
||||
|
|
||||
|
if (accountWithSameId) { |
||||
|
oldAccountId = account.id; |
||||
|
delete account.id; |
||||
|
} |
||||
|
|
||||
|
let accountObject: Prisma.AccountCreateInput = { |
||||
|
...account, |
||||
|
User: { connect: { id: userId } } |
||||
|
}; |
||||
|
|
||||
|
if ( |
||||
|
existingPlatforms.some(({ id }) => { |
||||
|
return id === platformId; |
||||
|
}) |
||||
|
) { |
||||
|
accountObject = { |
||||
|
...accountObject, |
||||
|
Platform: { connect: { id: platformId } } |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const newAccount = await this.accountService.createAccount( |
||||
|
accountObject, |
||||
|
userId |
||||
|
); |
||||
|
|
||||
|
// Store the new to old account ID mappings for updating activities
|
||||
|
if (accountWithSameId && oldAccountId) { |
||||
|
accountIdMapping[oldAccountId] = newAccount.id; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
for (const activity of activitiesDto) { |
||||
if (!activity.dataSource) { |
if (!activity.dataSource) { |
||||
if (activity.type === 'ITEM') { |
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') { |
||||
activity.dataSource = 'MANUAL'; |
activity.dataSource = DataSource.MANUAL; |
||||
} else { |
} else { |
||||
activity.dataSource = this.dataProviderService.getPrimaryDataSource(); |
activity.dataSource = |
||||
|
this.dataProviderService.getDataSourceForImport(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// If a new account is created, then update the accountId in all activities
|
||||
|
if (!isDryRun) { |
||||
|
if (Object.keys(accountIdMapping).includes(activity.accountId)) { |
||||
|
activity.accountId = accountIdMapping[activity.accountId]; |
||||
} |
} |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
await this.validateActivities({ |
const assetProfiles = await this.validateActivities({ |
||||
activities, |
activitiesDto, |
||||
maxActivitiesToImport, |
maxActivitiesToImport |
||||
|
}); |
||||
|
|
||||
|
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ |
||||
|
activitiesDto, |
||||
userId |
userId |
||||
}); |
}); |
||||
|
|
||||
const accountIds = (await this.accountService.getAccounts(userId)).map( |
const accounts = (await this.accountService.getAccounts(userId)).map( |
||||
(account) => { |
({ id, name }) => { |
||||
return account.id; |
return { id, name }; |
||||
} |
} |
||||
); |
); |
||||
|
|
||||
for (const { |
if (isDryRun) { |
||||
accountId, |
accountsDto.forEach(({ id, name }) => { |
||||
comment, |
accounts.push({ id, name }); |
||||
currency, |
}); |
||||
dataSource, |
} |
||||
date, |
|
||||
fee, |
const activities: Activity[] = []; |
||||
quantity, |
|
||||
symbol, |
for (let [ |
||||
type, |
index, |
||||
unitPrice |
{ |
||||
} of activities) { |
accountId, |
||||
await this.orderService.createOrder({ |
|
||||
comment, |
comment, |
||||
|
date, |
||||
|
error, |
||||
fee, |
fee, |
||||
quantity, |
quantity, |
||||
|
SymbolProfile, |
||||
type, |
type, |
||||
unitPrice, |
unitPrice |
||||
userId, |
} |
||||
accountId: accountIds.includes(accountId) ? accountId : undefined, |
] of activitiesExtendedWithErrors.entries()) { |
||||
date: parseISO(<string>(<unknown>date)), |
const assetProfile = assetProfiles[ |
||||
SymbolProfile: { |
getAssetProfileIdentifier({ |
||||
connectOrCreate: { |
dataSource: SymbolProfile.dataSource, |
||||
create: { |
symbol: SymbolProfile.symbol |
||||
currency, |
}) |
||||
dataSource, |
] ?? { |
||||
symbol |
currency: SymbolProfile.currency, |
||||
}, |
dataSource: SymbolProfile.dataSource, |
||||
where: { |
symbol: SymbolProfile.symbol |
||||
dataSource_symbol: { |
}; |
||||
|
const { |
||||
|
assetClass, |
||||
|
assetSubClass, |
||||
|
countries, |
||||
|
createdAt, |
||||
|
currency, |
||||
|
dataSource, |
||||
|
id, |
||||
|
isin, |
||||
|
name, |
||||
|
scraperConfiguration, |
||||
|
sectors, |
||||
|
symbol, |
||||
|
symbolMapping, |
||||
|
url, |
||||
|
updatedAt |
||||
|
} = assetProfile; |
||||
|
const validatedAccount = accounts.find(({ id }) => { |
||||
|
return id === accountId; |
||||
|
}); |
||||
|
|
||||
|
let order: |
||||
|
| OrderWithAccount |
||||
|
| (Omit<OrderWithAccount, 'Account'> & { |
||||
|
Account?: { id: string; name: string }; |
||||
|
}); |
||||
|
|
||||
|
if (SymbolProfile.currency !== assetProfile.currency) { |
||||
|
// Convert the unit price and fee to the asset currency if the imported
|
||||
|
// activity is in a different currency
|
||||
|
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate( |
||||
|
unitPrice, |
||||
|
SymbolProfile.currency, |
||||
|
assetProfile.currency, |
||||
|
date |
||||
|
); |
||||
|
|
||||
|
if (!unitPrice) { |
||||
|
throw new Error( |
||||
|
`activities.${index} historical exchange rate at ${format( |
||||
|
date, |
||||
|
DATE_FORMAT |
||||
|
)} is not available from "${SymbolProfile.currency}" to "${ |
||||
|
assetProfile.currency |
||||
|
}"` |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
fee = await this.exchangeRateDataService.toCurrencyAtDate( |
||||
|
fee, |
||||
|
SymbolProfile.currency, |
||||
|
assetProfile.currency, |
||||
|
date |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (isDryRun) { |
||||
|
order = { |
||||
|
comment, |
||||
|
date, |
||||
|
fee, |
||||
|
quantity, |
||||
|
type, |
||||
|
unitPrice, |
||||
|
userId, |
||||
|
accountId: validatedAccount?.id, |
||||
|
accountUserId: undefined, |
||||
|
createdAt: new Date(), |
||||
|
id: uuidv4(), |
||||
|
isDraft: isAfter(date, endOfToday()), |
||||
|
SymbolProfile: { |
||||
|
assetClass, |
||||
|
assetSubClass, |
||||
|
countries, |
||||
|
createdAt, |
||||
|
currency, |
||||
|
dataSource, |
||||
|
id, |
||||
|
isin, |
||||
|
name, |
||||
|
scraperConfiguration, |
||||
|
sectors, |
||||
|
symbol, |
||||
|
symbolMapping, |
||||
|
updatedAt, |
||||
|
url, |
||||
|
comment: assetProfile.comment |
||||
|
}, |
||||
|
Account: validatedAccount, |
||||
|
symbolProfileId: undefined, |
||||
|
updatedAt: new Date() |
||||
|
}; |
||||
|
} else { |
||||
|
if (error) { |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
order = await this.orderService.createOrder({ |
||||
|
comment, |
||||
|
date, |
||||
|
fee, |
||||
|
quantity, |
||||
|
type, |
||||
|
unitPrice, |
||||
|
userId, |
||||
|
accountId: validatedAccount?.id, |
||||
|
SymbolProfile: { |
||||
|
connectOrCreate: { |
||||
|
create: { |
||||
|
currency, |
||||
dataSource, |
dataSource, |
||||
symbol |
symbol |
||||
|
}, |
||||
|
where: { |
||||
|
dataSource_symbol: { |
||||
|
dataSource, |
||||
|
symbol |
||||
|
} |
||||
} |
} |
||||
} |
} |
||||
} |
}, |
||||
}, |
updateAccountBalance: false, |
||||
User: { connect: { id: userId } } |
User: { connect: { id: userId } } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const value = new Big(quantity).mul(unitPrice).toNumber(); |
||||
|
|
||||
|
activities.push({ |
||||
|
...order, |
||||
|
error, |
||||
|
value, |
||||
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( |
||||
|
fee, |
||||
|
currency, |
||||
|
userCurrency |
||||
|
), |
||||
|
// @ts-ignore
|
||||
|
SymbolProfile: assetProfile, |
||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( |
||||
|
value, |
||||
|
currency, |
||||
|
userCurrency |
||||
|
) |
||||
}); |
}); |
||||
} |
} |
||||
|
|
||||
|
activities.sort((activity1, activity2) => { |
||||
|
return Number(activity1.date) - Number(activity2.date); |
||||
|
}); |
||||
|
|
||||
|
if (!isDryRun) { |
||||
|
// Gather symbol data in the background, if not dry run
|
||||
|
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => { |
||||
|
return getAssetProfileIdentifier({ |
||||
|
dataSource: SymbolProfile.dataSource, |
||||
|
symbol: SymbolProfile.symbol |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
this.dataGatheringService.gatherSymbols( |
||||
|
uniqueActivities.map(({ date, SymbolProfile }) => { |
||||
|
return { |
||||
|
date, |
||||
|
dataSource: SymbolProfile.dataSource, |
||||
|
symbol: SymbolProfile.symbol |
||||
|
}; |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return activities; |
||||
} |
} |
||||
|
|
||||
private async validateActivities({ |
private async extendActivitiesWithErrors({ |
||||
activities, |
activitiesDto, |
||||
maxActivitiesToImport, |
|
||||
userId |
userId |
||||
}: { |
}: { |
||||
activities: Partial<CreateOrderDto>[]; |
activitiesDto: Partial<CreateOrderDto>[]; |
||||
maxActivitiesToImport: number; |
|
||||
userId: string; |
userId: string; |
||||
}) { |
}): Promise<Partial<Activity>[]> { |
||||
if (activities?.length > maxActivitiesToImport) { |
|
||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); |
|
||||
} |
|
||||
|
|
||||
const existingActivities = await this.orderService.orders({ |
const existingActivities = await this.orderService.orders({ |
||||
include: { SymbolProfile: true }, |
include: { SymbolProfile: true }, |
||||
orderBy: { date: 'desc' }, |
orderBy: { date: 'desc' }, |
||||
where: { userId } |
where: { userId } |
||||
}); |
}); |
||||
|
|
||||
for (const [ |
return activitiesDto.map( |
||||
index, |
({ |
||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice } |
accountId, |
||||
] of activities.entries()) { |
comment, |
||||
const duplicateActivity = existingActivities.find((activity) => { |
currency, |
||||
return ( |
dataSource, |
||||
activity.SymbolProfile.currency === currency && |
date: dateString, |
||||
activity.SymbolProfile.dataSource === dataSource && |
fee, |
||||
isSameDay(activity.date, parseISO(<string>(<unknown>date))) && |
quantity, |
||||
activity.fee === fee && |
symbol, |
||||
activity.quantity === quantity && |
type, |
||||
activity.SymbolProfile.symbol === symbol && |
unitPrice |
||||
activity.type === type && |
}) => { |
||||
activity.unitPrice === unitPrice |
const date = parseISO(<string>(<unknown>dateString)); |
||||
); |
const isDuplicate = existingActivities.some((activity) => { |
||||
}); |
return ( |
||||
|
activity.SymbolProfile.currency === currency && |
||||
|
activity.SymbolProfile.dataSource === dataSource && |
||||
|
isSameDay(activity.date, date) && |
||||
|
activity.fee === fee && |
||||
|
activity.quantity === quantity && |
||||
|
activity.SymbolProfile.symbol === symbol && |
||||
|
activity.type === type && |
||||
|
activity.unitPrice === unitPrice |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
const error: ActivityError = isDuplicate |
||||
|
? { code: 'IS_DUPLICATE' } |
||||
|
: undefined; |
||||
|
|
||||
if (duplicateActivity) { |
return { |
||||
throw new Error(`activities.${index} is a duplicate activity`); |
accountId, |
||||
|
comment, |
||||
|
date, |
||||
|
error, |
||||
|
fee, |
||||
|
quantity, |
||||
|
type, |
||||
|
unitPrice, |
||||
|
SymbolProfile: { |
||||
|
currency, |
||||
|
dataSource, |
||||
|
symbol, |
||||
|
assetClass: null, |
||||
|
assetSubClass: null, |
||||
|
comment: null, |
||||
|
countries: null, |
||||
|
createdAt: undefined, |
||||
|
id: undefined, |
||||
|
isin: null, |
||||
|
name: null, |
||||
|
scraperConfiguration: null, |
||||
|
sectors: null, |
||||
|
symbolMapping: null, |
||||
|
updatedAt: undefined, |
||||
|
url: null |
||||
|
} |
||||
|
}; |
||||
} |
} |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
}: { |
||||
|
activitiesDto: Partial<CreateOrderDto>[]; |
||||
|
maxActivitiesToImport: number; |
||||
|
}) { |
||||
|
if (activitiesDto?.length > maxActivitiesToImport) { |
||||
|
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); |
||||
|
} |
||||
|
|
||||
|
const assetProfiles: { |
||||
|
[assetProfileIdentifier: string]: Partial<SymbolProfile>; |
||||
|
} = {}; |
||||
|
|
||||
|
const uniqueActivitiesDto = uniqBy( |
||||
|
activitiesDto, |
||||
|
({ dataSource, symbol }) => { |
||||
|
return getAssetProfileIdentifier({ dataSource, symbol }); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
for (const [ |
||||
|
index, |
||||
|
{ currency, dataSource, symbol } |
||||
|
] of uniqueActivitiesDto.entries()) { |
||||
if (dataSource !== 'MANUAL') { |
if (dataSource !== 'MANUAL') { |
||||
const quotes = await this.dataProviderService.getQuotes([ |
const assetProfile = ( |
||||
{ dataSource, symbol } |
await this.dataProviderService.getAssetProfiles([ |
||||
]); |
{ dataSource, symbol } |
||||
|
]) |
||||
|
)?.[symbol]; |
||||
|
|
||||
if (quotes[symbol] === undefined) { |
if (!assetProfile?.name) { |
||||
throw new Error( |
throw new Error( |
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` |
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` |
||||
); |
); |
||||
} |
} |
||||
|
|
||||
if (quotes[symbol].currency !== currency) { |
if ( |
||||
|
assetProfile.currency !== currency && |
||||
|
!this.exchangeRateDataService.hasCurrencyPair( |
||||
|
currency, |
||||
|
assetProfile.currency |
||||
|
) |
||||
|
) { |
||||
throw new Error( |
throw new Error( |
||||
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"` |
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"` |
||||
); |
); |
||||
} |
} |
||||
|
|
||||
|
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = |
||||
|
assetProfile; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
|
return assetProfiles; |
||||
} |
} |
||||
} |
} |
||||
|
@ -0,0 +1,54 @@ |
|||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; |
||||
|
import { |
||||
|
Controller, |
||||
|
Get, |
||||
|
HttpStatus, |
||||
|
Param, |
||||
|
Query, |
||||
|
Res, |
||||
|
UseInterceptors |
||||
|
} from '@nestjs/common'; |
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import { Response } from 'express'; |
||||
|
|
||||
|
import { LogoService } from './logo.service'; |
||||
|
|
||||
|
@Controller('logo') |
||||
|
export class LogoController { |
||||
|
public constructor(private readonly logoService: LogoService) {} |
||||
|
|
||||
|
@Get(':dataSource/:symbol') |
||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
||||
|
public async getLogoByDataSourceAndSymbol( |
||||
|
@Param('dataSource') dataSource: DataSource, |
||||
|
@Param('symbol') symbol: string, |
||||
|
@Res() response: Response |
||||
|
) { |
||||
|
try { |
||||
|
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({ |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}); |
||||
|
|
||||
|
response.contentType('image/png'); |
||||
|
response.send(buffer); |
||||
|
} catch { |
||||
|
response.status(HttpStatus.NOT_FOUND).send(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Get() |
||||
|
public async getLogoByUrl( |
||||
|
@Query('url') url: string, |
||||
|
@Res() response: Response |
||||
|
) { |
||||
|
try { |
||||
|
const buffer = await this.logoService.getLogoByUrl(url); |
||||
|
|
||||
|
response.contentType('image/png'); |
||||
|
response.send(buffer); |
||||
|
} catch { |
||||
|
response.status(HttpStatus.NOT_FOUND).send(); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,13 @@ |
|||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { LogoController } from './logo.controller'; |
||||
|
import { LogoService } from './logo.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [LogoController], |
||||
|
imports: [ConfigurationModule, SymbolProfileModule], |
||||
|
providers: [LogoService] |
||||
|
}) |
||||
|
export class LogoModule {} |
@ -0,0 +1,60 @@ |
|||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
||||
|
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; |
||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces'; |
||||
|
import { HttpException, Injectable } from '@nestjs/common'; |
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import got from 'got'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class LogoService { |
||||
|
public constructor( |
||||
|
private readonly symbolProfileService: SymbolProfileService |
||||
|
) {} |
||||
|
|
||||
|
public async getLogoByDataSourceAndSymbol({ |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}: UniqueAsset) { |
||||
|
if (!DataSource[dataSource]) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ |
||||
|
{ dataSource, symbol } |
||||
|
]); |
||||
|
|
||||
|
if (!assetProfile) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.getBuffer(assetProfile.url); |
||||
|
} |
||||
|
|
||||
|
public async getLogoByUrl(aUrl: string) { |
||||
|
return this.getBuffer(aUrl); |
||||
|
} |
||||
|
|
||||
|
private getBuffer(aUrl: string) { |
||||
|
const abortController = new AbortController(); |
||||
|
|
||||
|
setTimeout(() => { |
||||
|
abortController.abort(); |
||||
|
}, DEFAULT_REQUEST_TIMEOUT); |
||||
|
|
||||
|
return got( |
||||
|
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, |
||||
|
{ |
||||
|
headers: { 'User-Agent': 'request' }, |
||||
|
// @ts-ignore
|
||||
|
signal: abortController.signal |
||||
|
} |
||||
|
).buffer(); |
||||
|
} |
||||
|
} |
@ -0,0 +1,9 @@ |
|||||
|
import { IsString } from 'class-validator'; |
||||
|
|
||||
|
export class CreatePlatformDto { |
||||
|
@IsString() |
||||
|
name: string; |
||||
|
|
||||
|
@IsString() |
||||
|
url: string; |
||||
|
} |
@ -0,0 +1,115 @@ |
|||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
import { |
||||
|
Body, |
||||
|
Controller, |
||||
|
Delete, |
||||
|
Get, |
||||
|
HttpException, |
||||
|
Inject, |
||||
|
Param, |
||||
|
Post, |
||||
|
Put, |
||||
|
UseGuards |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
import { Platform } from '@prisma/client'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
import { CreatePlatformDto } from './create-platform.dto'; |
||||
|
import { PlatformService } from './platform.service'; |
||||
|
import { UpdatePlatformDto } from './update-platform.dto'; |
||||
|
|
||||
|
@Controller('platform') |
||||
|
export class PlatformController { |
||||
|
public constructor( |
||||
|
private readonly platformService: PlatformService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
@Get() |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async getPlatforms() { |
||||
|
return this.platformService.getPlatformsWithAccountCount(); |
||||
|
} |
||||
|
|
||||
|
@Post() |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async createPlatform( |
||||
|
@Body() data: CreatePlatformDto |
||||
|
): Promise<Platform> { |
||||
|
if ( |
||||
|
!hasPermission(this.request.user.permissions, permissions.createPlatform) |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.platformService.createPlatform(data); |
||||
|
} |
||||
|
|
||||
|
@Put(':id') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async updatePlatform( |
||||
|
@Param('id') id: string, |
||||
|
@Body() data: UpdatePlatformDto |
||||
|
) { |
||||
|
if ( |
||||
|
!hasPermission(this.request.user.permissions, permissions.updatePlatform) |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const originalPlatform = await this.platformService.getPlatform({ |
||||
|
id |
||||
|
}); |
||||
|
|
||||
|
if (!originalPlatform) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.platformService.updatePlatform({ |
||||
|
data: { |
||||
|
...data |
||||
|
}, |
||||
|
where: { |
||||
|
id |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Delete(':id') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async deletePlatform(@Param('id') id: string) { |
||||
|
if ( |
||||
|
!hasPermission(this.request.user.permissions, permissions.deletePlatform) |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const originalPlatform = await this.platformService.getPlatform({ |
||||
|
id |
||||
|
}); |
||||
|
|
||||
|
if (!originalPlatform) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.platformService.deletePlatform({ id }); |
||||
|
} |
||||
|
} |
@ -0,0 +1,13 @@ |
|||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { PlatformController } from './platform.controller'; |
||||
|
import { PlatformService } from './platform.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [PlatformController], |
||||
|
exports: [PlatformService], |
||||
|
imports: [PrismaModule], |
||||
|
providers: [PlatformService] |
||||
|
}) |
||||
|
export class PlatformModule {} |
@ -0,0 +1,83 @@ |
|||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
import { Platform, Prisma } from '@prisma/client'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class PlatformService { |
||||
|
public constructor(private readonly prismaService: PrismaService) {} |
||||
|
|
||||
|
public async createPlatform(data: Prisma.PlatformCreateInput) { |
||||
|
return this.prismaService.platform.create({ |
||||
|
data |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async deletePlatform( |
||||
|
where: Prisma.PlatformWhereUniqueInput |
||||
|
): Promise<Platform> { |
||||
|
return this.prismaService.platform.delete({ where }); |
||||
|
} |
||||
|
|
||||
|
public async getPlatform( |
||||
|
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput |
||||
|
): Promise<Platform> { |
||||
|
return this.prismaService.platform.findUnique({ |
||||
|
where: platformWhereUniqueInput |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getPlatforms({ |
||||
|
cursor, |
||||
|
orderBy, |
||||
|
skip, |
||||
|
take, |
||||
|
where |
||||
|
}: { |
||||
|
cursor?: Prisma.PlatformWhereUniqueInput; |
||||
|
orderBy?: Prisma.PlatformOrderByWithRelationInput; |
||||
|
skip?: number; |
||||
|
take?: number; |
||||
|
where?: Prisma.PlatformWhereInput; |
||||
|
} = {}) { |
||||
|
return this.prismaService.platform.findMany({ |
||||
|
cursor, |
||||
|
orderBy, |
||||
|
skip, |
||||
|
take, |
||||
|
where |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getPlatformsWithAccountCount() { |
||||
|
const platformsWithAccountCount = |
||||
|
await this.prismaService.platform.findMany({ |
||||
|
include: { |
||||
|
_count: { |
||||
|
select: { Account: true } |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return platformsWithAccountCount.map(({ _count, id, name, url }) => { |
||||
|
return { |
||||
|
id, |
||||
|
name, |
||||
|
url, |
||||
|
accountCount: _count.Account |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async updatePlatform({ |
||||
|
data, |
||||
|
where |
||||
|
}: { |
||||
|
data: Prisma.PlatformUpdateInput; |
||||
|
where: Prisma.PlatformWhereUniqueInput; |
||||
|
}): Promise<Platform> { |
||||
|
return this.prismaService.platform.update({ |
||||
|
data, |
||||
|
where |
||||
|
}); |
||||
|
} |
||||
|
} |
@ -0,0 +1,12 @@ |
|||||
|
import { IsString } from 'class-validator'; |
||||
|
|
||||
|
export class UpdatePlatformDto { |
||||
|
@IsString() |
||||
|
id: string; |
||||
|
|
||||
|
@IsString() |
||||
|
name: string; |
||||
|
|
||||
|
@IsString() |
||||
|
url: string; |
||||
|
} |
@ -0,0 +1,9 @@ |
|||||
|
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { GetValueObject } from './get-value-object.interface'; |
||||
|
|
||||
|
export interface GetValuesObject { |
||||
|
dataProviderInfos: DataProviderInfo[]; |
||||
|
errors: ResponseError['errors']; |
||||
|
values: GetValueObject[]; |
||||
|
} |
@ -0,0 +1,132 @@ |
|||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import Big from 'big.js'; |
||||
|
|
||||
|
import { CurrentRateServiceMock } from './current-rate.service.mock'; |
||||
|
import { PortfolioCalculator } from './portfolio-calculator'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
|
return CurrentRateServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let currentRateService: CurrentRateService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
currentRateService = new CurrentRateService(null, null, null); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with NOVN.SW buy and sell', async () => { |
||||
|
const portfolioCalculator = new PortfolioCalculator({ |
||||
|
currentRateService, |
||||
|
currency: 'CHF', |
||||
|
orders: [ |
||||
|
{ |
||||
|
currency: 'CHF', |
||||
|
date: '2022-03-07', |
||||
|
dataSource: 'YAHOO', |
||||
|
fee: new Big(0), |
||||
|
name: 'Novartis AG', |
||||
|
quantity: new Big(2), |
||||
|
symbol: 'NOVN.SW', |
||||
|
type: 'BUY', |
||||
|
unitPrice: new Big(75.8) |
||||
|
}, |
||||
|
{ |
||||
|
currency: 'CHF', |
||||
|
date: '2022-04-08', |
||||
|
dataSource: 'YAHOO', |
||||
|
fee: new Big(0), |
||||
|
name: 'Novartis AG', |
||||
|
quantity: new Big(2), |
||||
|
symbol: 'NOVN.SW', |
||||
|
type: 'SELL', |
||||
|
unitPrice: new Big(85.73) |
||||
|
} |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
portfolioCalculator.computeTransactionPoints(); |
||||
|
|
||||
|
const spy = jest |
||||
|
.spyOn(Date, 'now') |
||||
|
.mockImplementation(() => parseDate('2022-04-11').getTime()); |
||||
|
|
||||
|
const chartData = await portfolioCalculator.getChartData( |
||||
|
parseDate('2022-03-07') |
||||
|
); |
||||
|
|
||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions( |
||||
|
parseDate('2022-03-07') |
||||
|
); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = |
||||
|
portfolioCalculator.getInvestmentsByGroup('month'); |
||||
|
|
||||
|
spy.mockRestore(); |
||||
|
|
||||
|
expect(chartData[0]).toEqual({ |
||||
|
date: '2022-03-07', |
||||
|
netPerformanceInPercentage: 0, |
||||
|
netPerformance: 0, |
||||
|
totalInvestment: 151.6, |
||||
|
value: 151.6 |
||||
|
}); |
||||
|
|
||||
|
expect(chartData[chartData.length - 1]).toEqual({ |
||||
|
date: '2022-04-11', |
||||
|
netPerformanceInPercentage: 13.100263852242744, |
||||
|
netPerformance: 19.86, |
||||
|
totalInvestment: 0, |
||||
|
value: 0 |
||||
|
}); |
||||
|
|
||||
|
expect(currentPositions).toEqual({ |
||||
|
currentValue: new Big('0'), |
||||
|
errors: [], |
||||
|
grossPerformance: new Big('19.86'), |
||||
|
grossPerformancePercentage: new Big('0.13100263852242744063'), |
||||
|
hasErrors: false, |
||||
|
netPerformance: new Big('19.86'), |
||||
|
netPerformancePercentage: new Big('0.13100263852242744063'), |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('0'), |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
fee: new Big('0'), |
||||
|
firstBuyDate: '2022-03-07', |
||||
|
grossPerformance: new Big('19.86'), |
||||
|
grossPerformancePercentage: new Big('0.13100263852242744063'), |
||||
|
investment: new Big('0'), |
||||
|
netPerformance: new Big('19.86'), |
||||
|
netPerformancePercentage: new Big('0.13100263852242744063'), |
||||
|
marketPrice: 87.8, |
||||
|
quantity: new Big('0'), |
||||
|
symbol: 'NOVN.SW', |
||||
|
transactionCount: 2 |
||||
|
} |
||||
|
], |
||||
|
totalInvestment: new Big('0') |
||||
|
}); |
||||
|
|
||||
|
expect(investments).toEqual([ |
||||
|
{ date: '2022-03-07', investment: new Big('151.6') }, |
||||
|
{ date: '2022-04-08', investment: new Big('0') } |
||||
|
]); |
||||
|
|
||||
|
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2022-03-01', investment: new Big('151.6') }, |
||||
|
{ date: '2022-04-01', investment: new Big('-171.46') } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
File diff suppressed because it is too large
@ -0,0 +1,7 @@ |
|||||
|
import { Cache } from 'cache-manager'; |
||||
|
|
||||
|
import type { RedisStore } from './redis-store.interface'; |
||||
|
|
||||
|
export interface RedisCache extends Cache { |
||||
|
store: RedisStore; |
||||
|
} |
@ -0,0 +1,8 @@ |
|||||
|
import { Store } from 'cache-manager'; |
||||
|
import { createClient } from 'redis'; |
||||
|
|
||||
|
export interface RedisStore extends Store { |
||||
|
getClient: () => ReturnType<typeof createClient>; |
||||
|
isCacheableValue: (value: any) => boolean; |
||||
|
name: 'redis'; |
||||
|
} |
@ -0,0 +1,36 @@ |
|||||
|
import * as fs from 'fs'; |
||||
|
import * as path from 'path'; |
||||
|
|
||||
|
import { |
||||
|
DATE_FORMAT, |
||||
|
getYesterday, |
||||
|
interpolate |
||||
|
} from '@ghostfolio/common/helper'; |
||||
|
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common'; |
||||
|
import { format } from 'date-fns'; |
||||
|
import { Response } from 'express'; |
||||
|
|
||||
|
@Controller('sitemap.xml') |
||||
|
export class SitemapController { |
||||
|
public sitemapXml = ''; |
||||
|
|
||||
|
public constructor() { |
||||
|
try { |
||||
|
this.sitemapXml = fs.readFileSync( |
||||
|
path.join(__dirname, 'assets', 'sitemap.xml'), |
||||
|
'utf8' |
||||
|
); |
||||
|
} catch {} |
||||
|
} |
||||
|
|
||||
|
@Get() |
||||
|
@Version(VERSION_NEUTRAL) |
||||
|
public async flushCache(@Res() response: Response): Promise<void> { |
||||
|
response.setHeader('content-type', 'application/xml'); |
||||
|
response.send( |
||||
|
interpolate(this.sitemapXml, { |
||||
|
currentDate: format(getYesterday(), DATE_FORMAT) |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
} |
@ -0,0 +1,24 @@ |
|||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; |
||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; |
||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; |
||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { SitemapController } from './sitemap.controller'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [SitemapController], |
||||
|
imports: [ |
||||
|
ConfigurationModule, |
||||
|
DataGatheringModule, |
||||
|
DataProviderModule, |
||||
|
ExchangeRateDataModule, |
||||
|
PrismaModule, |
||||
|
RedisCacheModule, |
||||
|
SymbolProfileModule |
||||
|
] |
||||
|
}) |
||||
|
export class SitemapModule {} |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue