Compare commits
73 Commits
Author | SHA1 | Date | |
---|---|---|---|
38474f54b0 | |||
18d25fb6c2 | |||
a850e8ca22 | |||
b5f565c054 | |||
aa6d0a4533 | |||
25e9028a41 | |||
925d38703e | |||
158bb00b8a | |||
b17111e6f1 | |||
c4765e31cd | |||
d321d56dee | |||
07dd22f7fe | |||
eb4d088a80 | |||
0509f0101f | |||
8818e09be8 | |||
d97fe4da9c | |||
b20fa55b79 | |||
dd7a6f1562 | |||
15357bd5b5 | |||
52c7adc266 | |||
1ae8970045 | |||
7c4c047140 | |||
527f7e4faf | |||
50160eb9dc | |||
58dff8a1e0 | |||
2cd41615b2 | |||
66d5793528 | |||
e8d65e1c85 | |||
da827a08f5 | |||
d545e4877c | |||
1918dee9c5 | |||
a08610b603 | |||
c22733db56 | |||
ee4866eb7d | |||
327b1fa0d7 | |||
b155666d21 | |||
c5ee3237ed | |||
16118d635c | |||
49ce4803ce | |||
0b65d05013 | |||
8793284e75 | |||
1c5e4050a8 | |||
4f187e1a9f | |||
b56111ae85 | |||
61dfc1f819 | |||
6137f228a8 | |||
5293de14cd | |||
7340a674b5 | |||
42cb3e2c73 | |||
e8a4a53c9f | |||
629f002074 | |||
7c65cf6ddd | |||
c38ebec3be | |||
2b8ab26e7e | |||
60f52bb209 | |||
616d168a7c | |||
b13e4425d3 | |||
1424236c48 | |||
2a605f850d | |||
88ffbfead0 | |||
5f4a8d505b | |||
e87b93f19c | |||
49dcade964 | |||
7cd65eed39 | |||
a51b210f79 | |||
285f2220f3 | |||
d72123246d | |||
3a78d6c3f1 | |||
d5e3ff5717 | |||
2efb331370 | |||
f521fe99c5 | |||
42306530b8 | |||
68c9d1b266 |
134
CHANGELOG.md
134
CHANGELOG.md
@ -5,6 +5,140 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.225.0 - 2023-01-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for importing dividends from a data provider
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the Frequently Asked Questions (FAQ) page
|
||||
|
||||
## 1.224.0 - 2023-01-04
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for the dividend timeline grouped by year
|
||||
- Added support for the investment timeline grouped by year
|
||||
- Set up the language localization for Français (`fr`)
|
||||
- Set up the language localization for Português (`pt`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for Dutch (`nl`)
|
||||
|
||||
## 1.223.0 - 2023-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added a student discount to the pricing page
|
||||
- Added a prefix to the codes of the coupon system
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized the page titles in the header for mobile
|
||||
- Extended the asset profile details dialog in the admin control panel
|
||||
|
||||
## 1.222.0 - 2022-12-29
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for filtering on the analysis page
|
||||
- Added the price to the `Subscription` database schema
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the execution time of the asset profile data gathering to every Sunday at lunch time
|
||||
- Improved the activities import by providing asset profile details
|
||||
- Upgraded `@codewithdan/observable-store` from version `2.2.11` to `2.2.15`
|
||||
- Upgraded `bull` from version `4.8.5` to `4.10.2`
|
||||
- Upgraded `countup.js` from version `2.0.7` to `2.3.2`
|
||||
- Upgraded the _Internet Identity_ dependencies from version `0.12.1` to `0.15.1`
|
||||
- Upgraded `prisma` from version `4.7.1` to `4.8.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the language localization of the account type
|
||||
|
||||
## 1.221.0 - 2022-12-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to manage the tags in the create or edit activity dialog
|
||||
- Added the tags to the admin control panel
|
||||
- Added a blog post: _The importance of tracking your personal finances_
|
||||
- Resolved the title of the blog post
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the activities import by a preview step
|
||||
- Improved the labels based on the type in the create or edit activity dialog
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Removed the data source type `RAKUTEN`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the date conversion for years with only two digits
|
||||
|
||||
## 1.220.0 - 2022-12-23
|
||||
|
||||
### Added
|
||||
|
||||
- Added the position detail dialog to the _Top 3_ and _Bottom 3_ performers of the analysis page
|
||||
- Added the `dryRun` option to the import activities endpoint
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 365 days
|
||||
- Upgraded `color` from version `4.0.1` to `4.2.3`
|
||||
- Upgraded `prettier` from version `2.7.1` to `2.8.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the rounding of the y-axis ticks in the benchmark comparator
|
||||
|
||||
## 1.219.0 - 2022-12-17
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to disable user sign up in the admin control panel
|
||||
- Extended the glossary of the resources page by _Deflation_, _Inflation_ and _Stagflation_
|
||||
|
||||
### Changed
|
||||
|
||||
- Added the name to the symbol column in the activities table
|
||||
- Combined the name and symbol column in the holdings table (former positions table)
|
||||
|
||||
## 1.218.0 - 2022-12-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added the date of the first activity to the positions table
|
||||
- Added an endpoint to fetch the logo of an asset or a platform
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the asset profile details dialog in the admin control panel
|
||||
- Upgraded `chart.js` from version `3.8.0` to `4.0.1`
|
||||
|
||||
## 1.217.0 - 2022-12-10
|
||||
|
||||
### Added
|
||||
|
||||
- Added the dividend timeline grouped by month
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the value redaction interceptor (including `comment`)
|
||||
- Improved the language localization for Español (`es`)
|
||||
- Upgraded `cheerio` from version `1.0.0-rc.6` to `1.0.0-rc.12`
|
||||
- Upgraded `prisma` from version `4.6.1` to `4.7.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the activities sorting in the account detail dialog
|
||||
|
||||
## 1.216.0 - 2022-12-03
|
||||
|
||||
### Added
|
||||
|
10
README.md
10
README.md
@ -15,14 +15,16 @@
|
||||
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/en/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/en/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/en/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://www.buymeacoffee.com/ghostfolio">
|
||||
<img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee"/></a>
|
||||
<a href="#contributing">
|
||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
|
||||
<img src="https://img.shields.io/badge/Contributions-Welcome-orange.svg"/></a>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||
|
||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
||||
@ -46,7 +48,7 @@ Ghostfolio is for you if you are...
|
||||
- 🧘 into minimalism
|
||||
- 🧺 caring about diversifying your financial resources
|
||||
- 🆓 interested in financial independence
|
||||
- 🙅 saying no to spreadsheets in 2022
|
||||
- 🙅 saying no to spreadsheets in 2023
|
||||
- 😎 still reading this list
|
||||
|
||||
## Features
|
||||
@ -280,6 +282,6 @@ If you like to support this project, get **[Ghostfolio Premium](https://ghostfol
|
||||
|
||||
## License
|
||||
|
||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
||||
© 2023 [Ghostfolio](https://ghostfol.io)
|
||||
|
||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
@ -27,6 +27,7 @@ import { ExportModule } from './export/export.module';
|
||||
import { FrontendMiddleware } from './frontend.middleware';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { InfoModule } from './info/info.module';
|
||||
import { LogoModule } from './logo/logo.module';
|
||||
import { OrderModule } from './order/order.module';
|
||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
@ -58,6 +59,7 @@ import { UserModule } from './user/user.module';
|
||||
ExportModule,
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
LogoModule,
|
||||
OrderModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
Version
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Request, Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
@ -58,18 +59,21 @@ export class AuthController {
|
||||
@Get('google/callback')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public googleLoginCallback(@Req() req, @Res() res) {
|
||||
public googleLoginCallback(
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
) {
|
||||
// Handles the Google OAuth2 callback
|
||||
const jwt: string = req.user.jwt;
|
||||
const jwt: string = (<any>request.user).jwt;
|
||||
|
||||
if (jwt) {
|
||||
res.redirect(
|
||||
response.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
||||
);
|
||||
} else {
|
||||
res.redirect(
|
||||
response.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
||||
|
@ -4,6 +4,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@ -21,6 +22,7 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
}),
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
SubscriptionModule,
|
||||
UserModule
|
||||
],
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Provider } from '@prisma/client';
|
||||
@ -11,6 +12,7 @@ export class AuthService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@ -50,6 +52,13 @@ export class AuthService {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (!isUserSignupEnabled) {
|
||||
throw new Error('Sign up forbidden');
|
||||
}
|
||||
|
||||
// Create new user if not found
|
||||
user = await this.userService.createUser({
|
||||
provider,
|
||||
@ -78,6 +87,13 @@ export class AuthService {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (!isUserSignupEnabled) {
|
||||
throw new Error('Sign up forbidden');
|
||||
}
|
||||
|
||||
// Create new user if not found
|
||||
user = await this.userService.createUser({
|
||||
provider,
|
||||
|
@ -30,8 +30,8 @@ export class BenchmarkController {
|
||||
}
|
||||
|
||||
@Get(':dataSource/:symbol/:startDateString')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getBenchmarkMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('startDateString') startDateString: string,
|
||||
|
@ -3,8 +3,10 @@ import * as path from 'path';
|
||||
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { format } from 'date-fns';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
@ -12,8 +14,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
public indexHtmlDe = '';
|
||||
public indexHtmlEn = '';
|
||||
public indexHtmlEs = '';
|
||||
public indexHtmlFr = '';
|
||||
public indexHtmlIt = '';
|
||||
public indexHtmlNl = '';
|
||||
public indexHtmlPt = '';
|
||||
public isProduction: boolean;
|
||||
|
||||
public constructor(
|
||||
@ -39,6 +43,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
this.getPathOfIndexHtmlFile('es'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlFr = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('fr'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlIt = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('it'),
|
||||
'utf8'
|
||||
@ -47,69 +55,113 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
this.getPathOfIndexHtmlFile('nl'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlPt = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('pt'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
public use(req: Request, res: Response, next: NextFunction) {
|
||||
public use(request: Request, response: Response, next: NextFunction) {
|
||||
const currentDate = format(new Date(), DATE_FORMAT);
|
||||
let featureGraphicPath = 'assets/cover.png';
|
||||
let title = 'Ghostfolio – Open Source Wealth Management Software';
|
||||
|
||||
if (req.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
|
||||
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
|
||||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||
} else if (req.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
|
||||
title = `500 Stars - ${title}`;
|
||||
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
|
||||
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
||||
} else if (req.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
|
||||
title = `Hacktoberfest 2022 - ${title}`;
|
||||
} else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
|
||||
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
|
||||
title = `Black Friday 2022 - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith(
|
||||
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances'
|
||||
)
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/20221226.jpg';
|
||||
title = `The importance of tracking your personal finances - ${title}`;
|
||||
}
|
||||
|
||||
if (
|
||||
req.path.startsWith('/api/') ||
|
||||
this.isFileRequest(req.url) ||
|
||||
request.path.startsWith('/api/') ||
|
||||
this.isFileRequest(request.url) ||
|
||||
!this.isProduction
|
||||
) {
|
||||
// Skip
|
||||
next();
|
||||
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
|
||||
res.send(
|
||||
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlDe, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'de',
|
||||
path: req.path,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/es' || req.path.startsWith('/es/')) {
|
||||
res.send(
|
||||
} else if (request.path === '/es' || request.path.startsWith('/es/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlEs, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'es',
|
||||
path: req.path,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/it' || req.path.startsWith('/it/')) {
|
||||
res.send(
|
||||
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlFr, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'fr',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlIt, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'it',
|
||||
path: req.path,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/nl' || req.path.startsWith('/nl/')) {
|
||||
res.send(
|
||||
} else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlNl, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: 'nl',
|
||||
path: req.path,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlPt, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'pt',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else {
|
||||
res.send(
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlEn, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||
path: req.path,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
|
@ -1,17 +1,26 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Logger,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import { ImportDataDto } from './import-data.dto';
|
||||
import { ImportService } from './import.service';
|
||||
@ -26,7 +35,10 @@ export class ImportController {
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async import(@Body() importData: ImportDataDto): Promise<void> {
|
||||
public async import(
|
||||
@Body() importData: ImportDataDto,
|
||||
@Query('dryRun') isDryRun?: boolean
|
||||
): Promise<ImportResponse> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -45,12 +57,18 @@ export class ImportController {
|
||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
try {
|
||||
return await this.importService.import({
|
||||
const activities = await this.importService.import({
|
||||
maxActivitiesToImport,
|
||||
activities: importData.activities,
|
||||
isDryRun,
|
||||
userCurrency,
|
||||
activitiesDto: importData.activities,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return { activities };
|
||||
} catch (error) {
|
||||
Logger.error(error, ImportController);
|
||||
|
||||
@ -63,4 +81,23 @@ export class ImportController {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('dividends/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async gatherDividends(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<ImportResponse> {
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
const activities = await this.importService.getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
userCurrency
|
||||
});
|
||||
|
||||
return { activities };
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,14 @@
|
||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ImportController } from './import.controller';
|
||||
@ -19,9 +22,12 @@ import { ImportService } from './import.service';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
OrderModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
providers: [ImportService]
|
||||
})
|
||||
|
@ -1,30 +1,118 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AccountWithPlatform,
|
||||
OrderWithAccount
|
||||
} from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isSameDay, parseISO } from 'date-fns';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly orderService: OrderService
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
userCurrency
|
||||
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
|
||||
try {
|
||||
const { firstBuyDate, historicalData, orders } =
|
||||
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||
|
||||
const [[assetProfile], dividends] = await Promise.all([
|
||||
this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
]),
|
||||
await this.dataProviderService.getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
from: parseDate(firstBuyDate),
|
||||
granularity: 'day',
|
||||
to: new Date()
|
||||
})
|
||||
]);
|
||||
|
||||
const accounts = orders.map((order) => {
|
||||
return order.Account;
|
||||
});
|
||||
|
||||
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||
|
||||
return Object.entries(dividends).map(([dateString, { marketPrice }]) => {
|
||||
const quantity =
|
||||
historicalData.find((historicalDataItem) => {
|
||||
return historicalDataItem.date === dateString;
|
||||
})?.quantity ?? 0;
|
||||
|
||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||
|
||||
return {
|
||||
Account,
|
||||
quantity,
|
||||
value,
|
||||
accountId: Account?.id,
|
||||
accountUserId: undefined,
|
||||
comment: undefined,
|
||||
createdAt: undefined,
|
||||
date: parseDate(dateString),
|
||||
fee: 0,
|
||||
feeInBaseCurrency: 0,
|
||||
id: assetProfile.id,
|
||||
isDraft: false,
|
||||
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
||||
symbolProfileId: assetProfile.id,
|
||||
type: 'DIVIDEND',
|
||||
unitPrice: marketPrice,
|
||||
updatedAt: undefined,
|
||||
userId: Account?.userId,
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
assetProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async import({
|
||||
activities,
|
||||
activitiesDto,
|
||||
isDryRun = false,
|
||||
maxActivitiesToImport,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
isDryRun?: boolean;
|
||||
maxActivitiesToImport: number;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
for (const activity of activities) {
|
||||
}): Promise<Activity[]> {
|
||||
for (const activity of activitiesDto) {
|
||||
if (!activity.dataSource) {
|
||||
if (activity.type === 'ITEM') {
|
||||
activity.dataSource = 'MANUAL';
|
||||
@ -34,8 +122,8 @@ export class ImportService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.validateActivities({
|
||||
activities,
|
||||
const assetProfiles = await this.validateActivities({
|
||||
activitiesDto,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
});
|
||||
@ -46,60 +134,138 @@ export class ImportService {
|
||||
}
|
||||
);
|
||||
|
||||
const activities: Activity[] = [];
|
||||
|
||||
for (const {
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
date: dateString,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
} of activities) {
|
||||
await this.orderService.createOrder({
|
||||
comment,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: accountIds.includes(accountId) ? accountId : undefined,
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
} of activitiesDto) {
|
||||
const date = parseISO(<string>(<unknown>dateString));
|
||||
const validatedAccountId = accountIds.includes(accountId)
|
||||
? accountId
|
||||
: undefined;
|
||||
|
||||
let order: OrderWithAccount;
|
||||
|
||||
if (isDryRun) {
|
||||
order = {
|
||||
comment,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccountId,
|
||||
accountUserId: undefined,
|
||||
createdAt: new Date(),
|
||||
id: uuidv4(),
|
||||
isDraft: isAfter(date, endOfToday()),
|
||||
SymbolProfile: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol,
|
||||
assetClass: null,
|
||||
assetSubClass: null,
|
||||
comment: null,
|
||||
countries: null,
|
||||
createdAt: undefined,
|
||||
id: undefined,
|
||||
name: null,
|
||||
scraperConfiguration: null,
|
||||
sectors: null,
|
||||
symbolMapping: null,
|
||||
updatedAt: undefined,
|
||||
url: null,
|
||||
...assetProfiles[symbol]
|
||||
},
|
||||
symbolProfileId: undefined,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
} else {
|
||||
order = await this.orderService.createOrder({
|
||||
comment,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccountId,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
});
|
||||
}
|
||||
|
||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||
|
||||
activities.push({
|
||||
...order,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
fee,
|
||||
currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
currency,
|
||||
userCurrency
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return activities;
|
||||
}
|
||||
|
||||
private isUniqueAccount(accounts: AccountWithPlatform[]) {
|
||||
const uniqueAccountIds = new Set<string>();
|
||||
|
||||
for (const account of accounts) {
|
||||
uniqueAccountIds.add(account.id);
|
||||
}
|
||||
|
||||
return uniqueAccountIds.size === 1;
|
||||
}
|
||||
|
||||
private async validateActivities({
|
||||
activities,
|
||||
activitiesDto,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
}: {
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
maxActivitiesToImport: number;
|
||||
userId: string;
|
||||
}) {
|
||||
if (activities?.length > maxActivitiesToImport) {
|
||||
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||
}
|
||||
|
||||
const assetProfiles: {
|
||||
[symbol: string]: Partial<SymbolProfile>;
|
||||
} = {};
|
||||
const existingActivities = await this.orderService.orders({
|
||||
include: { SymbolProfile: true },
|
||||
orderBy: { date: 'desc' },
|
||||
@ -109,7 +275,7 @@ export class ImportService {
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||
] of activities.entries()) {
|
||||
] of activitiesDto.entries()) {
|
||||
const duplicateActivity = existingActivities.find((activity) => {
|
||||
return (
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
@ -128,22 +294,28 @@ export class ImportService {
|
||||
}
|
||||
|
||||
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 === undefined) {
|
||||
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) {
|
||||
throw new Error(
|
||||
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
|
||||
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"`
|
||||
);
|
||||
}
|
||||
|
||||
assetProfiles[symbol] = assetProfile;
|
||||
}
|
||||
}
|
||||
|
||||
return assetProfiles;
|
||||
}
|
||||
}
|
||||
|
@ -103,6 +103,13 @@ export class InfoService {
|
||||
)) as string;
|
||||
}
|
||||
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (isUserSignupEnabled) {
|
||||
globalPermissions.push(permissions.createUserAccount);
|
||||
}
|
||||
|
||||
return {
|
||||
...info,
|
||||
globalPermissions,
|
||||
@ -295,14 +302,14 @@ export class InfoService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stripeConfig = await this.prismaService.property.findUnique({
|
||||
let subscriptions: Subscription[] = [];
|
||||
|
||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||
});
|
||||
})) ?? { value: '{}' };
|
||||
|
||||
if (stripeConfig) {
|
||||
return [JSON.parse(stripeConfig.value)];
|
||||
}
|
||||
subscriptions = [JSON.parse(stripeConfig.value)];
|
||||
|
||||
return [];
|
||||
return subscriptions;
|
||||
}
|
||||
}
|
||||
|
54
apps/api/src/app/logo/logo.controller.ts
Normal file
54
apps/api/src/app/logo/logo.controller.ts
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
13
apps/api/src/app/logo/logo.module.ts
Normal file
13
apps/api/src/app/logo/logo.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/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 {}
|
55
apps/api/src/app/logo/logo.service.ts
Normal file
55
apps/api/src/app/logo/logo.service.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
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 get = bent(
|
||||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||
'GET',
|
||||
'buffer',
|
||||
200,
|
||||
{
|
||||
'User-Agent': 'request'
|
||||
}
|
||||
);
|
||||
return get();
|
||||
}
|
||||
}
|
@ -6,5 +6,6 @@ export interface Activities {
|
||||
|
||||
export interface Activity extends OrderWithAccount {
|
||||
feeInBaseCurrency: number;
|
||||
value: number;
|
||||
valueInBaseCurrency: number;
|
||||
}
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
@ -39,8 +37,7 @@ export class OrderController {
|
||||
private readonly apiService: ApiService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly orderService: OrderService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@ -87,7 +84,7 @@ export class OrderController {
|
||||
);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
let activities = await this.orderService.getOrders({
|
||||
const activities = await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
@ -95,20 +92,6 @@ export class OrderController {
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
activities = nullifyValuesInObjects(activities, [
|
||||
'fee',
|
||||
'feeInBaseCurrency',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
]);
|
||||
}
|
||||
|
||||
return { activities };
|
||||
}
|
||||
|
||||
|
@ -362,6 +362,12 @@ export class OrderService {
|
||||
delete data.symbol;
|
||||
delete data.tags;
|
||||
|
||||
// Remove existing tags
|
||||
await this.prismaService.order.update({
|
||||
data: { tags: { set: [] } },
|
||||
where
|
||||
});
|
||||
|
||||
return this.prismaService.order.update({
|
||||
data: {
|
||||
...data,
|
||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
|
@ -53,7 +53,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
|
@ -41,7 +41,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
|
@ -68,7 +68,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
|
@ -2,6 +2,7 @@ import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { GroupBy } from '@ghostfolio/common/types';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
@ -478,46 +479,60 @@ export class PortfolioCalculator {
|
||||
});
|
||||
}
|
||||
|
||||
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
|
||||
public getInvestmentsByGroup(
|
||||
groupBy: GroupBy
|
||||
): { date: string; investment: Big }[] {
|
||||
if (this.orders.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const investments = [];
|
||||
let currentDate: Date;
|
||||
let investmentByMonth = new Big(0);
|
||||
let investmentByGroup = new Big(0);
|
||||
|
||||
for (const [index, order] of this.orders.entries()) {
|
||||
if (
|
||||
isSameMonth(parseDate(order.date), currentDate) &&
|
||||
isSameYear(parseDate(order.date), currentDate)
|
||||
isSameYear(parseDate(order.date), currentDate) &&
|
||||
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
||||
) {
|
||||
// Same month: Add up investments
|
||||
// Same group: Add up investments
|
||||
|
||||
investmentByMonth = investmentByMonth.plus(
|
||||
investmentByGroup = investmentByGroup.plus(
|
||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||
);
|
||||
} else {
|
||||
// New month: Store previous month and reset
|
||||
// New group: Store previous group and reset
|
||||
|
||||
if (currentDate) {
|
||||
investments.push({
|
||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||
investment: investmentByMonth
|
||||
date: format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
),
|
||||
investment: investmentByGroup
|
||||
});
|
||||
}
|
||||
|
||||
currentDate = parseDate(order.date);
|
||||
investmentByMonth = order.quantity
|
||||
investmentByGroup = order.quantity
|
||||
.mul(order.unitPrice)
|
||||
.mul(this.getFactor(order.type));
|
||||
}
|
||||
|
||||
if (index === this.orders.length - 1) {
|
||||
// Store current month (latest order)
|
||||
// Store current group (latest order)
|
||||
investments.push({
|
||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||
investment: investmentByMonth
|
||||
date: format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
),
|
||||
investment: investmentByGroup
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -12,12 +12,12 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioDividends,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import type {
|
||||
DateRange,
|
||||
GroupBy,
|
||||
@ -185,27 +185,78 @@ export class PortfolioController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get('dividends')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getDividends(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioDividends> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
let dividends = await this.portfolioService.getDividends({
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const maxDividend = dividends.reduce(
|
||||
(investment, item) => Math.max(investment, item.investment),
|
||||
1
|
||||
);
|
||||
|
||||
dividends = dividends.map((item) => ({
|
||||
date: item.date,
|
||||
investment: item.investment / maxDividend
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
dividends = dividends.map((item) => {
|
||||
return nullifyValuesInObject(item, ['investment']);
|
||||
});
|
||||
}
|
||||
|
||||
return { dividends };
|
||||
}
|
||||
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getInvestments(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('groupBy') groupBy?: GroupBy
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioInvestments> {
|
||||
let investments: InvestmentItem[];
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
if (groupBy === 'month') {
|
||||
investments = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
groupBy: 'month'
|
||||
});
|
||||
} else {
|
||||
investments = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
impersonationId
|
||||
});
|
||||
}
|
||||
let investments = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -240,10 +291,20 @@ export class PortfolioController {
|
||||
@Version('2')
|
||||
public async getPerformanceV2(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') dateRange: DateRange = 'max'
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const performanceInformation = await this.portfolioService.getPerformance({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
@ -298,12 +359,22 @@ export class PortfolioController {
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPositions(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') dateRange: DateRange = 'max'
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioPositions> {
|
||||
const result = await this.portfolioService.getPositions(
|
||||
impersonationId,
|
||||
dateRange
|
||||
);
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const result = await this.portfolioService.getPositions({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -323,6 +394,7 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
@Get('public/:accessId')
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPublic(
|
||||
@Param('accessId') accessId
|
||||
): Promise<PortfolioPublicDetails> {
|
||||
@ -372,6 +444,8 @@ export class PortfolioController {
|
||||
allocationCurrent: portfolioPosition.value / totalValue,
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||
dataSource: portfolioPosition.dataSource,
|
||||
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||
name: portfolioPosition.name,
|
||||
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||
|
@ -67,6 +67,8 @@ import {
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
max,
|
||||
parseISO,
|
||||
set,
|
||||
@ -206,19 +208,63 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public async getInvestments({
|
||||
public async getDividends({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
groupBy
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
}: {
|
||||
dateRange: DateRange;
|
||||
impersonationId: string;
|
||||
filters?: Filter[];
|
||||
groupBy?: GroupBy;
|
||||
impersonationId: string;
|
||||
}): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const activities = await this.orderService.getOrders({
|
||||
filters,
|
||||
userId,
|
||||
types: ['DIVIDEND'],
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency
|
||||
});
|
||||
|
||||
let dividends = activities.map((dividend) => {
|
||||
return {
|
||||
date: format(dividend.date, DATE_FORMAT),
|
||||
investment: dividend.valueInBaseCurrency
|
||||
};
|
||||
});
|
||||
|
||||
if (groupBy) {
|
||||
dividends = this.getDividendsByGroup({ dividends, groupBy });
|
||||
}
|
||||
|
||||
const startDate = this.getStartDate(
|
||||
dateRange,
|
||||
parseDate(dividends[0]?.date)
|
||||
);
|
||||
|
||||
return dividends.filter(({ date }) => {
|
||||
return !isBefore(parseDate(date), startDate);
|
||||
});
|
||||
}
|
||||
|
||||
public async getInvestments({
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
}: {
|
||||
dateRange: DateRange;
|
||||
filters?: Filter[];
|
||||
groupBy?: GroupBy;
|
||||
impersonationId: string;
|
||||
}): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId,
|
||||
includeDrafts: true
|
||||
});
|
||||
@ -236,26 +282,31 @@ export class PortfolioService {
|
||||
|
||||
let investments: InvestmentItem[];
|
||||
|
||||
if (groupBy === 'month') {
|
||||
investments = portfolioCalculator.getInvestmentsByMonth().map((item) => {
|
||||
return {
|
||||
date: item.date,
|
||||
investment: item.investment.toNumber()
|
||||
};
|
||||
});
|
||||
if (groupBy) {
|
||||
investments = portfolioCalculator
|
||||
.getInvestmentsByGroup(groupBy)
|
||||
.map((item) => {
|
||||
return {
|
||||
date: item.date,
|
||||
investment: item.investment.toNumber()
|
||||
};
|
||||
});
|
||||
|
||||
// Add investment of current month
|
||||
const dateOfCurrentMonth = format(
|
||||
set(new Date(), { date: 1 }),
|
||||
// Add investment of current group
|
||||
const dateOfCurrentGroup = format(
|
||||
set(new Date(), {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : new Date().getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
);
|
||||
const investmentOfCurrentMonth = investments.filter(({ date }) => {
|
||||
return date === dateOfCurrentMonth;
|
||||
const investmentOfCurrentGroup = investments.filter(({ date }) => {
|
||||
return date === dateOfCurrentGroup;
|
||||
});
|
||||
|
||||
if (investmentOfCurrentMonth.length <= 0) {
|
||||
if (investmentOfCurrentGroup.length <= 0) {
|
||||
investments.push({
|
||||
date: dateOfCurrentMonth,
|
||||
date: dateOfCurrentGroup,
|
||||
investment: 0
|
||||
});
|
||||
}
|
||||
@ -303,11 +354,13 @@ export class PortfolioService {
|
||||
|
||||
public async getChart({
|
||||
dateRange = 'max',
|
||||
filters,
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
@ -316,6 +369,7 @@ export class PortfolioService {
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId
|
||||
});
|
||||
|
||||
@ -357,15 +411,15 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
public async getDetails({
|
||||
impersonationId,
|
||||
dateRange = 'max',
|
||||
filters,
|
||||
impersonationId,
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
impersonationId: string;
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||
@ -493,6 +547,7 @@ export class PortfolioService {
|
||||
countries: symbolProfile.countries,
|
||||
currency: item.currency,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
dateOfFirstActivity: parseDate(item.firstBuyDate),
|
||||
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
|
||||
grossPerformancePercent:
|
||||
item.grossPerformancePercentage?.toNumber() ?? 0,
|
||||
@ -605,8 +660,9 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const positionCurrency = orders[0].SymbolProfile.currency;
|
||||
const [SymbolProfile] =
|
||||
await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]);
|
||||
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{ dataSource: aDataSource, symbol: aSymbol }
|
||||
]);
|
||||
|
||||
const portfolioOrders: PortfolioOrder[] = orders
|
||||
.filter((order) => {
|
||||
@ -690,6 +746,7 @@ export class PortfolioService {
|
||||
historicalDataArray.push({
|
||||
averagePrice: orders[0].unitPrice,
|
||||
date: firstBuyDate,
|
||||
quantity: orders[0].quantity,
|
||||
value: orders[0].unitPrice
|
||||
});
|
||||
}
|
||||
@ -706,6 +763,7 @@ export class PortfolioService {
|
||||
j++;
|
||||
}
|
||||
let currentAveragePrice = 0;
|
||||
let currentQuantity = 0;
|
||||
const currentSymbol = transactionPoints[j].items.find(
|
||||
(item) => item.symbol === aSymbol
|
||||
);
|
||||
@ -713,11 +771,13 @@ export class PortfolioService {
|
||||
currentAveragePrice = currentSymbol.quantity.eq(0)
|
||||
? 0
|
||||
: currentSymbol.investment.div(currentSymbol.quantity).toNumber();
|
||||
currentQuantity = currentSymbol.quantity.toNumber();
|
||||
}
|
||||
|
||||
historicalDataArray.push({
|
||||
date,
|
||||
averagePrice: currentAveragePrice,
|
||||
quantity: currentQuantity,
|
||||
value: marketPrice
|
||||
});
|
||||
|
||||
@ -809,14 +869,20 @@ export class PortfolioService {
|
||||
}
|
||||
}
|
||||
|
||||
public async getPositions(
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
public async getPositions({
|
||||
dateRange = 'max',
|
||||
filters,
|
||||
impersonationId
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
}): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId
|
||||
});
|
||||
|
||||
@ -836,7 +902,7 @@ export class PortfolioService {
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
startDate
|
||||
);
|
||||
@ -887,10 +953,12 @@ export class PortfolioService {
|
||||
|
||||
public async getPerformance({
|
||||
dateRange = 'max',
|
||||
filters,
|
||||
impersonationId,
|
||||
userId
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
userId: string;
|
||||
}): Promise<PortfolioPerformanceResponse> {
|
||||
@ -900,6 +968,7 @@ export class PortfolioService {
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId
|
||||
});
|
||||
|
||||
@ -955,6 +1024,7 @@ export class PortfolioService {
|
||||
|
||||
const historicalDataContainer = await this.getChart({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
@ -1204,6 +1274,68 @@ export class PortfolioService {
|
||||
);
|
||||
}
|
||||
|
||||
private getDividendsByGroup({
|
||||
dividends,
|
||||
groupBy
|
||||
}: {
|
||||
dividends: InvestmentItem[];
|
||||
groupBy: GroupBy;
|
||||
}): InvestmentItem[] {
|
||||
if (dividends.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dividendsByGroup: InvestmentItem[] = [];
|
||||
let currentDate: Date;
|
||||
let investmentByGroup = new Big(0);
|
||||
|
||||
for (const [index, dividend] of dividends.entries()) {
|
||||
if (
|
||||
isSameYear(parseDate(dividend.date), currentDate) &&
|
||||
(groupBy === 'year' ||
|
||||
isSameMonth(parseDate(dividend.date), currentDate))
|
||||
) {
|
||||
// Same group: Add up dividends
|
||||
|
||||
investmentByGroup = investmentByGroup.plus(dividend.investment);
|
||||
} else {
|
||||
// New group: Store previous group and reset
|
||||
|
||||
if (currentDate) {
|
||||
dividendsByGroup.push({
|
||||
date: format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
),
|
||||
investment: investmentByGroup.toNumber()
|
||||
});
|
||||
}
|
||||
|
||||
currentDate = parseDate(dividend.date);
|
||||
investmentByGroup = new Big(dividend.investment);
|
||||
}
|
||||
|
||||
if (index === dividends.length - 1) {
|
||||
// Store current month (latest order)
|
||||
dividendsByGroup.push({
|
||||
date: format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
),
|
||||
investment: investmentByGroup.toNumber()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return dividendsByGroup;
|
||||
}
|
||||
|
||||
private getFees({
|
||||
date = new Date(0),
|
||||
orders,
|
||||
@ -1246,6 +1378,7 @@ export class PortfolioService {
|
||||
assetSubClass: AssetClass.CASH,
|
||||
countries: [],
|
||||
dataSource: undefined,
|
||||
dateOfFirstActivity: undefined,
|
||||
grossPerformance: 0,
|
||||
grossPerformancePercent: 0,
|
||||
investment: balance,
|
||||
|
@ -21,6 +21,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Request, Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
@ -62,6 +63,7 @@ export class SubscriptionController {
|
||||
|
||||
await this.subscriptionService.createSubscription({
|
||||
duration: coupon.duration,
|
||||
price: 0,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
@ -86,9 +88,12 @@ export class SubscriptionController {
|
||||
}
|
||||
|
||||
@Get('stripe/callback')
|
||||
public async stripeCallback(@Req() req, @Res() res) {
|
||||
public async stripeCallback(
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
) {
|
||||
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
||||
req.query.checkoutSessionId
|
||||
<string>request.query.checkoutSessionId
|
||||
);
|
||||
|
||||
Logger.log(
|
||||
@ -96,7 +101,7 @@ export class SubscriptionController {
|
||||
'SubscriptionController'
|
||||
);
|
||||
|
||||
res.redirect(
|
||||
response.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
PROPERTY_STRIPE_CONFIG
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Subscription } from '@prisma/client';
|
||||
@ -70,13 +74,16 @@ export class SubscriptionService {
|
||||
|
||||
public async createSubscription({
|
||||
duration = '1 year',
|
||||
price,
|
||||
userId
|
||||
}: {
|
||||
duration?: StringValue;
|
||||
price: number;
|
||||
userId: string;
|
||||
}) {
|
||||
await this.prismaService.subscription.create({
|
||||
data: {
|
||||
price,
|
||||
expiresAt: addMilliseconds(new Date(), ms(duration)),
|
||||
User: {
|
||||
connect: {
|
||||
@ -93,7 +100,21 @@ export class SubscriptionService {
|
||||
aCheckoutSessionId
|
||||
);
|
||||
|
||||
await this.createSubscription({ userId: session.client_reference_id });
|
||||
let subscriptions: SubscriptionInterface[] = [];
|
||||
|
||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||
})) ?? { value: '{}' };
|
||||
|
||||
subscriptions = [JSON.parse(stripeConfig.value)];
|
||||
|
||||
const coupon = subscriptions[0]?.coupon ?? 0;
|
||||
const price = subscriptions[0]?.price ?? 0;
|
||||
|
||||
await this.createSubscription({
|
||||
price: price - coupon,
|
||||
userId: session.client_reference_id
|
||||
});
|
||||
|
||||
await this.stripe.customers.update(session.customer as string, {
|
||||
description: session.client_reference_id
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
||||
import { PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config';
|
||||
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
@ -69,17 +69,14 @@ export class UserController {
|
||||
|
||||
@Post()
|
||||
public async signupUser(): Promise<UserItem> {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
const isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
)) as boolean;
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (isReadOnlyMode) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
if (!isUserSignupEnabled) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const hasAdmin = await this.userService.hasAdmin();
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -27,3 +27,40 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
|
||||
return nullifyValuesInObject(object, keys);
|
||||
});
|
||||
}
|
||||
|
||||
export function redactAttributes({
|
||||
object,
|
||||
options
|
||||
}: {
|
||||
object: any;
|
||||
options: { attribute: string; valueMap: { [key: string]: any } }[];
|
||||
}): any {
|
||||
if (!object || !options || !options.length) {
|
||||
return object;
|
||||
}
|
||||
|
||||
const redactedObject = cloneDeep(object);
|
||||
|
||||
for (const option of options) {
|
||||
if (redactedObject.hasOwnProperty(option.attribute)) {
|
||||
redactedObject[option.attribute] =
|
||||
option.valueMap[redactedObject[option.attribute]] ??
|
||||
option.valueMap['*'] ??
|
||||
redactedObject[option.attribute];
|
||||
} else {
|
||||
// If the attribute is not present on the current object,
|
||||
// check if it exists on any nested objects
|
||||
for (const property in redactedObject) {
|
||||
if (typeof redactedObject[property] === 'object') {
|
||||
// Recursively call the function on the nested object
|
||||
redactedObject[property] = redactAttributes({
|
||||
options,
|
||||
object: redactedObject[property]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redactedObject;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
@ -12,7 +13,7 @@ import { map } from 'rxjs/operators';
|
||||
export class RedactValuesInResponseInterceptor<T>
|
||||
implements NestInterceptor<T, any>
|
||||
{
|
||||
public constructor() {}
|
||||
public constructor(private userService: UserService) {}
|
||||
|
||||
public intercept(
|
||||
context: ExecutionContext,
|
||||
@ -23,7 +24,10 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const hasImpersonationId = !!request.headers?.['impersonation-id'];
|
||||
|
||||
if (hasImpersonationId) {
|
||||
if (
|
||||
hasImpersonationId ||
|
||||
this.userService.isRestrictedView(request.user)
|
||||
) {
|
||||
if (data.accounts) {
|
||||
for (const accountId of Object.keys(data.accounts)) {
|
||||
if (data.accounts[accountId]?.balance !== undefined) {
|
||||
@ -38,6 +42,34 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
activity.Account.balance = null;
|
||||
}
|
||||
|
||||
if (activity.comment !== undefined) {
|
||||
activity.comment = null;
|
||||
}
|
||||
|
||||
if (activity.fee !== undefined) {
|
||||
activity.fee = null;
|
||||
}
|
||||
|
||||
if (activity.feeInBaseCurrency !== undefined) {
|
||||
activity.feeInBaseCurrency = null;
|
||||
}
|
||||
|
||||
if (activity.quantity !== undefined) {
|
||||
activity.quantity = null;
|
||||
}
|
||||
|
||||
if (activity.unitPrice !== undefined) {
|
||||
activity.unitPrice = null;
|
||||
}
|
||||
|
||||
if (activity.value !== undefined) {
|
||||
activity.value = null;
|
||||
}
|
||||
|
||||
if (activity.valueInBaseCurrency !== undefined) {
|
||||
activity.valueInBaseCurrency = null;
|
||||
}
|
||||
|
||||
return activity;
|
||||
});
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
CallHandler,
|
||||
@ -5,7 +6,7 @@ import {
|
||||
Injectable,
|
||||
NestInterceptor
|
||||
} from '@nestjs/common';
|
||||
import { isArray } from 'lodash';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@ -28,63 +29,23 @@ export class TransformDataSourceInResponseInterceptor<T>
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||
) {
|
||||
if (data.activities) {
|
||||
data.activities.map((activity) => {
|
||||
activity.SymbolProfile.dataSource = encodeDataSource(
|
||||
activity.SymbolProfile.dataSource
|
||||
);
|
||||
return activity;
|
||||
});
|
||||
}
|
||||
|
||||
if (isArray(data.benchmarks)) {
|
||||
data.benchmarks.map((benchmark) => {
|
||||
benchmark.dataSource = encodeDataSource(benchmark.dataSource);
|
||||
return benchmark;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.dataSource) {
|
||||
data.dataSource = encodeDataSource(data.dataSource);
|
||||
}
|
||||
|
||||
if (data.errors) {
|
||||
for (const error of data.errors) {
|
||||
if (error.dataSource) {
|
||||
error.dataSource = encodeDataSource(error.dataSource);
|
||||
data = redactAttributes({
|
||||
options: [
|
||||
{
|
||||
attribute: 'dataSource',
|
||||
valueMap: Object.keys(DataSource).reduce(
|
||||
(valueMap, dataSource) => {
|
||||
valueMap[dataSource] = encodeDataSource(
|
||||
DataSource[dataSource]
|
||||
);
|
||||
return valueMap;
|
||||
},
|
||||
{}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.holdings) {
|
||||
for (const symbol of Object.keys(data.holdings)) {
|
||||
if (data.holdings[symbol].dataSource) {
|
||||
data.holdings[symbol].dataSource = encodeDataSource(
|
||||
data.holdings[symbol].dataSource
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.items) {
|
||||
data.items.map((item) => {
|
||||
item.dataSource = encodeDataSource(item.dataSource);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.positions) {
|
||||
data.positions.map((position) => {
|
||||
position.dataSource = encodeDataSource(position.dataSource);
|
||||
return position;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.SymbolProfile) {
|
||||
data.SymbolProfile.dataSource = encodeDataSource(
|
||||
data.SymbolProfile.dataSource
|
||||
);
|
||||
}
|
||||
],
|
||||
object: data
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
|
@ -11,6 +11,8 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
|
||||
|
||||
@Injectable()
|
||||
export class CronService {
|
||||
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
|
||||
|
||||
public constructor(
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
@ -28,12 +30,12 @@ export class CronService {
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_5PM)
|
||||
public async runEveryDayAtFivePM() {
|
||||
public async runEveryDayAtFivePm() {
|
||||
this.twitterBotService.tweetFearAndGreedIndex();
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_WEEKEND)
|
||||
public async runEveryWeekend() {
|
||||
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
|
||||
public async runEverySundayAtTwelvePm() {
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
for (const { dataSource, symbol } of uniqueAssets) {
|
||||
|
@ -37,6 +37,20 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
|
@ -59,6 +59,10 @@ import { DataProviderService } from './data-provider.service';
|
||||
]
|
||||
}
|
||||
],
|
||||
exports: [DataProviderService, GhostfolioScraperApiService]
|
||||
exports: [
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
})
|
||||
export class DataProviderModule {}
|
||||
|
@ -23,6 +23,27 @@ export class DataProviderService {
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async getDividends({
|
||||
dataSource,
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
return this.getDataProvider(DataSource[dataSource]).getDividends({
|
||||
from,
|
||||
granularity,
|
||||
symbol,
|
||||
to
|
||||
});
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aItems: IDataGatheringItem[],
|
||||
aGranularity: Granularity = 'month',
|
||||
|
@ -37,6 +37,20 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
|
@ -37,6 +37,20 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
|
@ -34,6 +34,20 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
|
@ -11,6 +11,18 @@ export interface DataProviderInterface {
|
||||
|
||||
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
|
||||
|
||||
getDividends({
|
||||
from,
|
||||
granularity,
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}): Promise<{ [date: string]: IDataProviderHistoricalResponse }>;
|
||||
|
||||
getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity,
|
||||
|
@ -29,6 +29,20 @@ export class ManualService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
|
@ -5,20 +5,18 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import bent from 'bent';
|
||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class RapidApiService implements DataProviderInterface {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
@ -33,6 +31,20 @@ export class RapidApiService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
@ -47,41 +59,6 @@ export class RapidApiService implements DataProviderInterface {
|
||||
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
|
||||
const fgi = await this.getFearAndGreedIndex();
|
||||
|
||||
try {
|
||||
// Rebuild historical data
|
||||
// TODO: can be removed after all data from the last year has been gathered
|
||||
// (introduced on 27.03.2021)
|
||||
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: this.getName(),
|
||||
date: subWeeks(getToday(), 1),
|
||||
marketPrice: fgi.oneWeekAgo.value
|
||||
}
|
||||
});
|
||||
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: this.getName(),
|
||||
date: subMonths(getToday(), 1),
|
||||
marketPrice: fgi.oneMonthAgo.value
|
||||
}
|
||||
});
|
||||
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: this.getName(),
|
||||
date: subYears(getToday(), 1),
|
||||
marketPrice: fgi.oneYearAgo.value
|
||||
}
|
||||
});
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
[ghostfolioFearAndGreedIndexSymbol]: {
|
||||
[format(getYesterday(), DATE_FORMAT)]: {
|
||||
|
@ -154,16 +154,65 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
response.url = url;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Could not get asset profile for ${aSymbol} (${this.getName()}): [${
|
||||
error.name
|
||||
}] ${error.message}`
|
||||
);
|
||||
Logger.error(error, 'YahooFinanceService');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
from,
|
||||
granularity = 'day',
|
||||
symbol,
|
||||
to
|
||||
}: {
|
||||
from: Date;
|
||||
granularity: Granularity;
|
||||
symbol: string;
|
||||
to: Date;
|
||||
}) {
|
||||
if (isSameDay(from, to)) {
|
||||
to = addDays(to, 1);
|
||||
}
|
||||
|
||||
try {
|
||||
const historicalResult = await yahooFinance.historical(
|
||||
this.convertToYahooFinanceSymbol(symbol),
|
||||
{
|
||||
events: 'dividends',
|
||||
interval: granularity === 'month' ? '1mo' : '1d',
|
||||
period1: format(from, DATE_FORMAT),
|
||||
period2: format(to, DATE_FORMAT)
|
||||
}
|
||||
);
|
||||
|
||||
const response: {
|
||||
[date: string]: IDataProviderHistoricalResponse;
|
||||
} = {};
|
||||
|
||||
for (const historicalItem of historicalResult) {
|
||||
response[format(historicalItem.date, DATE_FORMAT)] = {
|
||||
marketPrice: this.getConvertedValue({
|
||||
symbol,
|
||||
value: historicalItem.dividends
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Could not get dividends for ${symbol} (${this.getName()}) from ${format(
|
||||
from,
|
||||
DATE_FORMAT
|
||||
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`,
|
||||
'YahooFinanceService'
|
||||
);
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
@ -176,11 +225,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
to = addDays(to, 1);
|
||||
}
|
||||
|
||||
const yahooFinanceSymbol = this.convertToYahooFinanceSymbol(aSymbol);
|
||||
|
||||
try {
|
||||
const historicalResult = await yahooFinance.historical(
|
||||
yahooFinanceSymbol,
|
||||
this.convertToYahooFinanceSymbol(aSymbol),
|
||||
{
|
||||
interval: '1d',
|
||||
period1: format(from, DATE_FORMAT),
|
||||
@ -192,27 +239,14 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {};
|
||||
|
||||
// Convert symbol back
|
||||
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
|
||||
|
||||
response[symbol] = {};
|
||||
response[aSymbol] = {};
|
||||
|
||||
for (const historicalItem of historicalResult) {
|
||||
let marketPrice = historicalItem.close;
|
||||
|
||||
if (symbol === `${this.baseCurrency}GBp`) {
|
||||
// Convert GPB to GBp (pence)
|
||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||
} else if (symbol === `${this.baseCurrency}ILA`) {
|
||||
// Convert ILS to ILA
|
||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||
} else if (symbol === `${this.baseCurrency}ZAc`) {
|
||||
// Convert ZAR to ZAc (cents)
|
||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||
}
|
||||
|
||||
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
|
||||
marketPrice,
|
||||
response[aSymbol][format(historicalItem.date, DATE_FORMAT)] = {
|
||||
marketPrice: this.getConvertedValue({
|
||||
symbol: aSymbol,
|
||||
value: historicalItem.close
|
||||
}),
|
||||
performance: historicalItem.open - historicalItem.close
|
||||
};
|
||||
}
|
||||
@ -427,6 +461,27 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return name || shortName || symbol;
|
||||
}
|
||||
|
||||
private getConvertedValue({
|
||||
symbol,
|
||||
value
|
||||
}: {
|
||||
symbol: string;
|
||||
value: number;
|
||||
}) {
|
||||
if (symbol === `${this.baseCurrency}GBp`) {
|
||||
// Convert GPB to GBp (pence)
|
||||
return new Big(value).mul(100).toNumber();
|
||||
} else if (symbol === `${this.baseCurrency}ILA`) {
|
||||
// Convert ILS to ILA
|
||||
return new Big(value).mul(100).toNumber();
|
||||
} else if (symbol === `${this.baseCurrency}ZAc`) {
|
||||
// Convert ZAR to ZAc (cents)
|
||||
return new Big(value).mul(100).toNumber();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private parseAssetClass(aPrice: Price): {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||
import {
|
||||
PROPERTY_CURRENCIES,
|
||||
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
@ -39,6 +42,13 @@ export class PropertyService {
|
||||
return properties?.[aKey];
|
||||
}
|
||||
|
||||
public async isUserSignupEnabled() {
|
||||
return (
|
||||
((await this.getByKey(PROPERTY_IS_USER_SIGNUP_ENABLED)) as boolean) ??
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
public async put({ key, value }: { key: string; value: string }) {
|
||||
return this.prismaService.property.upsert({
|
||||
create: { key, value },
|
||||
|
@ -36,6 +36,13 @@ export class SymbolProfileService {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
Order: {
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
SymbolProfileOverrides: true
|
||||
},
|
||||
where: {
|
||||
@ -118,6 +125,9 @@ export class SymbolProfileService {
|
||||
private getSymbols(
|
||||
symbolProfiles: (SymbolProfile & {
|
||||
_count: { Order: number };
|
||||
Order?: {
|
||||
date: Date;
|
||||
}[];
|
||||
SymbolProfileOverrides: SymbolProfileOverrides;
|
||||
})[]
|
||||
): EnhancedSymbolProfile[] {
|
||||
@ -128,6 +138,7 @@ export class SymbolProfileService {
|
||||
countries: this.getCountries(
|
||||
symbolProfile?.countries as unknown as Prisma.JsonArray
|
||||
),
|
||||
dateOfFirstActivity: <Date>undefined,
|
||||
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
||||
sectors: this.getSectors(symbolProfile),
|
||||
symbolMapping: this.getSymbolMapping(symbolProfile)
|
||||
@ -136,6 +147,9 @@ export class SymbolProfileService {
|
||||
item.activitiesCount = symbolProfile._count.Order;
|
||||
delete item._count;
|
||||
|
||||
item.dateOfFirstActivity = symbolProfile.Order?.[0]?.date;
|
||||
delete item.Order;
|
||||
|
||||
if (item.SymbolProfileOverrides) {
|
||||
item.assetClass =
|
||||
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
|
||||
|
@ -89,6 +89,10 @@
|
||||
"baseHref": "/es/",
|
||||
"localize": ["es"]
|
||||
},
|
||||
"development-fr": {
|
||||
"baseHref": "/fr/",
|
||||
"localize": ["fr"]
|
||||
},
|
||||
"development-it": {
|
||||
"baseHref": "/it/",
|
||||
"localize": ["it"]
|
||||
@ -97,6 +101,10 @@
|
||||
"baseHref": "/nl/",
|
||||
"localize": ["nl"]
|
||||
},
|
||||
"development-pt": {
|
||||
"baseHref": "/pt/",
|
||||
"localize": ["pt"]
|
||||
},
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
@ -144,12 +152,18 @@
|
||||
"development-es": {
|
||||
"browserTarget": "client:build:development-es"
|
||||
},
|
||||
"development-fr": {
|
||||
"browserTarget": "client:build:development-fr"
|
||||
},
|
||||
"development-it": {
|
||||
"browserTarget": "client:build:development-it"
|
||||
},
|
||||
"development-nl": {
|
||||
"browserTarget": "client:build:development-nl"
|
||||
},
|
||||
"development-pt": {
|
||||
"browserTarget": "client:build:development-pt"
|
||||
},
|
||||
"production": {
|
||||
"browserTarget": "client:build:production"
|
||||
}
|
||||
@ -164,8 +178,10 @@
|
||||
"targetFiles": [
|
||||
"messages.de.xlf",
|
||||
"messages.es.xlf",
|
||||
"messages.fr.xlf",
|
||||
"messages.it.xlf",
|
||||
"messages.nl.xlf"
|
||||
"messages.nl.xlf",
|
||||
"messages.pt.xlf"
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -194,6 +210,10 @@
|
||||
"baseHref": "/es/",
|
||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
||||
},
|
||||
"fr": {
|
||||
"baseHref": "/fr/",
|
||||
"translation": "apps/client/src/locales/messages.fr.xlf"
|
||||
},
|
||||
"it": {
|
||||
"baseHref": "/it/",
|
||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||
@ -201,6 +221,10 @@
|
||||
"nl": {
|
||||
"baseHref": "/nl/",
|
||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||
},
|
||||
"pt": {
|
||||
"baseHref": "/pt/",
|
||||
"translation": "apps/client/src/locales/messages.pt.xlf"
|
||||
}
|
||||
},
|
||||
"sourceLocale": "en"
|
||||
|
@ -2,7 +2,7 @@ import { Platform } from '@angular/cdk/platform';
|
||||
import { Inject, forwardRef } from '@angular/core';
|
||||
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
|
||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { format, parse } from 'date-fns';
|
||||
import { addYears, format, getYear, parse } from 'date-fns';
|
||||
|
||||
export class CustomDateAdapter extends NativeDateAdapter {
|
||||
public constructor(
|
||||
@ -31,6 +31,16 @@ export class CustomDateAdapter extends NativeDateAdapter {
|
||||
* Parses a date from a provided value
|
||||
*/
|
||||
public parse(aValue: string): Date {
|
||||
return parse(aValue, getDateFormatString(this.locale), new Date());
|
||||
let date = parse(aValue, getDateFormatString(this.locale), new Date());
|
||||
|
||||
if (getYear(date) < 1900) {
|
||||
if (getYear(date) > Number(format(new Date(), 'yy')) + 1) {
|
||||
date = addYears(date, 1900);
|
||||
} else {
|
||||
date = addYears(date, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
@ -109,6 +109,13 @@ const routes: Routes = [
|
||||
'./pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module'
|
||||
).then((m) => m.BlackFriday2022PageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/12/the-importance-of-tracking-your-personal-finances',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
|
||||
).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule)
|
||||
},
|
||||
{
|
||||
path: 'demo',
|
||||
loadChildren: () =>
|
||||
|
@ -3,6 +3,7 @@
|
||||
class="position-fixed w-100"
|
||||
[currentRoute]="currentRoute"
|
||||
[info]="info"
|
||||
[pageTitle]="pageTitle"
|
||||
[user]="user"
|
||||
(signOut)="onSignOut()"
|
||||
></gf-header>
|
||||
|
@ -5,7 +5,13 @@ import {
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
NavigationEnd,
|
||||
PRIMARY_OUTLET,
|
||||
Router
|
||||
} from '@angular/router';
|
||||
import {
|
||||
primaryColorHex,
|
||||
secondaryColorHex,
|
||||
@ -36,6 +42,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
public currentYear = new Date().getFullYear();
|
||||
public deviceType: string;
|
||||
public info: InfoItem;
|
||||
public pageTitle: string;
|
||||
public user: User;
|
||||
public version = environment.version;
|
||||
|
||||
@ -47,6 +54,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
private deviceService: DeviceDetectorService,
|
||||
private materialCssVarsService: MaterialCssVarsService,
|
||||
private router: Router,
|
||||
private title: Title,
|
||||
private tokenStorageService: TokenStorageService,
|
||||
private userService: UserService
|
||||
) {
|
||||
@ -66,6 +74,19 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.currentRoute = urlSegments[0].path;
|
||||
|
||||
this.info = this.dataService.fetchInfo();
|
||||
|
||||
if (this.deviceType === 'mobile') {
|
||||
setTimeout(() => {
|
||||
const index = this.title.getTitle().indexOf('–');
|
||||
const title =
|
||||
index === -1
|
||||
? ''
|
||||
: this.title.getTitle().substring(0, index).trim();
|
||||
this.pageTitle = title.length <= 15 ? title : 'Ghostfolio';
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
|
@ -12,7 +12,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -27,7 +27,7 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
|
||||
styleUrls: ['./account-detail-dialog.component.scss']
|
||||
})
|
||||
export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
public accountType: AccountType;
|
||||
public accountType: string;
|
||||
public name: string;
|
||||
public orders: OrderWithAccount[];
|
||||
public platformName: string;
|
||||
@ -59,7 +59,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
.fetchAccount(this.data.accountId)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
|
||||
this.accountType = accountType;
|
||||
this.accountType = translate(accountType);
|
||||
this.name = name;
|
||||
this.platformName = Platform?.name ?? '-';
|
||||
this.valueInBaseCurrency = valueInBaseCurrency;
|
||||
|
@ -30,7 +30,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="orders?.length > 0" class="row">
|
||||
<div class="row" [ngClass]="{ 'd-none': !orders?.length }">
|
||||
<div class="col mb-3">
|
||||
<div class="h5 mb-0" i18n>Activities</div>
|
||||
<gf-activities-table
|
||||
@ -38,7 +38,7 @@
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="data.deviceType"
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="!hasImpersonationId"
|
||||
[hasPermissionToExportActivities]="true"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToImportActivities]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
|
@ -18,14 +18,8 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="account">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="px-1"
|
||||
i18n
|
||||
mat-header-cell
|
||||
mat-sort-header="name"
|
||||
>
|
||||
Name
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name">
|
||||
<ng-container i18n>Name</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<gf-symbol-icon
|
||||
|
@ -3,12 +3,6 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
::ng-deep {
|
||||
.mat-form-field-infix {
|
||||
border-top: 0 solid transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-table {
|
||||
td {
|
||||
&.mat-footer-cell {
|
||||
|
@ -21,13 +21,16 @@ import {
|
||||
format,
|
||||
isBefore,
|
||||
isSameDay,
|
||||
isToday,
|
||||
isValid,
|
||||
parse,
|
||||
parseISO
|
||||
} from 'date-fns';
|
||||
import { last } from 'lodash';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
import { MarketDataDetailDialogParams } from './market-data-detail-dialog/interfaces/interfaces';
|
||||
import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-detail-dialog.component';
|
||||
|
||||
@Component({
|
||||
@ -37,6 +40,7 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-
|
||||
templateUrl: './admin-market-data-detail.component.html'
|
||||
})
|
||||
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
@Input() currency: string;
|
||||
@Input() dataSource: DataSource;
|
||||
@Input() dateOfFirstActivity: string;
|
||||
@Input() locale = getLocale();
|
||||
@ -106,9 +110,15 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
const marketDataItems = [...missingMarketData, ...this.marketData];
|
||||
|
||||
if (!isToday(last(marketDataItems)?.date)) {
|
||||
marketDataItems.push({ date: new Date() });
|
||||
}
|
||||
|
||||
this.marketDataByMonth = {};
|
||||
|
||||
for (const marketDataItem of [...missingMarketData, ...this.marketData]) {
|
||||
for (const marketDataItem of marketDataItems) {
|
||||
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
|
||||
const key = format(marketDataItem.date, 'yyyy-MM');
|
||||
|
||||
@ -152,9 +162,10 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
}
|
||||
|
||||
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
||||
data: {
|
||||
data: <MarketDataDetailDialogParams>{
|
||||
date,
|
||||
marketPrice,
|
||||
currency: this.currency,
|
||||
dataSource: this.dataSource,
|
||||
symbol: this.symbol,
|
||||
user: this.user
|
||||
|
@ -2,6 +2,7 @@ import { User } from '@ghostfolio/common/interfaces';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface MarketDataDetailDialogParams {
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
date: Date;
|
||||
marketPrice: number;
|
||||
|
@ -1,5 +1,5 @@
|
||||
<form class="d-flex flex-column h-100">
|
||||
<h1 mat-dialog-title i18n>Details for {{ data.symbol }}</h1>
|
||||
<h1 i18n mat-dialog-title>Details for {{ data.symbol }}</h1>
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<div class="mb-3">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
@ -30,6 +30,7 @@
|
||||
type="number"
|
||||
[(ngModel)]="data.marketPrice"
|
||||
/>
|
||||
<span class="ml-2" matSuffix>{{ data.currency }}</span>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
|
@ -13,12 +13,11 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { DATE_FORMAT, getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { AssetSubClass, DataSource } from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||
@ -98,7 +97,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
) {
|
||||
this.openAssetProfileDialog({
|
||||
dataSource: params['dataSource'],
|
||||
dateOfFirstActivity: params['dateOfFirstActivity'],
|
||||
symbol: params['symbol']
|
||||
});
|
||||
}
|
||||
@ -195,18 +193,9 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onOpenAssetProfileDialog({
|
||||
dataSource,
|
||||
dateOfFirstActivity,
|
||||
symbol
|
||||
}: UniqueAsset & { dateOfFirstActivity: string }) {
|
||||
try {
|
||||
dateOfFirstActivity = format(parseISO(dateOfFirstActivity), DATE_FORMAT);
|
||||
} catch {}
|
||||
|
||||
public onOpenAssetProfileDialog({ dataSource, symbol }: UniqueAsset) {
|
||||
this.router.navigate([], {
|
||||
queryParams: {
|
||||
dateOfFirstActivity,
|
||||
dataSource,
|
||||
symbol,
|
||||
assetProfileDialog: true
|
||||
@ -221,11 +210,9 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
|
||||
private openAssetProfileDialog({
|
||||
dataSource,
|
||||
dateOfFirstActivity,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
dateOfFirstActivity: string;
|
||||
symbol: string;
|
||||
}) {
|
||||
this.userService
|
||||
@ -238,7 +225,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
autoFocus: false,
|
||||
data: <AssetProfileDialogParams>{
|
||||
dataSource,
|
||||
dateOfFirstActivity,
|
||||
symbol,
|
||||
deviceType: this.deviceType,
|
||||
locale: this.user?.settings?.locale
|
||||
|
@ -176,7 +176,7 @@
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="cursor-pointer"
|
||||
mat-row
|
||||
(click)="onOpenAssetProfileDialog({ dateOfFirstActivity: row.date, dataSource: row.dataSource, symbol: row.symbol })"
|
||||
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
EnhancedSymbolProfile,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { MarketData } from '@prisma/client';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -28,11 +29,13 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
|
||||
styleUrls: ['./asset-profile-dialog.component.scss']
|
||||
})
|
||||
export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
public assetClass: string;
|
||||
public assetProfile: EnhancedSymbolProfile;
|
||||
public assetProfileForm = this.formBuilder.group({
|
||||
comment: '',
|
||||
symbolMapping: ''
|
||||
});
|
||||
public assetSubClass: string;
|
||||
public countries: {
|
||||
[code: string]: { name: string; value: number };
|
||||
};
|
||||
@ -64,6 +67,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ assetProfile, marketData }) => {
|
||||
this.assetProfile = assetProfile;
|
||||
|
||||
this.assetClass = translate(this.assetProfile?.assetClass);
|
||||
this.assetSubClass = translate(this.assetProfile?.assetSubClass);
|
||||
this.countries = {};
|
||||
this.marketDataDetails = marketData;
|
||||
this.sectors = {};
|
||||
@ -87,8 +93,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
this.assetProfileForm.setValue({
|
||||
comment: this.assetProfile?.comment,
|
||||
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping)
|
||||
comment: this.assetProfile?.comment ?? '',
|
||||
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {})
|
||||
});
|
||||
|
||||
this.assetProfileForm.markAsPristine();
|
||||
|
@ -43,22 +43,33 @@
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<gf-admin-market-data-detail
|
||||
class="mb-3"
|
||||
[currency]="assetProfile?.currency"
|
||||
[dataSource]="data.dataSource"
|
||||
[dateOfFirstActivity]="data.dateOfFirstActivity"
|
||||
[dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
|
||||
[locale]="data.locale"
|
||||
[marketData]="marketDataDetails"
|
||||
[symbol]="data.symbol"
|
||||
(marketDataChanged)="onMarketDataChanged($event)"
|
||||
></gf-admin-market-data-detail>
|
||||
<div class="row">
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
|
||||
>Symbol</gf-value
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value i18n size="medium" [value]="assetProfile?.currency"
|
||||
>Currency</gf-value
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[isDate]="data.dateOfFirstActivity ? true : false"
|
||||
[isDate]="assetProfile?.dateOfFirstActivity ? true : false"
|
||||
[locale]="data.locale"
|
||||
[value]="data.dateOfFirstActivity ?? '-'"
|
||||
>First Buy Date</gf-value
|
||||
[value]="assetProfile?.dateOfFirstActivity ?? '-'"
|
||||
>First Activity</gf-value
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
@ -67,15 +78,11 @@
|
||||
size="medium"
|
||||
[locale]="data.locale"
|
||||
[value]="assetProfile?.activitiesCount ?? 0"
|
||||
>Transactions</gf-value
|
||||
>Activities</gf-value
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[hidden]="!assetProfile?.assetClass"
|
||||
[value]="assetProfile?.assetClass"
|
||||
<gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass"
|
||||
>Asset Class</gf-value
|
||||
>
|
||||
</div>
|
||||
@ -83,8 +90,8 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[hidden]="!assetProfile?.assetSubClass"
|
||||
[value]="assetProfile?.assetSubClass"
|
||||
[hidden]="!assetSubClass"
|
||||
[value]="assetSubClass"
|
||||
>Asset Sub Class</gf-value
|
||||
>
|
||||
</div>
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface AssetProfileDialogParams {
|
||||
dateOfFirstActivity: string;
|
||||
dataSource: DataSource;
|
||||
deviceType: string;
|
||||
locale: string;
|
||||
|
@ -4,9 +4,11 @@ import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import {
|
||||
ghostfolioPrefix,
|
||||
PROPERTY_COUPONS,
|
||||
PROPERTY_CURRENCIES,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_IS_USER_SIGNUP_ENABLED,
|
||||
PROPERTY_SYSTEM_MESSAGE
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
@ -35,6 +37,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
public hasPermissionForSystemMessage: boolean;
|
||||
public hasPermissionToToggleReadOnlyMode: boolean;
|
||||
public info: InfoItem;
|
||||
public permissions = permissions;
|
||||
public transactionCount: number;
|
||||
public userCount: number;
|
||||
public user: User;
|
||||
@ -95,7 +98,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
public onAddCoupon() {
|
||||
const coupons = [
|
||||
...this.coupons,
|
||||
{ code: this.generateCouponCode(16), duration: this.couponDuration }
|
||||
{
|
||||
code: `${ghostfolioPrefix}${this.generateCouponCode(14)}`,
|
||||
duration: this.couponDuration
|
||||
}
|
||||
];
|
||||
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
|
||||
}
|
||||
@ -167,6 +173,13 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onEnableUserSignupModeChange(aEvent: MatSlideToggleChange) {
|
||||
this.putAdminSetting({
|
||||
key: PROPERTY_IS_USER_SIGNUP_ENABLED,
|
||||
value: aEvent.checked ? undefined : false
|
||||
});
|
||||
}
|
||||
|
||||
public onSetSystemMessage() {
|
||||
const systemMessage = prompt($localize`Please set your system message:`);
|
||||
|
||||
@ -214,7 +227,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
private putAdminSetting({ key, value }: { key: string; value: any }) {
|
||||
this.dataService
|
||||
.putAdminSetting(key, {
|
||||
value: value ? JSON.stringify(value) : undefined
|
||||
value: value || value === false ? JSON.stringify(value) : undefined
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
|
@ -72,16 +72,52 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="align-items-start d-flex my-3">
|
||||
<div
|
||||
*ngIf="info?.benchmarks?.length > 0"
|
||||
class="align-items-start d-flex my-3"
|
||||
>
|
||||
<div class="w-50" i18n>Benchmarks</div>
|
||||
<div class="w-50">
|
||||
<table>
|
||||
<tr *ngFor="let benchmark of info?.benchmarks">
|
||||
<tr *ngFor="let benchmark of info.benchmarks">
|
||||
<td class="pl-1">{{ benchmark.symbol }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="info?.tags?.length > 0"
|
||||
class="align-items-start d-flex my-3"
|
||||
>
|
||||
<div class="w-50" i18n>Tags</div>
|
||||
<div class="w-50">
|
||||
<table>
|
||||
<tr *ngFor="let tag of info.tags">
|
||||
<td class="pl-1">{{ tag.name }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>User Signup</div>
|
||||
<div class="w-50">
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
[checked]="info.globalPermissions.includes(permissions.createUserAccount)"
|
||||
(change)="onEnableUserSignupModeChange($event)"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
|
||||
<div class="w-50" i18n>Read-only Mode</div>
|
||||
<div class="w-50">
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
[checked]="info?.isReadOnlyMode"
|
||||
(change)="onReadOnlyModeChange($event)"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
|
||||
<div class="w-50" i18n>System Message</div>
|
||||
<div class="w-50">
|
||||
@ -109,16 +145,6 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
|
||||
<div class="w-50" i18n>Read-only Mode</div>
|
||||
<div class="w-50">
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
[checked]="info?.isReadOnlyMode"
|
||||
(change)="onReadOnlyModeChange($event)"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="d-flex my-3 subscription"
|
||||
|
@ -159,10 +159,12 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
border: {
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
width: 1
|
||||
},
|
||||
display: true,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
borderWidth: 1,
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
||||
display: false
|
||||
},
|
||||
@ -173,17 +175,19 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
},
|
||||
y: {
|
||||
border: {
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
display: false
|
||||
},
|
||||
display: true,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
||||
display: false,
|
||||
drawBorder: false
|
||||
display: false
|
||||
},
|
||||
position: 'right',
|
||||
ticks: {
|
||||
callback: (value: number) => {
|
||||
return `${value} %`;
|
||||
return `${value.toFixed(2)} %`;
|
||||
},
|
||||
display: true,
|
||||
mirror: true,
|
||||
|
@ -1,11 +1,11 @@
|
||||
<mat-toolbar class="px-2">
|
||||
<ng-container *ngIf="user">
|
||||
<a
|
||||
[routerLink]="['/']"
|
||||
class="align-items-center d-flex h-100 no-min-width px-2 rounded-0"
|
||||
mat-button
|
||||
[routerLink]="['/']"
|
||||
>
|
||||
<gf-logo></gf-logo>
|
||||
<gf-logo [label]="pageTitle"></gf-logo>
|
||||
</a>
|
||||
<span class="spacer"></span>
|
||||
<a
|
||||
@ -231,7 +231,10 @@
|
||||
mat-button
|
||||
[routerLink]="['/']"
|
||||
>
|
||||
<gf-logo [hideName]="currentRoute === 'register'"></gf-logo>
|
||||
<gf-logo
|
||||
[label]="pageTitle"
|
||||
[showLabel]="currentRoute !== 'register'"
|
||||
></gf-logo>
|
||||
</a>
|
||||
<span class="spacer"></span>
|
||||
<a
|
||||
@ -289,7 +292,7 @@
|
||||
<ng-container i18n>Sign in</ng-container>
|
||||
</button>
|
||||
<a
|
||||
*ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode"
|
||||
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
|
||||
class="d-none d-sm-block"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
|
@ -30,6 +30,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
|
||||
export class HeaderComponent implements OnChanges {
|
||||
@Input() currentRoute: string;
|
||||
@Input() info: InfoItem;
|
||||
@Input() pageTitle: string;
|
||||
@Input() user: User;
|
||||
|
||||
@Output() signOut = new EventEmitter<void>();
|
||||
@ -38,6 +39,7 @@ export class HeaderComponent implements OnChanges {
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToAccessAdminControl: boolean;
|
||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||
public hasPermissionToCreateUser: boolean;
|
||||
public impersonationId: string;
|
||||
public isMenuOpen: boolean;
|
||||
|
||||
@ -79,6 +81,11 @@ export class HeaderComponent implements OnChanges {
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableFearAndGreedIndex
|
||||
);
|
||||
|
||||
this.hasPermissionToCreateUser = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.createUserAccount
|
||||
);
|
||||
}
|
||||
|
||||
public impersonateAccount(aId: string) {
|
||||
|
@ -27,7 +27,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
public historicalDataItems: HistoricalDataItem[];
|
||||
public info: InfoItem;
|
||||
public isLoading = true;
|
||||
public readonly numberOfDays = 180;
|
||||
public readonly numberOfDays = 365;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
@ -1,5 +1,5 @@
|
||||
<div class="container">
|
||||
<h3 class="mb-3 text-center" i18n>Markets</h3>
|
||||
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Markets</h3>
|
||||
<div class="mb-5 row">
|
||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||
<div class="mb-2 text-center text-muted">
|
||||
|
@ -1,10 +1,8 @@
|
||||
<div class="container pb-3 px-3">
|
||||
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Summary</h3>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||
<mat-card class="h-100">
|
||||
<mat-card-header>
|
||||
<mat-card-title i18n>Summary</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-portfolio-summary
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
|
@ -48,6 +48,7 @@ import { last } from 'lodash';
|
||||
})
|
||||
export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
@Input() benchmarkDataItems: InvestmentItem[] = [];
|
||||
@Input() benchmarkDataLabel = '';
|
||||
@Input() colorScheme: ColorScheme;
|
||||
@Input() currency: string;
|
||||
@Input() daysInMarket: number;
|
||||
@ -153,7 +154,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
y: this.isInPercent ? investment * 100 : investment
|
||||
};
|
||||
}),
|
||||
label: $localize`Deposit`,
|
||||
label: this.benchmarkDataLabel,
|
||||
segment: {
|
||||
borderColor: (context: unknown) =>
|
||||
this.isInFuture(
|
||||
@ -194,6 +195,18 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
this.chart.options.plugins.tooltip = <unknown>(
|
||||
this.getTooltipPluginConfiguration()
|
||||
);
|
||||
this.chart.options.scales.x.min = this.daysInMarket
|
||||
? subDays(new Date(), this.daysInMarket).toISOString()
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
this.savingsRate &&
|
||||
this.chart.options.plugins.annotation.annotations.savingsRate
|
||||
) {
|
||||
this.chart.options.plugins.annotation.annotations.savingsRate.value =
|
||||
this.savingsRate;
|
||||
}
|
||||
|
||||
this.chart.update();
|
||||
} else {
|
||||
this.chart = new Chart(this.chartCanvas.nativeElement, {
|
||||
@ -257,13 +270,19 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
border: {
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
width: this.groupBy ? 0 : 1
|
||||
},
|
||||
display: true,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
borderWidth: this.groupBy ? 0 : 1,
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
||||
display: false
|
||||
},
|
||||
min: this.daysInMarket
|
||||
? subDays(new Date(), this.daysInMarket).toISOString()
|
||||
: undefined,
|
||||
suggestedMax: new Date().toISOString(),
|
||||
type: 'time',
|
||||
time: {
|
||||
tooltipFormat: getDateFormatString(this.locale),
|
||||
@ -271,12 +290,14 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
},
|
||||
y: {
|
||||
border: {
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
display: false
|
||||
},
|
||||
display: !this.isInPercent,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
||||
display: false,
|
||||
drawBorder: false
|
||||
display: false
|
||||
},
|
||||
position: 'right',
|
||||
ticks: {
|
||||
|
@ -19,7 +19,7 @@
|
||||
<div class="my-3 text-center text-muted" i18n>or</div>
|
||||
<div class="d-flex flex-column">
|
||||
<button
|
||||
class="mb-2"
|
||||
class="mb-2 px-4 rounded-pill"
|
||||
mat-stroked-button
|
||||
(click)="onLoginWithInternetIdentity()"
|
||||
>
|
||||
@ -29,7 +29,10 @@
|
||||
style="height: 0.75rem"
|
||||
/><span i18n>Sign in with Internet Identity</span>
|
||||
</button>
|
||||
<a href="../api/v1/auth/google" mat-stroked-button
|
||||
<a
|
||||
class="px-4 rounded-pill"
|
||||
href="../api/v1/auth/google"
|
||||
mat-stroked-button
|
||||
><img
|
||||
class="mr-2"
|
||||
src="../assets/icons/google.svg"
|
||||
|
@ -223,7 +223,7 @@
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="data.locale"
|
||||
[showActions]="false"
|
||||
[showSymbolColumn]="false"
|
||||
[showNameColumn]="false"
|
||||
(export)="onExport()"
|
||||
></gf-activities-table>
|
||||
</div>
|
||||
|
@ -3,12 +3,12 @@
|
||||
<a
|
||||
class="d-flex p-3 w-100"
|
||||
[ngClass]="{ 'cursor-default': isLoading }"
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{
|
||||
dataSource: position?.dataSource,
|
||||
positionDetailDialog: true,
|
||||
symbol: position?.symbol
|
||||
}"
|
||||
[routerLink]="[]"
|
||||
>
|
||||
<div class="d-flex mr-2">
|
||||
<gf-trend-indicator
|
||||
@ -39,7 +39,7 @@
|
||||
<div *ngIf="!isLoading" class="flex-grow-1 text-truncate">
|
||||
<div class="h6 m-0 text-truncate">{{ position?.name }}</div>
|
||||
<div class="d-flex">
|
||||
<span>{{ position?.symbol | gfSymbol }}</span>
|
||||
<small class="text-muted">{{ position?.symbol | gfSymbol }}</small>
|
||||
</div>
|
||||
<div class="d-flex mt-1">
|
||||
<gf-value
|
||||
|
@ -1,153 +0,0 @@
|
||||
<table
|
||||
class="gf-table w-100"
|
||||
mat-table
|
||||
matSort
|
||||
matSortActive="allocationCurrent"
|
||||
matSortDirection="desc"
|
||||
[dataSource]="dataSource"
|
||||
>
|
||||
<ng-container matColumnDef="icon">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
<gf-symbol-icon
|
||||
*ngIf="element.url"
|
||||
[tooltip]="element.name"
|
||||
[url]="element.url"
|
||||
></gf-symbol-icon>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Symbol</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<span [title]="element.name">{{ element.symbol | gfSymbol }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="name">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
<ng-container i18n>Name</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||
<ng-container *ngIf="element.name !== element.symbol">{{
|
||||
element.name
|
||||
}}</ng-container>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="value">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
<ng-container i18n>Value</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.value"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="allocationCurrent">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="justify-content-end px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
<ng-container i18n>Allocation</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isPercent]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.allocationCurrent"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="performance">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell px-1 text-right"
|
||||
mat-header-cell
|
||||
mat-sort-header="netPerformancePercent"
|
||||
>
|
||||
<ng-container i18n>Performance</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.netPerformancePercent"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||
<tr
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
mat-row
|
||||
[ngClass]="{
|
||||
'cursor-pointer':
|
||||
hasPermissionToShowValues &&
|
||||
!ignoreAssetSubClasses.includes(row.assetSubClass)
|
||||
}"
|
||||
(click)="
|
||||
hasPermissionToShowValues &&
|
||||
!ignoreAssetSubClasses.includes(row.assetSubClass) &&
|
||||
onOpenPositionDialog({ dataSource: row.dataSource, symbol: row.symbol })
|
||||
"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
class="px-4 py-3"
|
||||
[theme]="{
|
||||
height: '1.5rem',
|
||||
width: '100%'
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
||||
|
||||
<div
|
||||
*ngIf="dataSource.data.length > pageSize && !isLoading"
|
||||
class="my-3 text-center"
|
||||
>
|
||||
<button mat-stroked-button (click)="onShowAllPositions()">
|
||||
<ng-container i18n>Show all</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="
|
||||
dataSource.data.length === 0 && hasPermissionToCreateActivity && !isLoading
|
||||
"
|
||||
class="p-3 text-center"
|
||||
>
|
||||
<gf-no-transactions-info-indicator
|
||||
[hasBorder]="false"
|
||||
></gf-no-transactions-info-indicator>
|
||||
</div>
|
||||
|
||||
<mat-paginator class="d-none" [pageSize]="pageSize"></mat-paginator>
|
@ -1,33 +0,0 @@
|
||||
@import '~apps/client/src/styles/ghostfolio-style';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
::ng-deep {
|
||||
.mat-form-field-infix {
|
||||
border-top: 0 solid transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-table {
|
||||
th {
|
||||
::ng-deep {
|
||||
.mat-sort-header-container {
|
||||
justify-content: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-row {
|
||||
&.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
.mat-form-field {
|
||||
color: rgba(var(--light-primary-text));
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
<img
|
||||
*ngIf="url"
|
||||
src="https://www.google.com/s2/favicons?domain={{ url }}&sz=64"
|
||||
*ngIf="src"
|
||||
onerror="this.style.display='none'"
|
||||
[ngClass]="{ large: size === 'large' }"
|
||||
[src]="src"
|
||||
[title]="tooltip ? tooltip : ''"
|
||||
/>
|
||||
|
@ -2,8 +2,9 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
OnInit
|
||||
OnChanges
|
||||
} from '@angular/core';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-symbol-icon',
|
||||
@ -11,12 +12,22 @@ import {
|
||||
templateUrl: './symbol-icon.component.html',
|
||||
styleUrls: ['./symbol-icon.component.scss']
|
||||
})
|
||||
export class SymbolIconComponent implements OnInit {
|
||||
export class SymbolIconComponent implements OnChanges {
|
||||
@Input() dataSource: DataSource;
|
||||
@Input() size: 'large';
|
||||
@Input() symbol: string;
|
||||
@Input() tooltip: string;
|
||||
@Input() url: string;
|
||||
|
||||
public src: string;
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public ngOnInit() {}
|
||||
public ngOnChanges() {
|
||||
if (this.dataSource && this.symbol) {
|
||||
this.src = `../api/v1/logo/${this.dataSource}/${this.symbol}`;
|
||||
} else if (this.url) {
|
||||
this.src = `../api/v1/logo?url=${this.url}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3">About Ghostfolio</h3>
|
||||
<h3 class="d-none d-sm-block mb-3 text-center">About Ghostfolio</h3>
|
||||
<div class="about-container">
|
||||
<p>
|
||||
Ghostfolio is a lightweight wealth management application for
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="mb-3 text-center" i18n>Privacy Policy</h3>
|
||||
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Privacy Policy</h3>
|
||||
<markdown [src]="'../assets/privacy-policy.md'"></markdown>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -55,7 +55,17 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
public hasPermissionToUpdateViewMode: boolean;
|
||||
public hasPermissionToUpdateUserSettings: boolean;
|
||||
public language = document.documentElement.lang;
|
||||
public locales = ['de', 'de-CH', 'en-GB', 'en-US', 'es', 'it', 'nl'];
|
||||
public locales = [
|
||||
'de',
|
||||
'de-CH',
|
||||
'en-GB',
|
||||
'en-US',
|
||||
'es',
|
||||
'fr',
|
||||
'it',
|
||||
'nl',
|
||||
'pt'
|
||||
];
|
||||
public price: number;
|
||||
public priceId: string;
|
||||
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>Account</h3>
|
||||
<h3 class="mb-3 text-center" i18n>Account</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="user?.settings" class="mb-5 row">
|
||||
@ -135,6 +135,10 @@
|
||||
>Español (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
<!--<mat-option value="fr"
|
||||
>Français (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>-->
|
||||
<mat-option value="it"
|
||||
>Italiano (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
@ -143,6 +147,10 @@
|
||||
>Nederlands (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
<!--<mat-option value="pt"
|
||||
>Português (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>-->
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>Accounts</h3>
|
||||
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Accounts</h3>
|
||||
<div class="accounts">
|
||||
<gf-accounts-table
|
||||
[accounts]="accounts"
|
||||
@ -27,8 +27,8 @@
|
||||
class="align-items-center d-flex justify-content-center"
|
||||
color="primary"
|
||||
mat-fab
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ createDialog: true }"
|
||||
[routerLink]="[]"
|
||||
>
|
||||
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||
</a>
|
||||
|
@ -13,14 +13,21 @@ const routes: Routes = [
|
||||
canActivate: [AuthGuard],
|
||||
children: [
|
||||
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
|
||||
{ path: 'jobs', component: AdminJobsComponent },
|
||||
{ path: 'market-data', component: AdminMarketDataComponent },
|
||||
{ path: 'overview', component: AdminOverviewComponent },
|
||||
{ path: 'users', component: AdminUsersComponent }
|
||||
{ path: 'jobs', component: AdminJobsComponent, title: $localize`Jobs` },
|
||||
{
|
||||
path: 'market-data',
|
||||
component: AdminMarketDataComponent,
|
||||
title: $localize`Market Data`
|
||||
},
|
||||
{
|
||||
path: 'overview',
|
||||
component: AdminOverviewComponent,
|
||||
title: $localize`Admin Control`
|
||||
},
|
||||
{ path: 'users', component: AdminUsersComponent, title: $localize`Users` }
|
||||
],
|
||||
component: AdminPageComponent,
|
||||
path: '',
|
||||
title: $localize`Admin Control`
|
||||
path: ''
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -125,7 +125,7 @@
|
||||
feedback, bug reports, feature requests and of course contributions!
|
||||
</p>
|
||||
<p>
|
||||
You can reach me by email at
|
||||
You can reach me by e-mail at
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
|
@ -99,7 +99,7 @@
|
||||
>
|
||||
of users. In the future, I would like to involve more contributors
|
||||
to further extend the functionality of Ghostfolio (e.g. with new
|
||||
reports). Get in touch with me by email at
|
||||
reports). Get in touch with me by e-mail at
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> if you
|
||||
are interested, I’m happy to discuss ideas.
|
||||
|
@ -64,15 +64,15 @@
|
||||
<p>
|
||||
When you authenticate with <i>Internet Identity</i>, the service
|
||||
only gets a dedicated pseudonym rather than sensitive user data like
|
||||
the email address or phone number. This preserves your anonymity and
|
||||
prevents you being tracked on the Internet.
|
||||
the e-mail address or phone number. This preserves your anonymity
|
||||
and prevents you being tracked on the Internet.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">The key benefits in a nutshell</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Authenticate yourself securely without the need of an email
|
||||
Authenticate yourself securely without the need of an e-mail
|
||||
address, username, or a password: all you need is your device to
|
||||
log in.
|
||||
</li>
|
||||
@ -89,7 +89,7 @@
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
If you would like to provide feedback or get involved in further
|
||||
development of Ghostfolio, please get in touch by email via
|
||||
development of Ghostfolio, please get in touch by e-mail via
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
|
@ -62,7 +62,7 @@
|
||||
new and better Internet based on decentralized blockchains to give
|
||||
power back to the users. <i>Internet Identity</i> created by the
|
||||
<a href="https://dfinity.org">Dfinity Foundation</a> enables you to
|
||||
sign in securely and anonymously to Ghostfolio without an email
|
||||
sign in securely and anonymously to Ghostfolio without an e-mail
|
||||
address, username, or a password. All you need is your device with
|
||||
built-in biometric authentication.
|
||||
</p>
|
||||
@ -90,7 +90,7 @@
|
||||
onboard more contributors who are actively involved in software
|
||||
engineering to realize the full potential of open source software.
|
||||
If you are a web developer and interested in personal finance,
|
||||
please get in touch by email via
|
||||
please get in touch by e-mail via
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>. We are
|
||||
happy to discuss ideas.
|
||||
|
@ -83,7 +83,7 @@
|
||||
<a href="https://ghostfolio.slack.com">Slack community</a> or get in
|
||||
touch on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or by
|
||||
email via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
|
||||
e-mail via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
|
||||
</p>
|
||||
<p>
|
||||
We look forward to hearing from you.<br />
|
||||
|
@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { TheImportanceOfTrackingYourPersonalFinancesPageComponent } from './the-importance-of-tracking-your-personal-finances-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
canActivate: [AuthGuard],
|
||||
component: TheImportanceOfTrackingYourPersonalFinancesPageComponent,
|
||||
path: '',
|
||||
title: 'The importance of tracking your personal finances'
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class TheImportanceOfTrackingYourPersonalFinancesRoutingModule {}
|
@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-the-importance-of-tracking-your-personal-finances-page',
|
||||
styleUrls: ['./the-importance-of-tracking-your-personal-finances-page.scss'],
|
||||
templateUrl: './the-importance-of-tracking-your-personal-finances-page.html'
|
||||
})
|
||||
export class TheImportanceOfTrackingYourPersonalFinancesPageComponent {}
|
@ -0,0 +1,168 @@
|
||||
<div class="blog container">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<article>
|
||||
<div class="mb-4 text-center">
|
||||
<h1 class="mb-1">
|
||||
The importance of tracking your personal finances
|
||||
</h1>
|
||||
<div class="mb-3 text-muted"><small>2022-12-26</small></div>
|
||||
<img
|
||||
alt="The importance of tracking your personal finances Teaser"
|
||||
class="rounded w-100"
|
||||
src="../assets/images/blog/20221226.jpg"
|
||||
title="The importance of tracking your personal finances"
|
||||
/>
|
||||
</div>
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
Once again, the end of the year is peak season for making
|
||||
resolutions, whether in the area of personal fitness, relationships
|
||||
or career. For sustainable changes, it’s important to track your
|
||||
progress and celebrate even small successes. Not surprisingly, these
|
||||
same principles apply to personal finances as well.
|
||||
</p>
|
||||
<p>
|
||||
Tracking your assets is an important part of achieving your
|
||||
financial goals. By regularly reviewing and monitoring your assets,
|
||||
you can get a clear picture of your financial situation and identify
|
||||
areas where action is required to reach your financial ambitions.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Understanding your net worth</h2>
|
||||
<p>
|
||||
One of the main benefits of monitoring wealth is that it helps you
|
||||
understand the value of your assets. By keeping track of your
|
||||
assets, you can see how much money you have invested, where it is
|
||||
invested, and how well it is performing. This allows you to see how
|
||||
your assets are performing over time, and you can adjust your
|
||||
strategy if necessary.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Identifying opportunities</h2>
|
||||
<p>
|
||||
Tracking your wealth can also help you identify financial
|
||||
opportunities for growth and improvement. By reviewing your assets
|
||||
regularly, you may be able to identify new investment opportunities
|
||||
or strategies that can help you grow your wealth and reach your
|
||||
financial objectives faster.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Managing the risk and making informed decisions</h2>
|
||||
<p>
|
||||
Monitoring your assets not only helps you understand the value of
|
||||
your assets and identify potential for growth, but also helps you
|
||||
manage your risk. By understanding the different types of your
|
||||
assets and their performance, you can make informed decisions about
|
||||
how to allocate your assets to minimize risk and maximize the
|
||||
potential return on investment.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Tracking personal finances with Ghostfolio</h2>
|
||||
<p>
|
||||
In summary, monitoring your assets is an important aspect of
|
||||
achieving your financial goals. It helps you understand the value of
|
||||
your assets, identify growth opportunities and manage your risk. By
|
||||
regularly reviewing and monitoring your assets, you can make
|
||||
informed decisions about your financial strategy and work toward
|
||||
your financial success.
|
||||
</p>
|
||||
<p>
|
||||
Instead of manually recording your assets and net worth in a
|
||||
spreadsheet, you can use
|
||||
<a href="https://ghostfol.io">Ghostfolio</a>, a web-based software
|
||||
to manage your personal finances.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4 py-3">
|
||||
<h2 class="h4 mb-0 text-center">
|
||||
Would you like to <strong>refine</strong> your
|
||||
<strong>personal investment strategy</strong>?
|
||||
</h2>
|
||||
<p class="lead mb-2 text-center">
|
||||
Ghostfolio empowers you to keep track of your wealth.
|
||||
</p>
|
||||
<div class="text-center">
|
||||
<a color="primary" href="https://ghostfol.io" mat-flat-button>
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">App</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Assets</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Decision</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Finance</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Fintech</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Ghostfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Goal</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Investment</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Management</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Monitoring</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Net Worth</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Opportunity</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Performance</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Personal Finance</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Portfolio Tracker</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Progress</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Risk</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Software</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Spreadsheet</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Strategy</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Success</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,19 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { TheImportanceOfTrackingYourPersonalFinancesRoutingModule } from './the-importance-of-tracking-your-personal-finances-page-routing.module';
|
||||
import { TheImportanceOfTrackingYourPersonalFinancesPageComponent } from './the-importance-of-tracking-your-personal-finances-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [TheImportanceOfTrackingYourPersonalFinancesPageComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
RouterModule,
|
||||
TheImportanceOfTrackingYourPersonalFinancesRoutingModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class TheImportanceOfTrackingYourPersonalFinancesPageModule {}
|
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -1,16 +1,42 @@
|
||||
<div class="container">
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="mb-3 text-center" i18n>Blog</h3>
|
||||
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Blog</h3>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2022/12/the-importance-of-tracking-your-personal-finances"
|
||||
>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">
|
||||
The importance of tracking your personal finances
|
||||
</div>
|
||||
<div class="d-flex text-muted">2022-12-26</div>
|
||||
</div>
|
||||
<div class="align-items-center d-flex">
|
||||
<ion-icon
|
||||
class="chevron text-muted"
|
||||
name="chevron-forward-outline"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card *ngIf="hasPermissionForSubscription" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2022/11/black-friday-2022"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">Black Friday 2022</div>
|
||||
<div class="d-flex text-muted">2022-11-13</div>
|
||||
</div>
|
||||
@ -31,10 +57,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2022/10/hacktoberfest-2022"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">Hacktoberfest 2022</div>
|
||||
<div class="d-flex text-muted">2022-10-01</div>
|
||||
</div>
|
||||
@ -55,10 +81,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2022/08/500-stars-on-github"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">500 Stars on GitHub</div>
|
||||
<div class="d-flex text-muted">2022-08-18</div>
|
||||
</div>
|
||||
@ -79,10 +105,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2022/07/ghostfolio-meets-internet-identity"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">
|
||||
Ghostfolio meets Internet Identity
|
||||
</div>
|
||||
@ -105,10 +131,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2022/07/how-do-i-get-my-finances-in-order"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">
|
||||
How do I get my finances in order?
|
||||
</div>
|
||||
@ -131,10 +157,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2022/01/ghostfolio-first-months-in-open-source"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">
|
||||
First months in Open Source
|
||||
</div>
|
||||
@ -157,10 +183,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../en/blog/2021/07/hello-ghostfolio"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">Hello Ghostfolio</div>
|
||||
<div class="d-flex text-muted">2021-07-31</div>
|
||||
</div>
|
||||
@ -181,10 +207,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
class="d-flex overflow-hidden w-100"
|
||||
href="../de/blog/2021/07/hallo-ghostfolio"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="h6 m-0 text-truncate">Hallo Ghostfolio</div>
|
||||
<div class="d-flex text-muted">2021-07-31</div>
|
||||
</div>
|
||||
|
@ -1,7 +1,9 @@
|
||||
<div class="container">
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="mb-3 text-center">Frequently Asked Questions (FAQ)</h3>
|
||||
<h3 class="d-none d-sm-block mb-3 text-center">
|
||||
Frequently Asked Questions (FAQ)
|
||||
</h3>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-title>What is Ghostfolio?</mat-card-title>
|
||||
<mat-card-content>
|
||||
@ -46,7 +48,7 @@
|
||||
<a href="../en/blog/2022/07/ghostfolio-meets-internet-identity"
|
||||
>Internet Identity</a
|
||||
>) enable you to sign in securely and anonymously to Ghostfolio. There
|
||||
is no need for an email address, phone number, or a username.
|
||||
is no need for an e-mail address, phone number, or a username.
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
@ -55,10 +57,11 @@
|
||||
>This project is driven by the efforts of contributors from around the
|
||||
world. The
|
||||
<a href="https://github.com/ghostfolio/ghostfolio">source code</a> is
|
||||
fully available as open source software (OSS). Our
|
||||
fully available as open source software (OSS). Thanks to our generous
|
||||
<a [routerLink]="['/pricing']">Ghostfolio Premium</a> users and
|
||||
<a href="https://www.buymeacoffee.com/ghostfolio">sponsors</a> allow
|
||||
us to run a free, limited plan for new investors.</mat-card-content
|
||||
<a href="https://www.buymeacoffee.com/ghostfolio">sponsors</a> we have
|
||||
the ability to run a free, limited plan for new
|
||||
investors.</mat-card-content
|
||||
>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
@ -75,7 +78,8 @@
|
||||
><a [routerLink]="['/pricing']">Ghostfolio Premium</a> is a fully
|
||||
managed Ghostfolio cloud offering for ambitious investors. The revenue
|
||||
is used to cover the hosting infrastructure. It is the Open Source
|
||||
code base with some extras like the market overview.</mat-card-content
|
||||
code base with some extras like the
|
||||
<a [routerLink]="['/markets']">markets overview</a>.</mat-card-content
|
||||
>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
@ -83,24 +87,45 @@
|
||||
<mat-card-content
|
||||
>Yes, you can try
|
||||
<a [routerLink]="['/pricing']">Ghostfolio Premium</a> by signing up
|
||||
for Ghostfolio and applying for a trial (see “My Ghostfolio”). It’s
|
||||
for Ghostfolio and applying for a trial (see “My Ghostfolio”). It is
|
||||
easy, free and there is no commitment. You can stop using it at any
|
||||
time.</mat-card-content
|
||||
>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-title
|
||||
>How can I get a student discount for Ghostfolio
|
||||
Premium?</mat-card-title
|
||||
>
|
||||
<mat-card-content
|
||||
>Request your student discount
|
||||
<a href="mailto:hi@ghostfol.io?Subject=Student Discount">here</a> with
|
||||
your university e-mail address.</mat-card-content
|
||||
>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-title>Which devices are supported?</mat-card-title>
|
||||
<mat-card-content
|
||||
>Ghostfolio works in every modern web browser on smartphones, tablets
|
||||
and desktop computers (where you have even more analysis options and
|
||||
statistics). For Android users, there is a dedicated Ghostfolio app
|
||||
available in the
|
||||
and desktop computers. For <i>Android</i> users, there is a dedicated
|
||||
Ghostfolio app available in the
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=ch.dotsilver.ghostfolio.twa"
|
||||
>Google Play Store</a
|
||||
>.</mat-card-content
|
||||
>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-title
|
||||
>I cannot find my broker in the list of platforms. What can I
|
||||
do?</mat-card-title
|
||||
>
|
||||
<mat-card-content>
|
||||
Please send an e-mail with the web address of your broker to
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> and we are happy to
|
||||
add it.
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-title
|
||||
>Ghostfolio sounds cool, how can I get involved?</mat-card-title
|
||||
@ -115,7 +140,7 @@
|
||||
>, a star on
|
||||
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>,
|
||||
feedback, bug reports, feature requests and of course contributions!
|
||||
You can reach us by email at
|
||||
You can reach us by e-mail at
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a
|
||||
>.</mat-card-content
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3 text-center">Features</h3>
|
||||
<h3 class="d-none d-sm-block mb-3 text-center">Features</h3>
|
||||
<div class="mb-4">
|
||||
<p>
|
||||
Check out the numerous features of <strong>Ghostfolio</strong> to
|
||||
@ -197,8 +197,12 @@
|
||||
<div class="flex-grow-1">
|
||||
<h4>Multi-Language</h4>
|
||||
<p class="m-0">
|
||||
Use Ghostfolio in multiple languages: English, Dutch, German,
|
||||
Italian and Spanish are currently supported.
|
||||
Use Ghostfolio in multiple languages: English,
|
||||
Dutch<ng-container *ngIf="false">, Français</ng-container>,
|
||||
German, Italian<ng-container *ngIf="false"
|
||||
>, Portuguese</ng-container
|
||||
>
|
||||
and Spanish are currently supported.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user