Compare commits

...

73 Commits

Author SHA1 Message Date
38474f54b0 Release 1.225.0 (#1580) 2023-01-07 18:26:38 +01:00
18d25fb6c2 Feature/extend faq page (#1577)
* Extend FAQ page

* Update changelog
2023-01-07 18:21:09 +01:00
a850e8ca22 Import dividend (#1560)
* Import dividend

* Update changelog
2023-01-07 18:20:02 +01:00
b5f565c054 Simplify data source transformation (#1578) 2023-01-07 17:06:42 +01:00
aa6d0a4533 Update dates (#1575) 2023-01-07 08:48:06 +01:00
25e9028a41 Release 1.224.0 (#1573) 2023-01-04 20:15:29 +01:00
925d38703e Feature/add group by year option on analysis page (#1568)
* Add group by year option
2023-01-04 20:13:13 +01:00
158bb00b8a Add missing dutch translations (#1572)
* Add missing dutch translations

* Update changelog
2023-01-03 21:12:01 +01:00
b17111e6f1 Feature/setup fr (#1571)
* Setup fr

* Update changelog
2023-01-02 21:19:19 +01:00
c4765e31cd Feature/setup pt (#1439)
* Setup pt

* Update changelog
2023-01-02 20:52:48 +01:00
d321d56dee Release 1.223.0 (#1566) 2023-01-01 18:53:10 +01:00
07dd22f7fe Feature/extend asset profile details dialog by currency and symbol (#1565)
* Extend asset profile details dialog

* Update changelog
2023-01-01 12:05:28 +01:00
eb4d088a80 Feature/optimize page title for mobile (#1564)
* Optimize page title for mobile

* Update changelog
2023-01-01 09:57:27 +01:00
0509f0101f Feature/add student discount (#1563)
* Add student discount

* Update changelog
2023-01-01 09:22:38 +01:00
8818e09be8 Feature/add prefix to coupon codes (#1562)
* Add prefix

* Update changelog
2022-12-31 17:06:15 +01:00
d97fe4da9c Release 1.222.0 (#1561) 2022-12-29 18:11:26 +01:00
b20fa55b79 Feature/add filters to analytics page (#1559)
* Add filters to analysis page

* Update changelog
2022-12-29 10:31:21 +01:00
dd7a6f1562 Bugfix/fix i18n for account type (#1554)
* Translate account type

* Update changelog
2022-12-29 10:20:23 +01:00
15357bd5b5 Feature/add asset profile details to activities import (#1552)
* Add asset profile details

* Update changelog
2022-12-29 10:19:30 +01:00
52c7adc266 Feature/upgrade internet identity dependencies to version 0.15.1 (#1549)
* Upgrade Internet Identity dependencies to version 0.15.1

* Update changelog
2022-12-28 20:08:30 +01:00
1ae8970045 Feature/add price to subscription (#1551)
* Add price

* Update changelog
2022-12-28 13:57:15 +01:00
7c4c047140 Remove .gitkeep (#1553) 2022-12-28 13:45:30 +01:00
527f7e4faf Feature/upgrade observable store to version 2.2.15 (#1550)
* Upgrade observable-store to version 2.2.15

* Update changelog
2022-12-28 13:43:28 +01:00
50160eb9dc Feature/upgrade countup.js to version 2.3.2 (#1548)
* Upgrade countup.js to version 2.3.2

* Update changelog
2022-12-28 13:22:19 +01:00
58dff8a1e0 Feature/upgrade prisma to version 4.8.0 (#1547)
* Upgrade prisma to version 4.8.0

* Update changelog
2022-12-28 10:40:34 +01:00
2cd41615b2 Feature/change execution time of asset profile data gathering (#1544)
* Change execution time

* Update changelog
2022-12-28 09:31:46 +01:00
66d5793528 Refactoring (#1545) 2022-12-27 10:05:26 +01:00
e8d65e1c85 Feature/upgrade bull to version 4.10.2 (#1542)
* Upgrade bull to version 4.10.2

* Update changelog
2022-12-27 09:12:44 +01:00
da827a08f5 Release 1.221.0 (#1541) 2022-12-26 17:09:51 +01:00
d545e4877c Feature/improve activities import by preview step (#1540)
* Improve activities import

* Update changelog
2022-12-26 17:07:51 +01:00
1918dee9c5 Format code 2022-12-26 16:50:50 +01:00
a08610b603 Feature/improve activities import (#1531) 2022-12-26 16:26:51 +01:00
c22733db56 Feature/resolve blog post titles (#1539)
* Resolve blog post titles

* Update changelog
2022-12-26 16:23:21 +01:00
ee4866eb7d Feature/add blog post the importance of tracking your personal finances (#1537)
* Add blog post: The importance of tracking your personal finances

* Update changelog
2022-12-26 12:23:43 +01:00
327b1fa0d7 Feature/refresh cryptocurrencies list 20221225 (#1536)
* Update cryptocurrencies.json

* Update changelog
2022-12-25 18:17:35 +01:00
b155666d21 Feature/improve label based on type in create or edit activity dialog (#1535)
* Improve label

* Update changelog
2022-12-25 12:41:48 +01:00
c5ee3237ed Refactoring (#1533) 2022-12-25 12:40:29 +01:00
16118d635c Bugfix/fix date conversion of two digit year (#1529)
* Fix date conversion of two digit year

* Update changelog
2022-12-25 12:38:40 +01:00
49ce4803ce Feature/add support to manage tags in create or edit activity dialog (#1532)
* Add support to manage tags

* Update changelog
2022-12-25 12:23:52 +01:00
0b65d05013 Feature/remove rakuten from data source type (#1534)
* Remove RAKUTEN

* Update changelog
2022-12-25 12:20:09 +01:00
8793284e75 Feature/add tags to admin control panel (#1530)
* Add tags

* Update changelog
2022-12-24 12:28:17 +01:00
1c5e4050a8 Release 1.220.0 (#1528) 2022-12-23 11:08:22 +01:00
4f187e1a9f Feature/increase fear and greed index to 365 days (#1519)
* Increase fear and greed index to 365 days

* Update changelog
2022-12-23 11:06:52 +01:00
b56111ae85 Feature/add dry run to import api endpoint (#1526)
* Add dry run to import API endpoint

* Update changelog
2022-12-23 10:51:49 +01:00
61dfc1f819 Feature/upgrade prettier to version 2.8.1 (#1523)
* Upgrade prettier to version 2.8.1

* Update changelog
2022-12-23 10:50:57 +01:00
6137f228a8 Upgrade @types/lodash to version 4.14.191 (#1527) 2022-12-23 10:50:38 +01:00
5293de14cd Bugfix/fix rounding of y axis ticks in benchmark comparator (#1521)
* Fix rounding

* Update changelog
2022-12-22 12:27:34 +01:00
7340a674b5 Feature/upgrade color to version 4.2.3 (#1524)
* Upgrade color to version 4.2.3

* Update changelog
2022-12-21 19:40:29 +01:00
42cb3e2c73 Harmonize style of symbol (#1520) 2022-12-20 13:29:57 +01:00
e8a4a53c9f Feature/support position detail dialog in top performers of analysis page (#1522)
* Add support for position detail dialog

* Update changelog
2022-12-19 09:36:34 +01:00
629f002074 Release 1.219.0 (#1518) 2022-12-17 21:22:26 +01:00
7c65cf6ddd Update locales (#1517) 2022-12-17 21:20:51 +01:00
c38ebec3be Feature/add name to activities table (#1516)
* Add name to symbol column

* Update changelog
2022-12-17 21:14:58 +01:00
2b8ab26e7e Feature/combine name and symbol column in holdings table (#1514)
* Combine name and symbol column

* Update changelog
2022-12-17 20:50:39 +01:00
60f52bb209 Handle user signup for OAuth and Internet Identity (#1515)
Co-Authored-By: gobdevel <99349192+gobdevel@users.noreply.github.com>
2022-12-17 16:18:00 +01:00
616d168a7c Feature/add signup permission (#1512)
* Added support to disable user sign up in the admin control panel

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2022-12-17 12:39:21 +01:00
b13e4425d3 Feature/extend glossary by deflation inflation and stagflation (#1511)
* Extend glossary by deflation, inflation and stagflation

* Update changelog
2022-12-16 17:38:19 +01:00
1424236c48 Add "Buy me a coffee" badge (#1509) 2022-12-15 17:26:30 +01:00
2a605f850d Refactor requests and responses (#1507) 2022-12-14 08:03:38 +01:00
88ffbfead0 Add purpose (#1508) 2022-12-13 12:56:10 +01:00
5f4a8d505b Release 1.218.0 (#1510) 2022-12-12 20:15:32 +01:00
e87b93f19c Feature/add logo endpoint (#1506)
* Add logo endpoint

* Update changelog
2022-12-12 20:13:45 +01:00
49dcade964 Feature/add date of first activity to holdings (#1505)
* Add date of first activity

* Update changelog
2022-12-11 10:19:35 +01:00
7cd65eed39 Feature/improve asset profile details dialog (#1504)
* Improve asset profile details dialog

* Update changelog
2022-12-11 09:37:07 +01:00
a51b210f79 Feature/upgrade chart.js to version 4.0.1 (#1503)
* Upgrade chart.js to version 4.0.1 (including plugins)

* Migrate border attributes

* Update changelog
2022-12-10 16:47:09 +01:00
285f2220f3 Release 1.217.0 (#1502) 2022-12-10 14:32:28 +01:00
d72123246d Feature/upgrade cheerio to version 1.0.0 rc.12 (#1501)
* Upgrade cheerio to version 1.0.0-rc.12

* Update changelog
2022-12-10 12:19:45 +01:00
3a78d6c3f1 Feature/add dividend timeline (#1498)
* Add dividend timeline

* Update changelog

Co-Authored-By: João Pereira <joao@jpereira.me>
2022-12-10 11:54:49 +01:00
d5e3ff5717 Add locale (#1500) 2022-12-09 15:06:15 +01:00
2efb331370 Feature/improve value redaction interceptor (#1495)
* Improve value redaction interceptor

* Update changelog
2022-12-07 17:48:46 +01:00
f521fe99c5 Feature/upgrade prisma to version 4.7.1 (#1493)
* Upgrade prisma to version 4.7.1

* Update changelog
2022-12-06 09:03:28 +01:00
42306530b8 New strings translated to Spanish (#1496)
* Update messages.es.xlf

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2022-12-04 20:02:02 +01:00
68c9d1b266 Bugfix/fix activities sorting in account detail dialog (#1494)
* Fix activities sorting

* Update changelog
2022-12-04 18:54:41 +01:00
174 changed files with 9462 additions and 2718 deletions

View File

@ -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

View File

@ -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).

View File

@ -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,

View File

@ -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`

View File

@ -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
],

View File

@ -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,

View File

@ -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,

View File

@ -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')
})
);

View File

@ -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 };
}
}

View File

@ -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]
})

View File

@ -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;
}
}

View File

@ -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;
}
}

View 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();
}
}
}

View 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 {}

View 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();
}
}

View File

@ -6,5 +6,6 @@ export interface Activities {
export interface Activity extends OrderWithAccount {
feeInBaseCurrency: number;
value: number;
valueInBaseCurrency: number;
}

View File

@ -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 };
}

View File

@ -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,

View File

@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();

View File

@ -53,7 +53,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();

View File

@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();

View File

@ -41,7 +41,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();

View File

@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();

View File

@ -68,7 +68,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore();

View File

@ -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
});
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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`

View File

@ -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

View File

@ -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

View File

@ -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;
}

View File

@ -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;
});
}

View File

@ -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;

View File

@ -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) {

View File

@ -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',

View File

@ -59,6 +59,10 @@ import { DataProviderService } from './data-provider.service';
]
}
],
exports: [DataProviderService, GhostfolioScraperApiService]
exports: [
DataProviderService,
GhostfolioScraperApiService,
YahooFinanceService
]
})
export class DataProviderModule {}

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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,

View File

@ -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',

View File

@ -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)]: {

View File

@ -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;

View File

@ -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 },

View File

@ -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;

View File

@ -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"

View File

@ -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;
}
}

View File

@ -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: () =>

View File

@ -3,6 +3,7 @@
class="position-fixed w-100"
[currentRoute]="currentRoute"
[info]="info"
[pageTitle]="pageTitle"
[user]="user"
(signOut)="onSignOut()"
></gf-header>

View File

@ -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

View File

@ -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;

View File

@ -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"

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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();

View File

@ -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>

View File

@ -1,7 +1,6 @@
import { DataSource } from '@prisma/client';
export interface AssetProfileDialogParams {
dateOfFirstActivity: string;
dataSource: DataSource;
deviceType: string;
locale: string;

View File

@ -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(() => {

View File

@ -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"

View File

@ -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,

View File

@ -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

View File

@ -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) {

View File

@ -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>();

View File

@ -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">

View File

@ -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"

View File

@ -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: {

View File

@ -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"

View File

@ -223,7 +223,7 @@
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
[showActions]="false"
[showSymbolColumn]="false"
[showNameColumn]="false"
(export)="onExport()"
></gf-activities-table>
</div>

View File

@ -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

View File

@ -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>

View File

@ -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));
}
}

View File

@ -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 : ''"
/>

View File

@ -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}`;
}
}
}

View File

@ -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

View File

@ -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>

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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: ''
}
];

View File

@ -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>

View File

@ -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, Im happy to discuss ideas.

View File

@ -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>

View File

@ -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.

View File

@ -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 />

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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, its 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>

View File

@ -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 {}

View File

@ -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>

View File

@ -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”). Its
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

View File

@ -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