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 { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.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 { 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 { 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 { 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() |
|||
export class ImportService { |
|||
public constructor( |
|||
private readonly accountService: AccountService, |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly dataGatheringService: DataGatheringService, |
|||
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({ |
|||
activities, |
|||
accountsDto, |
|||
activitiesDto, |
|||
isDryRun = false, |
|||
maxActivitiesToImport, |
|||
userCurrency, |
|||
userId |
|||
}: { |
|||
activities: Partial<CreateOrderDto>[]; |
|||
accountsDto: Partial<CreateAccountDto>[]; |
|||
activitiesDto: Partial<CreateOrderDto>[]; |
|||
isDryRun?: boolean; |
|||
maxActivitiesToImport: number; |
|||
userCurrency: string; |
|||
userId: string; |
|||
}): Promise<void> { |
|||
for (const activity of activities) { |
|||
}): Promise<Activity[]> { |
|||
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.type === 'ITEM') { |
|||
activity.dataSource = 'MANUAL'; |
|||
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') { |
|||
activity.dataSource = DataSource.MANUAL; |
|||
} 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({ |
|||
activities, |
|||
maxActivitiesToImport, |
|||
const assetProfiles = await this.validateActivities({ |
|||
activitiesDto, |
|||
maxActivitiesToImport |
|||
}); |
|||
|
|||
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ |
|||
activitiesDto, |
|||
userId |
|||
}); |
|||
|
|||
const accountIds = (await this.accountService.getAccounts(userId)).map( |
|||
(account) => { |
|||
return account.id; |
|||
const accounts = (await this.accountService.getAccounts(userId)).map( |
|||
({ id, name }) => { |
|||
return { id, name }; |
|||
} |
|||
); |
|||
|
|||
for (const { |
|||
accountId, |
|||
comment, |
|||
currency, |
|||
dataSource, |
|||
date, |
|||
fee, |
|||
quantity, |
|||
symbol, |
|||
type, |
|||
unitPrice |
|||
} of activities) { |
|||
await this.orderService.createOrder({ |
|||
if (isDryRun) { |
|||
accountsDto.forEach(({ id, name }) => { |
|||
accounts.push({ id, name }); |
|||
}); |
|||
} |
|||
|
|||
const activities: Activity[] = []; |
|||
|
|||
for (let [ |
|||
index, |
|||
{ |
|||
accountId, |
|||
comment, |
|||
date, |
|||
error, |
|||
fee, |
|||
quantity, |
|||
SymbolProfile, |
|||
type, |
|||
unitPrice, |
|||
userId, |
|||
accountId: accountIds.includes(accountId) ? accountId : undefined, |
|||
date: parseISO(<string>(<unknown>date)), |
|||
SymbolProfile: { |
|||
connectOrCreate: { |
|||
create: { |
|||
currency, |
|||
dataSource, |
|||
symbol |
|||
}, |
|||
where: { |
|||
dataSource_symbol: { |
|||
unitPrice |
|||
} |
|||
] of activitiesExtendedWithErrors.entries()) { |
|||
const assetProfile = assetProfiles[ |
|||
getAssetProfileIdentifier({ |
|||
dataSource: SymbolProfile.dataSource, |
|||
symbol: SymbolProfile.symbol |
|||
}) |
|||
] ?? { |
|||
currency: SymbolProfile.currency, |
|||
dataSource: SymbolProfile.dataSource, |
|||
symbol: SymbolProfile.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, |
|||
symbol |
|||
}, |
|||
where: { |
|||
dataSource_symbol: { |
|||
dataSource, |
|||
symbol |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
User: { connect: { id: userId } } |
|||
}, |
|||
updateAccountBalance: false, |
|||
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({ |
|||
activities, |
|||
maxActivitiesToImport, |
|||
private async extendActivitiesWithErrors({ |
|||
activitiesDto, |
|||
userId |
|||
}: { |
|||
activities: Partial<CreateOrderDto>[]; |
|||
maxActivitiesToImport: number; |
|||
activitiesDto: Partial<CreateOrderDto>[]; |
|||
userId: string; |
|||
}) { |
|||
if (activities?.length > maxActivitiesToImport) { |
|||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); |
|||
} |
|||
|
|||
}): Promise<Partial<Activity>[]> { |
|||
const existingActivities = await this.orderService.orders({ |
|||
include: { SymbolProfile: true }, |
|||
orderBy: { date: 'desc' }, |
|||
where: { userId } |
|||
}); |
|||
|
|||
for (const [ |
|||
index, |
|||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice } |
|||
] of activities.entries()) { |
|||
const duplicateActivity = existingActivities.find((activity) => { |
|||
return ( |
|||
activity.SymbolProfile.currency === currency && |
|||
activity.SymbolProfile.dataSource === dataSource && |
|||
isSameDay(activity.date, parseISO(<string>(<unknown>date))) && |
|||
activity.fee === fee && |
|||
activity.quantity === quantity && |
|||
activity.SymbolProfile.symbol === symbol && |
|||
activity.type === type && |
|||
activity.unitPrice === unitPrice |
|||
); |
|||
}); |
|||
return activitiesDto.map( |
|||
({ |
|||
accountId, |
|||
comment, |
|||
currency, |
|||
dataSource, |
|||
date: dateString, |
|||
fee, |
|||
quantity, |
|||
symbol, |
|||
type, |
|||
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) { |
|||
throw new Error(`activities.${index} is a duplicate activity`); |
|||
return { |
|||
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') { |
|||
const quotes = await this.dataProviderService.getQuotes([ |
|||
{ dataSource, symbol } |
|||
]); |
|||
const assetProfile = ( |
|||
await this.dataProviderService.getAssetProfiles([ |
|||
{ dataSource, symbol } |
|||
]) |
|||
)?.[symbol]; |
|||
|
|||
if (quotes[symbol] === undefined) { |
|||
if (!assetProfile?.name) { |
|||
throw new Error( |
|||
`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( |
|||
`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