Compare commits

..

79 Commits

Author SHA1 Message Date
a3a9957196 Release 1.27.0 (#223) 2021-07-18 20:05:46 +02:00
9072cbdba1 Feature/add no transactions info on zen page (#222)
* Add no transactions info to zen page

* Update changelog
2021-07-18 17:34:28 +02:00
120b691336 Bugfix/fix url to fear and greed index (#221)
* Fix url

* Update changelog
2021-07-18 09:33:23 +02:00
bd4ad76953 Feature/remove pause in onboarding (#220)
* Improve onboarding
  * Remove pause
  * Add icon

* Update changelog
2021-07-18 08:19:05 +02:00
94d56f553f Bugfix/fix chart on landing page (#219)
* Fix chart on landing page

* Update changelog
2021-07-17 20:48:08 +02:00
ecdd325228 Release 1.26.0 (#217) 2021-07-17 11:06:14 +02:00
51fbc538ca Feature/set public stripe key dynamically (#216)
* Set public Stripe key dynamically

* Update changelog
2021-07-17 11:04:43 +02:00
39a76f7f40 Feature/add robots.txt (#215)
* Add robots.txt

* Update changelog
2021-07-16 21:32:02 +02:00
e4d325daab Feature/various style improvements (#214)
* Improve styles

* Update changelog
2021-07-15 18:10:18 +02:00
b765df65d6 Improve wording (#213) 2021-07-14 20:54:23 +02:00
c7b7efae3b Feature/import transactions (#212)
* Implement import transactions functionality

* Update changelog
2021-07-14 20:54:05 +02:00
be5b58f49a Bugfix/fix warn color (#211)
* Fix warn color

* Update changelog
2021-07-13 20:29:22 +02:00
91c748c7ad Release 1.25.0 (#210) 2021-07-11 17:21:24 +02:00
ecfe694f0b Feature/export transactions (#209)
* Export functionality for transactions

* Update changelog
2021-07-11 17:05:58 +02:00
1491bf7f76 Update changelog (#208) 2021-07-11 10:37:02 +02:00
b3b9a051c3 Shorten slogan (#207) 2021-07-11 10:31:36 +02:00
bf1146bfd6 Feature/change slogan to wealth management (#206)
* Harmonize slogans to "Open Source Wealth Management Software"

* Update changelog
2021-07-10 19:20:02 +02:00
0774ca91a1 Improve settings selectors layout (#205) 2021-07-10 18:17:17 +02:00
f403807f2d Bugfix/fix average buy price calculation (#204)
* Fix average buy price calculation

* Update changelog
2021-07-10 18:16:46 +02:00
f22991b090 Feature/respect cash balance in analysis (#203)
* Respect cash balance in in analysis

* Update changelog
2021-07-10 14:57:03 +02:00
1135a5b335 Fix rendering of currency and platform in dialogs and clean up observables (#202) 2021-07-08 21:28:28 +02:00
d9ea255c17 Release 1.24.0 (#201) 2021-07-07 21:45:57 +02:00
2c19d8c8e7 Feature/add balance to account (#193)
* Add balance attribute and calculate total balance

* Update changelog
2021-07-07 21:23:36 +02:00
db090229ce Feature/add total value in the create or edit transaction dialog (#192)
* Display total value

* Update changelog
2021-07-07 21:14:01 +02:00
fbe590ddb9 Feature/upgrade angular material css vars to 2.0.0 (#200)
* Upgrade angular-material-css-vars

* Update changelog
2021-07-05 21:53:30 +02:00
0d65136a9e Revert "Remove unneeded dependencies (#197)" (#199)
This reverts commit a062a3cee4.
2021-07-05 20:35:10 +02:00
dea87cc3cf Improve README.md (#198) 2021-07-04 22:19:09 +02:00
a062a3cee4 Remove unneeded dependencies (#197) 2021-07-04 22:11:47 +02:00
5b1b207a6f Feature/upgrade angular dependencies to version 12.0.x (#196)
* Update angular dependencies to version 12.0.X

* Update changelog
2021-07-04 21:55:25 +02:00
63cc7b2871 Feature/upgrade nestjs dependencies (#195)
* Upgrade @nestjs dependencies

* Update changelog
2021-07-04 21:45:53 +02:00
3986e8f879 Upgrade Nx to version 12.5.4 (#194) 2021-07-04 21:31:15 +02:00
290e93bbd7 Release 1.23.1 (#191) 2021-07-03 12:20:36 +02:00
b08ecd1b18 Release 1.23.0 (#190) 2021-07-03 11:51:26 +02:00
92d321a001 Drafts for orders (#187)
* Render the future with a dashed border

* Update changelog
2021-07-03 11:32:03 +02:00
ce2d8d519d Change from travis-ci.org to travis-ci.com (#188) 2021-06-30 21:52:29 +02:00
f32bef071e Add contributing section (#186) 2021-06-27 10:23:51 +02:00
4aa7365d9b Release 1.22.0 (#185) 2021-06-25 17:34:49 +02:00
367f25a975 Feature/set user id in stripe callback (#184)
* Set user id as description

* Update changelog
2021-06-24 21:52:41 +02:00
9832334da1 Move @types/lodash to dev dependencies (#183) 2021-06-23 17:36:40 +02:00
e126f9ec54 Release 1.21.0 (#182) 2021-06-22 21:55:00 +02:00
09bbda3502 Change from subscription to one time payment (#181) 2021-06-22 21:53:29 +02:00
ee9a521813 Bugfix/fix base currency in pricing page (#180)
* Fix base currency

* Update changelog
2021-06-21 20:52:01 +02:00
169c151547 Feature/improve style of about page (#177)
* Improve style

* Update changelog
2021-06-21 20:08:45 +02:00
3a95ec0f81 Release 1.20.0 (#179) 2021-06-21 20:05:54 +02:00
ad00cd9d81 Feature/setup subscription with stripe (#178)
* Set up stripe for subscriptions

* Update permissions and add discount

* Update changelog
2021-06-21 20:03:36 +02:00
373a2015c0 Release 1.19.0 (#176) 2021-06-17 23:01:59 +02:00
66c955ad6c Feature/ghostfolio in numbers (#175)
* Add Ghostfolio in numbers section

* Update changelog
2021-06-17 22:59:48 +02:00
a2440fc067 Release 1.18.0 (#174) 2021-06-16 17:34:43 +02:00
3d7624d997 Feature/improve twa onboarding (#173)
* Improve TWA onboarding (Redirect to the account registration page)

* Update changelog
2021-06-16 17:31:31 +02:00
0264b592b9 Feature/improve investments by sector (#172)
* Improve investments analysis by sector

* Update changelog
2021-06-16 17:05:43 +02:00
198eaf57d3 Release 1.17.0 (#171) 2021-06-15 21:17:41 +02:00
6783ea2ebb Feature/upgrade various frontend dependencies (#170)
* Upgrade frontend dependencies

* Update changelog
2021-06-15 21:15:48 +02:00
a35701fe24 Feature/upgrade to angular 12 (#169)
* Upgrade to Angular 12

* Update changelog
2021-06-15 21:03:55 +02:00
5db90f1787 Feature/improve error page of fingerprint sign in (#167)
* Improve error page

* Update changelog
2021-06-15 09:47:18 +02:00
81fe538484 Order attribute 2021-06-15 09:43:48 +02:00
51884913be Feature/disable fingerprint sign in in demo account page (#163)
* Disable fingerprint toggle for demo user

* Update changelog
2021-06-15 09:21:53 +02:00
8886082dfa Feature/upgrade eslint and prettier dependencies (#164)
* Upgrade eslint and prettier dependencies

* Feature/upgrade date fns to version 2.22.1 (#165)

* Feature/upgrade chart.js to version 3.3.2 (#166)

* Update changelog
2021-06-15 09:17:27 +02:00
3b12e5b85b Release 1.16.0 (#162) 2021-06-14 22:00:00 +02:00
6c1119caec Restrict webauthn to fingerprint only and improve UX (#161)
* Restrict webauthn to fingerprint only

* Move webauthn login to separate page /webauthn

* Stay signed in with social login

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-06-14 21:57:09 +02:00
698d5ec3b7 Release 1.15.0 (#160) 2021-06-14 16:15:50 +02:00
e87c942cb8 Add webauthn (#82)
* Add webauthn

* Complete WebAuthn device sign up and login

* Move device registration to account page
* Replace the token login with a WebAuthn prompt if the current device has been registered
* Mark the current device in the list of registered auth devices

* Fix after rebase

* Fix tests

* Disable "Add current device" button if current device is registered

* Add option to "Stay signed in"

* Remove device list feature, sign in with deviceId instead

* Improve usability

* Update changelog

Co-authored-by: Matthias Frey <mfrey43@gmail.com>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-06-14 16:09:40 +02:00
f7860a9799 Feature/introduce max items in portfolio proportion chart (#159)
* Add option to limit items

* Update changelog
2021-06-14 14:18:02 +02:00
c519eb0e99 Bugfix/fix last activity column (#158)
* Fix last activity (only values in the past)

* Update changelog
2021-06-14 14:06:24 +02:00
8314b98f81 Feature/improve tables (#157)
* Improve tables

* Update changelog
2021-06-14 14:02:25 +02:00
194cf1ddcc Feature/clean up analysis page (#155)
* Clean up analysis page

* Update changelog
2021-06-14 13:55:15 +02:00
7da6478699 Improve server start instructions (#156) 2021-06-12 22:29:56 +02:00
4f2bbba782 Release 1.14.0 (#154) 2021-06-09 20:36:31 +02:00
9eb25f6c9e Feature/connect or create logic for symbol profile (#153)
* Add connectOrCreate logic

* Extend seed

* Update changelog
2021-06-09 20:35:02 +02:00
f74b00446c Feature/improve world map chart (#152)
* Improve world map chart

* Update changelog
2021-06-09 20:32:39 +02:00
beb7e6ec34 Release 1.13.0 (#151) 2021-06-08 22:02:11 +02:00
2eafc042ad Feature/add world map (#150)
* Add a global heat map

* Update changelog
2021-06-08 21:59:46 +02:00
74954bc51d Release 1.12.0 (#149) 2021-06-06 15:33:20 +02:00
6a03120225 Feature/add symbol profile model (#148)
* Add symbol profile model and positions by country chart

* Add positions by continent chart

* Fix tests

* Extend seed

* Update changelog
2021-06-06 15:31:28 +02:00
21504573b4 Release 1.11.0 (#147) 2021-06-05 17:30:59 +02:00
fabd912fba Setup initial prisma migration (#146) 2021-06-05 17:20:52 +02:00
00b42855b6 Feature/upgrade prisma to 2.24.1 (#145)
* Upgrade prisma

* Update changelog

* Update database push script
2021-06-05 17:19:38 +02:00
ef272360fb Feature/render average prices in position detail chart (#144)
* Render average buy prices

* Update changelog
2021-06-05 17:17:53 +02:00
026a5011d4 Feature/add account registration page (#141)
* Add account registration page

* Update changelog
2021-06-05 17:16:07 +02:00
aa4206af0e Feature/various frontend improvements 2 (#140)
* Change buttons to links

* Update changelog
2021-06-05 17:11:03 +02:00
198 changed files with 7963 additions and 4078 deletions

View File

@ -5,6 +5,194 @@ 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.27.0 - 18.07.2021
### Changed
- Improved the onboarding
- Flow of creating a new account
- Info message to add the first transaction
### Fixed
- Fixed the chart on the landing page
- Fixed the url to the _Fear & Greed Index_ on the resources page
## 1.26.0 - 17.07.2021
### Added
- Added the import functionality for transactions
- Added the `robots.txt` file
### Changed
- Improved the styling of the current pricing plan
- Improved the styling of the transaction type badge
- Set the public _Stripe_ key dynamically
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
### Fixed
- Fixed the warn color (button) of the theme
## 1.25.0 - 11.07.2021
### Added
- Added the export functionality for transactions
### Changed
- Respected the cash balance on the analysis page
- Improved the settings selectors on the account page
- Harmonized the slogan to "Open Source Wealth Management Software"
### Fixed
- Fixed rendering of currency and platform in dialogs (account and transaction)
- Fixed an issue in the calculation of the average buy prices in the position detail chart
## 1.24.0 - 07.07.2021
### Added
- Added the total value in the create or edit transaction dialog
- Added a balance attribute to the account model
- Calculated the total balance (cash)
### Changed
- Upgraded `@angular/cdk` and `@angular/material` from version `11.0.4` to `12.0.6`
- Upgraded `@nestjs` dependencies
- Upgraded `angular-material-css-vars` from version `1.2.0` to `2.0.0`
- Upgraded `Nx` from version `12.3.6` to `12.5.4`
## 1.23.1 - 03.07.2021
### Fixed
- Fixed the investment chart (drafts)
## 1.23.0 - 03.07.2021
### Added
- Added support for future transactions (drafts)
## 1.22.0 - 25.06.2021
### Added
- Set the user id in the _Stripe_ callback
## 1.21.0 - 22.06.2021
### Changed
- Changed _Stripe_ mode from `subscription` to `payment`
### Fixed
- Fixed the base currency on the pricing page
## 1.20.0 - 21.06.2021
### Added
- Set up _Stripe_ for subscriptions
### Changed
- Improved the style of the _Ghostfolio in Numbers_ section
## 1.19.0 - 17.06.2021
### Added
- Added a _Ghostfolio in Numbers_ section to the about page
## 1.18.0 - 16.06.2021
### Changed
- Improved the pie chart: Investments by sector
- Improved the onboarding for TWA by redirecting to the account registration page
## 1.17.0 - 15.06.2021
### Changed
- Improved the error page of the sign in with fingerprint
- Disable the sign in with fingerprint selector for the demo user
- Upgraded `angular` from version `11.2.4` to `12.0.4`
- Upgraded `angular-material-css-vars` from version `1.1.2` to `1.2.0`
- Upgraded `chart.js` from version `3.2.1` to `3.3.2`
- Upgraded `date-fns` from version `2.19.0` to `2.22.1`
- Upgraded `eslint` and `prettier` dependencies
- Upgraded `ngx-device-detector` from version `2.0.6` to `2.1.1`
- Upgraded `ngx-markdown` from version `11.1.2` to `12.0.1`
## 1.16.0 - 14.06.2021
### Changed
- Improved the sign in with fingerprint
## 1.15.0 - 14.06.2021
### Added
- Added a counter column to the transactions table
- Added a label to indicate the default account in the accounts table
- Added an option to limit the items in pie charts
- Added sign in with fingerprint
### Changed
- Cleaned up the analysis page with an unused chart module
- Improved the cell alignment in the users table of the admin control panel
### Fixed
- Fixed the last activity column of users in the admin control panel
## 1.14.0 - 09.06.2021
### Added
- Added a connect or create symbol profile model logic on creating a new transaction
### Changed
- Improved the global heat map to visualize investments by country
## 1.13.0 - 08.06.2021
### Added
- Added a global heat map to visualize investments by country
## 1.12.0 - 06.06.2021
### Added
- Added a symbol profile model with additional data
- Added new pie charts: Investments by continent and country
## 1.11.0 - 05.06.2021
### Added
- Added a dedicated page for the account registration
- Rendered the average buy prices in the position detail chart (useful for recurring transactions)
- Introduced the initial prisma migration
### Changed
- Changed the buttons to links (`<a>`) on the tools page
- Upgraded `prisma` from version `2.20.1` to `2.24.1`
## 1.10.1 - 02.06.2021
### Fixed
@ -64,11 +252,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added an index in the user table of the admin control panel
- Added an index in the users table of the admin control panel
### Changed
- Improved the alignment in the user table of the admin control panel
- Improved the alignment in the users table of the admin control panel
## 1.5.0 - 22.05.2021
@ -200,7 +388,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the user table styling of the admin control panel
- Improved the users table styling of the admin control panel
- Improved the background colors in the dark mode
## 0.92.0 - 25.04.2021
@ -208,7 +396,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Prepared further for multi accounts support: store account for new transactions
- Added a horizontal scrollbar to the user table of the admin control panel
- Added a horizontal scrollbar to the users table of the admin control panel
### Fixed
@ -235,7 +423,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the user table of the admin control panel
- Improved the users table of the admin control panel
## 0.89.0 - 21.04.2021
@ -266,7 +454,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed an issue in the user table of the admin control panel with missing data
- Fixed an issue in the users table of the admin control panel with missing data
## 0.86.1 - 18.04.2021
@ -281,7 +469,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed the about page for the new license
- Optimized the data management for historical data
- Optimized the exchange rate service
- Improved the user table of the admin control panel
- Improved the users table of the admin control panel
### Fixed

View File

@ -1,21 +1,36 @@
<div align="center">
<a href="https://ghostfol.io">
<img
alt="Ghostfolio Logo"
src="https://avatars.githubusercontent.com/u/82473144?s=200"
width="100"
/>
</a>
<h1>Ghostfolio</h1>
<p>
<strong>Open Source Portfolio Tracker</strong>
<strong>Open Source Wealth Management Software made for Humans</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
</p>
<p>
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/>
<a href="https://travis-ci.org/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
<a href="#contributing">
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></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 portfolio tracker based on web technology. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
## Why Ghostfolio?
@ -79,26 +94,34 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
1. Start server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
1. Press _Sign out_ and check out the _Live Demo_
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
## Development
Please make sure you have completed the instructions from [_Setup_](#Setup)
Please make sure you have completed the instructions from [_Setup_](#Setup).
### Start server
- Debug: Run `yarn watch:server` and click "Launch Program" in _Visual Studio Code_
- Serve: Run `yarn start:server`
<ol type="a">
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <i>Visual Studio Code</i></li>
<li>Serve: Run <code>yarn start:server</code></li>
</ol>
### Start client
- Run `yarn start:client`
Run `yarn start:client`
## Testing
Run `yarn test`
## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
## License
© 2021 [Ghostfolio](https://ghostfol.io)

View File

@ -86,7 +86,6 @@
"main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json",
"aot": true,
"assets": [
"apps/client/src/assets",
{
@ -104,6 +103,11 @@
"input": "",
"output": "./"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",
@ -121,7 +125,13 @@
}
],
"styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/lib/marked.js"]
"scripts": ["node_modules/marked/lib/marked.js"],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"configurations": {
"production": {
@ -152,7 +162,8 @@
]
}
},
"outputs": ["{options.outputPath}"]
"outputs": ["{options.outputPath}"],
"defaultConfiguration": ""
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",

View File

@ -11,5 +11,6 @@ module.exports = {
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000
testTimeout: 10000,
testEnvironment: 'node'
};

View File

@ -4,6 +4,7 @@ import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alph
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
@ -20,6 +21,7 @@ import { AccountService } from './account.service';
AlphaVantageService,
ConfigurationService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
ImpersonationService,
PrismaService,

View File

@ -1,12 +1,15 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Account, Order, Prisma } from '@prisma/client';
import { Account, Currency, Order, Prisma } from '@prisma/client';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CashDetails } from './interfaces/cash-details.interface';
@Injectable()
export class AccountService {
public constructor(
private exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService,
private prisma: PrismaService
) {}
@ -73,6 +76,27 @@ export class AccountService {
});
}
public async getCashDetails(
aUserId: string,
aCurrency: Currency
): Promise<CashDetails> {
let totalCashBalance = 0;
const accounts = await this.accounts({
where: { userId: aUserId }
});
accounts.forEach((account) => {
totalCashBalance += this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
);
});
return { accounts, balance: totalCashBalance };
}
public async updateAccount(
params: {
where: Prisma.AccountWhereUniqueInput;

View File

@ -1,10 +1,16 @@
import { AccountType } from '@prisma/client';
import { IsString, ValidateIf } from 'class-validator';
import { AccountType, Currency } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateAccountDto {
@IsString()
accountType: AccountType;
@IsNumber()
balance: number;
@IsString()
currency: Currency;
@IsString()
name: string;

View File

@ -0,0 +1,6 @@
import { Account } from '@prisma/client';
export interface CashDetails {
accounts: Account[];
balance: number;
}

View File

@ -1,10 +1,16 @@
import { AccountType } from '@prisma/client';
import { IsString, ValidateIf } from 'class-validator';
import { AccountType, Currency } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class UpdateAccountDto {
@IsString()
accountType: AccountType;
@IsNumber()
balance: number;
@IsString()
currency: Currency;
@IsString()
id: string;

View File

@ -1,5 +1,6 @@
import { join } from 'path';
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
@ -22,10 +23,13 @@ import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module';
import { ExperimentalModule } from './experimental/experimental.module';
import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module';
import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module';
@ -34,10 +38,13 @@ import { UserModule } from './user/user.module';
AdminModule,
AccessModule,
AccountModule,
AuthDeviceModule,
AuthModule,
CacheModule,
ConfigModule.forRoot(),
ExperimentalModule,
ExportModule,
ImportModule,
InfoModule,
OrderModule,
PortfolioModule,
@ -57,6 +64,7 @@ import { UserModule } from './user/user.module';
rootPath: join(__dirname, '..', 'client'),
exclude: ['/api*']
}),
SubscriptionModule,
SymbolModule,
UserModule
],

View File

@ -0,0 +1,44 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('auth-device')
export class AuthDeviceController {
public constructor(
private readonly authDeviceService: AuthDeviceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteAuthDevice
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.authDeviceService.deleteAuthDevice({ id });
}
}

View File

@ -0,0 +1,4 @@
export interface AuthDeviceDto {
createdAt: string;
id: string;
}

View File

@ -0,0 +1,18 @@
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@Module({
controllers: [AuthDeviceController],
imports: [
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }
})
],
providers: [AuthDeviceService, ConfigurationService, PrismaService]
})
export class AuthDeviceModule {}

View File

@ -0,0 +1,65 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { AuthDevice, Prisma } from '@prisma/client';
@Injectable()
export class AuthDeviceService {
public constructor(
private readonly configurationService: ConfigurationService,
private prisma: PrismaService
) {}
public async authDevice(
where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice | null> {
return this.prisma.authDevice.findUnique({
where
});
}
public async authDevices(params: {
skip?: number;
take?: number;
cursor?: Prisma.AuthDeviceWhereUniqueInput;
where?: Prisma.AuthDeviceWhereInput;
orderBy?: Prisma.AuthDeviceOrderByInput;
}): Promise<AuthDevice[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prisma.authDevice.findMany({
skip,
take,
cursor,
where,
orderBy
});
}
public async createAuthDevice(
data: Prisma.AuthDeviceCreateInput
): Promise<AuthDevice> {
return this.prisma.authDevice.create({
data
});
}
public async updateAuthDevice(params: {
data: Prisma.AuthDeviceUpdateInput;
where: Prisma.AuthDeviceWhereUniqueInput;
}): Promise<AuthDevice> {
const { data, where } = params;
return this.prisma.authDevice.update({
data,
where
});
}
public async deleteAuthDevice(
where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice> {
return this.prisma.authDevice.delete({
where
});
}
}

View File

@ -1,9 +1,12 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import {
Body,
Controller,
Get,
HttpException,
Param,
Post,
Req,
Res,
UseGuards
@ -12,12 +15,17 @@ import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AuthService } from './auth.service';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Controller('auth')
export class AuthController {
public constructor(
private readonly authService: AuthService,
private readonly configurationService: ConfigurationService
private readonly configurationService: ConfigurationService,
private readonly webAuthService: WebAuthService
) {}
@Get('anonymous/:accessToken')
@ -53,4 +61,44 @@ export class AuthController {
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
}
}
@Get('webauthn/generate-attestation-options')
@UseGuards(AuthGuard('jwt'))
public async generateAttestationOptions() {
return this.webAuthService.generateAttestationOptions();
}
@Post('webauthn/verify-attestation')
@UseGuards(AuthGuard('jwt'))
public async verifyAttestation(
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
) {
return this.webAuthService.verifyAttestation(
body.deviceName,
body.credential
);
}
@Post('webauthn/generate-assertion-options')
public async generateAssertionOptions(@Body() body: { deviceId: string }) {
return this.webAuthService.generateAssertionOptions(body.deviceId);
}
@Post('webauthn/verify-assertion')
public async verifyAssertion(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON }
) {
try {
const authToken = await this.webAuthService.verifyAssertion(
body.deviceId,
body.credential
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
}

View File

@ -1,3 +1,5 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
@ -18,12 +20,14 @@ import { JwtStrategy } from './jwt.strategy';
})
],
providers: [
AuthDeviceService,
AuthService,
ConfigurationService,
GoogleStrategy,
JwtStrategy,
PrismaService,
UserService
UserService,
WebAuthService
]
})
export class AuthModule {}

View File

@ -1,5 +1,10 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { Provider } from '@prisma/client';
export interface AuthDeviceDialogParams {
authDevice: AuthDeviceDto;
}
export interface ValidateOAuthLoginParams {
provider: Provider;
thirdPartyId: string;

View File

@ -0,0 +1,226 @@
export interface AuthenticatorAssertionResponse extends AuthenticatorResponse {
readonly authenticatorData: ArrayBuffer;
readonly signature: ArrayBuffer;
readonly userHandle: ArrayBuffer | null;
}
export interface AuthenticatorAttestationResponse
extends AuthenticatorResponse {
readonly attestationObject: ArrayBuffer;
}
export interface AuthenticationExtensionsClientInputs {
appid?: string;
appidExclude?: string;
credProps?: boolean;
uvm?: boolean;
}
export interface AuthenticationExtensionsClientOutputs {
appid?: boolean;
credProps?: CredentialPropertiesOutput;
uvm?: UvmEntries;
}
export interface AuthenticatorSelectionCriteria {
authenticatorAttachment?: AuthenticatorAttachment;
requireResidentKey?: boolean;
residentKey?: ResidentKeyRequirement;
userVerification?: UserVerificationRequirement;
}
export interface PublicKeyCredential extends Credential {
readonly rawId: ArrayBuffer;
readonly response: AuthenticatorResponse;
getClientExtensionResults(): AuthenticationExtensionsClientOutputs;
}
export interface PublicKeyCredentialCreationOptions {
attestation?: AttestationConveyancePreference;
authenticatorSelection?: AuthenticatorSelectionCriteria;
challenge: BufferSource;
excludeCredentials?: PublicKeyCredentialDescriptor[];
extensions?: AuthenticationExtensionsClientInputs;
pubKeyCredParams: PublicKeyCredentialParameters[];
rp: PublicKeyCredentialRpEntity;
timeout?: number;
user: PublicKeyCredentialUserEntity;
}
export interface PublicKeyCredentialDescriptor {
id: BufferSource;
transports?: AuthenticatorTransport[];
type: PublicKeyCredentialType;
}
export interface PublicKeyCredentialParameters {
alg: COSEAlgorithmIdentifier;
type: PublicKeyCredentialType;
}
export interface PublicKeyCredentialRequestOptions {
allowCredentials?: PublicKeyCredentialDescriptor[];
challenge: BufferSource;
extensions?: AuthenticationExtensionsClientInputs;
rpId?: string;
timeout?: number;
userVerification?: UserVerificationRequirement;
}
export interface PublicKeyCredentialUserEntity
extends PublicKeyCredentialEntity {
displayName: string;
id: BufferSource;
}
export interface AuthenticatorResponse {
readonly clientDataJSON: ArrayBuffer;
}
export interface CredentialPropertiesOutput {
rk?: boolean;
}
export interface Credential {
readonly id: string;
readonly type: string;
}
export interface PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity {
id?: string;
}
export interface PublicKeyCredentialEntity {
name: string;
}
export declare type AttestationConveyancePreference =
| 'direct'
| 'enterprise'
| 'indirect'
| 'none';
export declare type AuthenticatorTransport = 'ble' | 'internal' | 'nfc' | 'usb';
export declare type COSEAlgorithmIdentifier = number;
export declare type UserVerificationRequirement =
| 'discouraged'
| 'preferred'
| 'required';
export declare type UvmEntries = UvmEntry[];
export declare type AuthenticatorAttachment = 'cross-platform' | 'platform';
export declare type ResidentKeyRequirement =
| 'discouraged'
| 'preferred'
| 'required';
export declare type BufferSource = ArrayBufferView | ArrayBuffer;
export declare type PublicKeyCredentialType = 'public-key';
export declare type UvmEntry = number[];
export interface PublicKeyCredentialCreationOptionsJSON
extends Omit<
PublicKeyCredentialCreationOptions,
'challenge' | 'user' | 'excludeCredentials'
> {
user: PublicKeyCredentialUserEntityJSON;
challenge: Base64URLString;
excludeCredentials: PublicKeyCredentialDescriptorJSON[];
extensions?: AuthenticationExtensionsClientInputs;
}
/**
* A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to
* (eventually) get passed into navigator.credentials.get(...) in the browser.
*/
export interface PublicKeyCredentialRequestOptionsJSON
extends Omit<
PublicKeyCredentialRequestOptions,
'challenge' | 'allowCredentials'
> {
challenge: Base64URLString;
allowCredentials?: PublicKeyCredentialDescriptorJSON[];
extensions?: AuthenticationExtensionsClientInputs;
}
export interface PublicKeyCredentialDescriptorJSON
extends Omit<PublicKeyCredentialDescriptor, 'id'> {
id: Base64URLString;
}
export interface PublicKeyCredentialUserEntityJSON
extends Omit<PublicKeyCredentialUserEntity, 'id'> {
id: string;
}
/**
* The value returned from navigator.credentials.create()
*/
export interface AttestationCredential extends PublicKeyCredential {
response: AuthenticatorAttestationResponseFuture;
}
/**
* A slightly-modified AttestationCredential to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AttestationCredentialJSON
extends Omit<
AttestationCredential,
'response' | 'rawId' | 'getClientExtensionResults'
> {
rawId: Base64URLString;
response: AuthenticatorAttestationResponseJSON;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
transports?: AuthenticatorTransport[];
}
/**
* The value returned from navigator.credentials.get()
*/
export interface AssertionCredential extends PublicKeyCredential {
response: AuthenticatorAssertionResponse;
}
/**
* A slightly-modified AssertionCredential to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AssertionCredentialJSON
extends Omit<
AssertionCredential,
'response' | 'rawId' | 'getClientExtensionResults'
> {
rawId: Base64URLString;
response: AuthenticatorAssertionResponseJSON;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
}
/**
* A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AuthenticatorAttestationResponseJSON
extends Omit<
AuthenticatorAttestationResponseFuture,
'clientDataJSON' | 'attestationObject'
> {
clientDataJSON: Base64URLString;
attestationObject: Base64URLString;
}
/**
* A slightly-modified AuthenticatorAssertionResponse to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AuthenticatorAssertionResponseJSON
extends Omit<
AuthenticatorAssertionResponse,
'authenticatorData' | 'clientDataJSON' | 'signature' | 'userHandle'
> {
authenticatorData: Base64URLString;
clientDataJSON: Base64URLString;
signature: Base64URLString;
userHandle?: string;
}
/**
* A WebAuthn-compatible device and the information needed to verify assertions by it
*/
export declare type AuthenticatorDevice = {
credentialPublicKey: Buffer;
credentialID: Buffer;
counter: number;
transports?: AuthenticatorTransport[];
};
/**
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
*/
export declare type Base64URLString = string;
/**
* AuthenticatorAttestationResponse in TypeScript's DOM lib is outdated (up through v3.9.7).
* Maintain an augmented version here so we can implement additional properties as the WebAuthn
* spec evolves.
*
* See https://www.w3.org/TR/webauthn-2/#iface-authenticatorattestationresponse
*
* Properties marked optional are not supported in all browsers.
*/
export interface AuthenticatorAttestationResponseFuture
extends AuthenticatorAttestationResponse {
getTransports?: () => AuthenticatorTransport[];
getAuthenticatorData?: () => ArrayBuffer;
getPublicKey?: () => ArrayBuffer;
getPublicKeyAlgorithm?: () => COSEAlgorithmIdentifier[];
}

View File

@ -0,0 +1,216 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Inject,
Injectable,
InternalServerErrorException
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import {
GenerateAssertionOptionsOpts,
GenerateAttestationOptionsOpts,
VerifiedAssertion,
VerifiedAttestation,
VerifyAssertionResponseOpts,
VerifyAttestationResponseOpts,
generateAssertionOptions,
generateAttestationOptions,
verifyAssertionResponse,
verifyAttestationResponse
} from '@simplewebauthn/server';
import { UserService } from '../user/user.service';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Injectable()
export class WebAuthService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly deviceService: AuthDeviceService,
private readonly jwtService: JwtService,
private readonly userService: UserService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
get rpID() {
return this.configurationService.get('WEB_AUTH_RP_ID');
}
get expectedOrigin() {
return this.configurationService.get('ROOT_URL');
}
public async generateAttestationOptions() {
const user = this.request.user;
const opts: GenerateAttestationOptionsOpts = {
rpName: 'Ghostfolio',
rpID: this.rpID,
userID: user.id,
userName: user.alias,
timeout: 60000,
attestationType: 'indirect',
authenticatorSelection: {
authenticatorAttachment: 'platform',
requireResidentKey: false,
userVerification: 'required'
}
};
const options = generateAttestationOptions(opts);
await this.userService.updateUser({
data: {
authChallenge: options.challenge
},
where: {
id: user.id
}
});
return options;
}
public async verifyAttestation(
deviceName: string,
credential: AttestationCredentialJSON
): Promise<AuthDeviceDto> {
const user = this.request.user;
const expectedChallenge = user.authChallenge;
let verification: VerifiedAttestation;
try {
const opts: VerifyAttestationResponseOpts = {
credential,
expectedChallenge,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID
};
verification = await verifyAttestationResponse(opts);
} catch (error) {
console.error(error);
throw new InternalServerErrorException(error.message);
}
const { verified, attestationInfo } = verification;
const devices = await this.deviceService.authDevices({
where: { userId: user.id }
});
if (verified && attestationInfo) {
const { credentialPublicKey, credentialID, counter } = attestationInfo;
let existingDevice = devices.find(
(device) => device.credentialId === credentialID
);
if (!existingDevice) {
/**
* Add the returned device to the user's list of devices
*/
existingDevice = await this.deviceService.createAuthDevice({
credentialPublicKey,
credentialId: credentialID,
counter,
User: { connect: { id: user.id } }
});
}
return {
createdAt: existingDevice.createdAt.toISOString(),
id: existingDevice.id
};
}
throw new InternalServerErrorException('An unknown error occurred');
}
public async generateAssertionOptions(deviceId: string) {
const device = await this.deviceService.authDevice({ id: deviceId });
if (!device) {
throw new Error('Device not found');
}
const opts: GenerateAssertionOptionsOpts = {
timeout: 60000,
allowCredentials: [
{
id: device.credentialId,
type: 'public-key',
transports: ['internal']
}
],
userVerification: 'preferred',
rpID: this.rpID
};
const options = generateAssertionOptions(opts);
await this.userService.updateUser({
data: {
authChallenge: options.challenge
},
where: {
id: device.userId
}
});
return options;
}
public async verifyAssertion(
deviceId: string,
credential: AssertionCredentialJSON
) {
const device = await this.deviceService.authDevice({ id: deviceId });
if (!device) {
throw new Error('Device not found');
}
const user = await this.userService.user({ id: device.userId });
let verification: VerifiedAssertion;
try {
const opts: VerifyAssertionResponseOpts = {
credential,
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID,
authenticator: {
credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey,
counter: device.counter
}
};
verification = verifyAssertionResponse(opts);
} catch (error) {
console.error(error);
throw new InternalServerErrorException({ error: error.message });
}
const { verified, assertionInfo } = verification;
if (verified) {
device.counter = assertionInfo.newCounter;
await this.deviceService.updateAuthDevice({
data: device,
where: { id: device.id }
});
return this.jwtService.sign({
id: user.id
});
}
throw new Error();
}
}

View File

@ -1,3 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
@ -13,9 +15,10 @@ import { ExperimentalController } from './experimental.controller';
import { ExperimentalService } from './experimental.service';
@Module({
imports: [],
imports: [RedisCacheModule],
controllers: [ExperimentalController],
providers: [
AccountService,
AlphaVantageService,
ConfigurationService,
DataProviderService,

View File

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
@ -14,6 +15,7 @@ import { Data } from './interfaces/data.interface';
@Injectable()
export class ExperimentalService {
public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private prisma: PrismaService,
@ -44,6 +46,7 @@ export class ExperimentalService {
fee: 0,
id: undefined,
platformId: undefined,
symbolProfileId: undefined,
type: Type.BUY,
updatedAt: undefined,
userId: undefined
@ -51,6 +54,7 @@ export class ExperimentalService {
});
const portfolio = new Portfolio(
this.accountService,
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService

View File

@ -0,0 +1,23 @@
import { Export } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { ExportService } from './export.service';
@Controller('export')
export class ExportController {
public constructor(
private readonly exportService: ExportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async export(): Promise<Export> {
return await this.exportService.export({
userId: this.request.user.id
});
}
}

View File

@ -0,0 +1,32 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [RedisCacheModule],
controllers: [ExportController],
providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
ExportService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class ExportModule {}

View File

@ -0,0 +1,31 @@
import { environment } from '@ghostfolio/api/environments/environment';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExportService {
public constructor(private prisma: PrismaService) {}
public async export({ userId }: { userId: string }): Promise<Export> {
const orders = await this.prisma.order.findMany({
orderBy: { date: 'desc' },
select: {
currency: true,
dataSource: true,
date: true,
fee: true,
quantity: true,
symbol: true,
type: true,
unitPrice: true
},
where: { userId }
});
return {
meta: { date: new Date().toISOString(), version: environment.version },
orders
};
}
}

View File

@ -0,0 +1,7 @@
import { Order } from '@prisma/client';
import { IsArray } from 'class-validator';
export class ImportDataDto {
@IsArray()
orders: Partial<Order>[];
}

View File

@ -0,0 +1,50 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
HttpException,
Inject,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ImportDataDto } from './import-data.dto';
import { ImportService } from './import.service';
@Controller('import')
export class ImportController {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Post()
@UseGuards(AuthGuard('jwt'))
public async import(@Body() importData: ImportDataDto): Promise<void> {
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
return await this.importService.import({
orders: importData.orders,
userId: this.request.user.id
});
} catch (error) {
console.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

View File

@ -0,0 +1,34 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ImportController } from './import.controller';
import { ImportService } from './import.service';
@Module({
imports: [RedisCacheModule],
controllers: [ImportController],
providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
ImportService,
OrderService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class ImportModule {}

View File

@ -0,0 +1,43 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client';
import { parseISO } from 'date-fns';
@Injectable()
export class ImportService {
public constructor(private readonly orderService: OrderService) {}
public async import({
orders,
userId
}: {
orders: Partial<Order>[];
userId: string;
}): Promise<void> {
for (const {
currency,
dataSource,
date,
fee,
quantity,
symbol,
type,
unitPrice
} of orders) {
await this.orderService.createOrder(
{
currency,
dataSource,
fee,
quantity,
symbol,
type,
unitPrice,
date: parseISO(<string>(<unknown>date)),
User: { connect: { id: userId } }
},
userId
);
}
}
}

View File

@ -1,10 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Currency } from '@prisma/client';
import * as bent from 'bent';
import { subDays } from 'date-fns';
@Injectable()
export class InfoService {
@ -17,6 +20,7 @@ export class InfoService {
) {}
public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
const platforms = await this.prisma.platform.findMany({
orderBy: { name: 'asc' },
select: { id: true, name: true }
@ -24,23 +28,83 @@ export class InfoService {
const globalPermissions: string[] = [];
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
globalPermissions.push(permissions.enableSocialLogin);
}
if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
globalPermissions.push(permissions.enableStatistics);
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
globalPermissions.push(permissions.enableSubscription);
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
}
return {
...info,
globalPermissions,
platforms,
currencies: Object.values(Currency),
demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering()
lastDataGathering: await this.getLastDataGathering(),
statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions()
};
}
private async countActiveUsers(aDays: number) {
return await this.prisma.user.count({
orderBy: {
Analytics: {
updatedAt: 'desc'
}
},
where: {
AND: [
{
NOT: {
Analytics: null
}
},
{
Analytics: {
updatedAt: {
gt: subDays(new Date(), aDays)
}
}
}
]
}
});
}
private async countGitHubStargazers(): Promise<number> {
try {
const get = bent(
`https://api.github.com/repos/ghostfolio/ghostfolio`,
'GET',
'json',
200,
{
'User-Agent': 'request'
}
);
const { stargazers_count } = await get();
return stargazers_count;
} catch (error) {
console.error(error);
return undefined;
}
}
private getDemoAuthToken() {
return this.jwtService.sign({
id: InfoService.DEMO_USER_ID
@ -54,4 +118,36 @@ export class InfoService {
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null;
}
private async getStatistics() {
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
return undefined;
}
const activeUsers1d = await this.countActiveUsers(1);
const activeUsers30d = await this.countActiveUsers(30);
const gitHubStargazers = await this.countGitHubStargazers();
return {
activeUsers1d,
activeUsers30d,
gitHubStargazers
};
}
private async getSubscriptions(): Promise<Subscription[]> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
const stripeConfig = await this.prisma.property.findUnique({
where: { key: 'STRIPE_CONFIG' }
});
if (stripeConfig) {
return [JSON.parse(stripeConfig.value)];
}
return [];
}
}

View File

@ -1,5 +1,5 @@
import { Currency, DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
import { IsISO8601, IsNumber, IsString } from 'class-validator';
export class CreateOrderDto {
@IsString()

View File

@ -68,10 +68,11 @@ export class OrderController {
public async getAllOrders(
@Headers('impersonation-id') impersonationId
): Promise<OrderModel[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let orders = await this.orderService.orders({
include: {
@ -132,12 +133,26 @@ export class OrderController {
return this.orderService.createOrder(
{
...data,
date,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
date,
SymbolProfile: {
connectOrCreate: {
where: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
}
}
},
User: { connect: { id: this.request.user.id } }
},
this.request.user.id

View File

@ -3,6 +3,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
import { CacheService } from '../cache/cache.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
@ -50,14 +51,16 @@ export class OrderService {
): Promise<Order> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
// Gather symbol data of order in the background
this.dataGatheringService.gatherSymbols([
{
dataSource: data.dataSource,
date: <Date>data.date,
symbol: data.symbol
}
]);
if (!isAfter(data.date as Date, endOfToday())) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.dataSource,
date: <Date>data.date,
symbol: data.symbol
}
]);
}
await this.cacheService.flush(aUserId);

View File

@ -142,10 +142,11 @@ export class PortfolioController {
): Promise<{ [symbol: string]: PortfolioPosition }> {
let details: { [symbol: string]: PortfolioPosition } = {};
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
@ -221,6 +222,7 @@ export class PortfolioController {
)
) {
overview = nullifyValuesInObject(overview, [
'cash',
'committedFunds',
'fees',
'totalBuy',
@ -238,10 +240,11 @@ export class PortfolioController {
@Query('range') range,
@Res() res: Response
): Promise<PortfolioPerformance> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
@ -306,10 +309,11 @@ export class PortfolioController {
public async getReport(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioReport> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id

View File

@ -1,3 +1,8 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
@ -11,10 +16,6 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { Module } from '@nestjs/common';
import { CacheService } from '../cache/cache.service';
import { OrderService } from '../order/order.service';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { UserService } from '../user/user.service';
import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service';
@ -22,6 +23,7 @@ import { PortfolioService } from './portfolio.service';
imports: [RedisCacheModule],
controllers: [PortfolioController],
providers: [
AccountService,
AlphaVantageService,
CacheService,
ConfigurationService,

View File

@ -1,3 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
@ -14,12 +18,15 @@ import { REQUEST } from '@nestjs/core';
import { DataSource } from '@prisma/client';
import {
add,
addMonths,
endOfToday,
format,
getDate,
getMonth,
getYear,
isAfter,
isSameDay,
parse,
parseISO,
setDate,
setMonth,
@ -28,9 +35,6 @@ import {
import { isEmpty } from 'lodash';
import * as roundTo from 'round-to';
import { OrderService } from '../order/order.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { UserService } from '../user/user.service';
import {
HistoricalDataItem,
PortfolioPositionDetail
@ -39,6 +43,7 @@ import {
@Injectable()
export class PortfolioService {
public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService,
@ -51,7 +56,7 @@ export class PortfolioService {
public async createPortfolio(aUserId: string): Promise<Portfolio> {
let portfolio: Portfolio;
let stringifiedPortfolio = await this.redisCacheService.get(
const stringifiedPortfolio = await this.redisCacheService.get(
`${aUserId}.portfolio`
);
@ -62,11 +67,11 @@ export class PortfolioService {
const {
orders,
portfolioItems
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } = JSON.parse(
stringifiedPortfolio
);
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } =
JSON.parse(stringifiedPortfolio);
portfolio = new Portfolio(
this.accountService,
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
@ -75,13 +80,15 @@ export class PortfolioService {
// Get portfolio from database
const orders = await this.orderService.orders({
include: {
Account: true
Account: true,
SymbolProfile: true
},
orderBy: { date: 'asc' },
where: { userId: aUserId }
});
portfolio = new Portfolio(
this.accountService,
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
@ -102,15 +109,21 @@ export class PortfolioService {
}
// Enrich portfolio with current data
return await portfolio.addCurrentPortfolioItems();
await portfolio.addCurrentPortfolioItems();
// Enrich portfolio with future data
await portfolio.addFuturePortfolioItems();
return portfolio;
}
public async findAll(aImpersonationId: string): Promise<PortfolioItem[]> {
try {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
@ -125,10 +138,11 @@ export class PortfolioService {
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
@ -146,6 +160,11 @@ export class PortfolioService {
return portfolio
.get()
.filter((portfolioItem) => {
if (isAfter(parseISO(portfolioItem.date), endOfToday())) {
// Filter out future dates
return false;
}
if (dateRangeDate === undefined) {
return true;
}
@ -168,21 +187,27 @@ export class PortfolioService {
public async getOverview(
aImpersonationId: string
): Promise<PortfolioOverview> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
const { balance } = await this.accountService.getCashDetails(
impersonationUserId || this.request.user.id,
this.request.user.Settings.currency
);
const committedFunds = portfolio.getCommittedFunds();
const fees = portfolio.getFees();
return {
committedFunds,
fees,
cash: balance,
ordersCount: portfolio.getOrders().length,
totalBuy: portfolio.getTotalBuy(),
totalSell: portfolio.getTotalSell()
@ -193,27 +218,29 @@ export class PortfolioService {
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
const positions = portfolio.getPositions(new Date())[aSymbol];
const position = portfolio.getPositions(new Date())[aSymbol];
if (positions) {
let {
if (position) {
const {
averagePrice,
currency,
firstBuyDate,
investment,
marketPrice,
quantity,
transactionCount
} = portfolio.getPositions(new Date())[aSymbol];
} = position;
let marketPrice = position.marketPrice;
const orders = portfolio.getOrders(aSymbol);
const historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
@ -227,6 +254,7 @@ export class PortfolioService {
}
const historicalDataArray: HistoricalDataItem[] = [];
let currentAveragePrice: number;
let maxPrice = marketPrice;
let minPrice = marketPrice;
@ -234,9 +262,25 @@ export class PortfolioService {
for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol]
)) {
const currentDate = parse(date, 'yyyy-MM-dd', new Date());
if (
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
isAfter(currentDate, parseISO(orders[0]?.getDate()))
) {
// Get snapshot of first day of next month
const snapshot = portfolio.get(
addMonths(setDate(currentDate, 1), 1)
)?.[0]?.positions[aSymbol];
orders.shift();
if (snapshot?.averagePrice) {
currentAveragePrice = snapshot.averagePrice;
}
}
historicalDataArray.push({
averagePrice,
date,
averagePrice: currentAveragePrice,
value: marketPrice
});
@ -298,7 +342,7 @@ export class PortfolioService {
const historicalDataArray: HistoricalDataItem[] = [];
for (const [date, { marketPrice, performance }] of Object.entries(
for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol]
).reverse()) {
historicalDataArray.push({
@ -309,13 +353,13 @@ export class PortfolioService {
return {
averagePrice: undefined,
currency: currentData[aSymbol].currency,
currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: historicalDataArray,
investment: undefined,
marketPrice: currentData[aSymbol].marketPrice,
marketPrice: currentData[aSymbol]?.marketPrice,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,

View File

@ -0,0 +1,57 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
HttpException,
Inject,
Post,
Req,
Res,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { SubscriptionService } from './subscription.service';
@Controller('subscription')
export class SubscriptionController {
public constructor(
private readonly configurationService: ConfigurationService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly subscriptionService: SubscriptionService
) {}
@Get('stripe/callback')
public async stripeCallback(@Req() req, @Res() res) {
await this.subscriptionService.createSubscription(
req.query.checkoutSessionId
);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
}
@Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt'))
public async createCheckoutSession(
@Body() { couponId, priceId }: { couponId: string; priceId: string }
) {
try {
return await this.subscriptionService.createCheckoutSession({
couponId,
priceId,
userId: this.request.user.id
});
} catch (error) {
console.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

View File

@ -0,0 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { SubscriptionController } from './subscription.controller';
import { SubscriptionService } from './subscription.service';
@Module({
imports: [],
controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService]
})
export class SubscriptionModule {}

View File

@ -0,0 +1,89 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { addDays } from 'date-fns';
import Stripe from 'stripe';
@Injectable()
export class SubscriptionService {
private stripe: Stripe;
public constructor(
private readonly configurationService: ConfigurationService,
private prisma: PrismaService
) {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2020-08-27'
}
);
}
public async createCheckoutSession({
couponId,
priceId,
userId
}: {
couponId?: string;
priceId: string;
userId: string;
}) {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/account`,
client_reference_id: userId,
line_items: [
{
price: priceId,
quantity: 1
}
],
mode: 'payment',
payment_method_types: ['card'],
success_url: `${this.configurationService.get(
'ROOT_URL'
)}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
};
if (couponId) {
checkoutSessionCreateParams.discounts = [
{
coupon: couponId
}
];
}
const session = await this.stripe.checkout.sessions.create(
checkoutSessionCreateParams
);
return {
sessionId: session.id
};
}
public async createSubscription(aCheckoutSessionId: string) {
try {
const session = await this.stripe.checkout.sessions.retrieve(
aCheckoutSessionId
);
await this.prisma.subscription.create({
data: {
expiresAt: addDays(new Date(), 365),
User: {
connect: {
id: session.client_reference_id
}
}
}
});
await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id
});
} catch (error) {
console.error(error);
}
}
}

View File

@ -0,0 +1,7 @@
import { Currency, ViewMode } from '@prisma/client';
export interface UserSettingsParams {
currency?: Currency;
userId: string;
viewMode?: ViewMode;
}

View File

@ -25,6 +25,7 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { UserItem } from './interfaces/user-item.interface';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UpdateUserSettingsDto } from './update-user-settings.dto';
import { UserService } from './user.service';
@ -92,10 +93,20 @@ export class UserController {
);
}
return await this.userService.updateUserSettings({
const userSettings: UserSettingsParams = {
currency: data.baseCurrency,
userId: this.request.user.id,
viewMode: data.viewMode
});
userId: this.request.user.id
};
if (
hasPermission(
getPermissions(this.request.user.role),
permissions.updateViewMode
)
) {
userSettings.viewMode = data.viewMode;
}
return await this.userService.updateUserSettings(userSettings);
}
}

View File

@ -1,13 +1,14 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { locale } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
import { add, isBefore } from 'date-fns';
import { isBefore } from 'date-fns';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
const crypto = require('crypto');
@ -24,7 +25,7 @@ export class UserService {
Account,
alias,
id,
role,
permissions,
Settings,
subscription
}: UserWithSettings): Promise<IUser> {
@ -36,15 +37,10 @@ export class UserService {
where: { GranteeUser: { id } }
});
const currentPermissions = getPermissions(role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
return {
alias,
id,
permissions,
subscription,
access: access.map((accessItem) => {
return {
@ -53,7 +49,6 @@ export class UserService {
};
}),
accounts: Account,
permissions: currentPermissions,
settings: {
locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
@ -72,6 +67,14 @@ export class UserService {
const user: UserWithSettings = userFromDatabase;
const currentPermissions = getPermissions(userFromDatabase.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
user.permissions = currentPermissions;
if (userFromDatabase?.Settings) {
if (!userFromDatabase.Settings.currency) {
// Set default currency if needed
@ -106,6 +109,13 @@ export class UserService {
type: SubscriptionType.Basic
};
}
if (user.subscription.type === SubscriptionType.Basic) {
user.permissions = user.permissions.filter((permission) => {
return permission !== permissions.updateViewMode;
});
user.Settings.viewMode = ViewMode.ZEN;
}
}
return user;
@ -213,11 +223,7 @@ export class UserService {
currency,
userId,
viewMode
}: {
currency?: Currency;
userId: string;
viewMode?: ViewMode;
}) {
}: UserSettingsParams) {
await this.prisma.settings.upsert({
create: {
currency,

View File

@ -1,3 +1,4 @@
export const environment = {
production: true
production: true,
version: `v${require('../../../../package.json').version}`
};

View File

@ -1,3 +1,4 @@
export const environment = {
production: false
production: false,
version: 'dev'
};

View File

@ -1,4 +1,5 @@
import { Account, Currency, Platform } from '@prisma/client';
import { Account, Currency, SymbolProfile } from '@prisma/client';
import { endOfToday, isAfter, parseISO } from 'date-fns';
import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces';
@ -12,6 +13,7 @@ export class Order {
private id: string;
private quantity: number;
private symbol: string;
private symbolProfile: SymbolProfile;
private total: number;
private type: OrderType;
private unitPrice: number;
@ -24,6 +26,7 @@ export class Order {
this.id = data.id || uuidv4();
this.quantity = data.quantity;
this.symbol = data.symbol;
this.symbolProfile = data.symbolProfile;
this.type = data.type;
this.unitPrice = data.unitPrice;
@ -50,6 +53,10 @@ export class Order {
return this.id;
}
public getIsDraft() {
return isAfter(parseISO(this.date), endOfToday());
}
public getQuantity() {
return this.quantity;
}
@ -58,6 +65,10 @@ export class Order {
return this.symbol;
}
getSymbolProfile() {
return this.symbolProfile;
}
public getTotal() {
return this.total;
}

View File

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { getUtc, getYesterday } from '@ghostfolio/common/helper';
import {
@ -16,6 +17,16 @@ import { MarketState } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { Portfolio } from './portfolio';
jest.mock('../app/account/account.service', () => {
return {
AccountService: jest.fn().mockImplementation(() => {
return {
getCashDetails: () => Promise.resolve({ accounts: [], balance: 0 })
};
})
};
});
jest.mock('../services/data-provider.service', () => {
return {
DataProviderService: jest.fn().mockImplementation(() => {
@ -81,12 +92,14 @@ const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
describe('Portfolio', () => {
let accountService: AccountService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolio: Portfolio;
let rulesService: RulesService;
beforeAll(async () => {
accountService = new AccountService(null, null, null);
dataProviderService = new DataProviderService(
null,
null,
@ -101,6 +114,7 @@ describe('Portfolio', () => {
await exchangeRateDataService.initialize();
portfolio = new Portfolio(
accountService,
dataProviderService,
exchangeRateDataService,
rulesService
@ -110,7 +124,9 @@ describe('Portfolio', () => {
Account: [
{
accountType: AccountType.SECURITIES,
balance: 0,
createdAt: new Date(),
currency: Currency.USD,
id: DEFAULT_ACCOUNT_ID,
isDefault: true,
name: 'Default Account',
@ -120,6 +136,7 @@ describe('Portfolio', () => {
}
],
alias: 'Test',
authChallenge: null,
createdAt: new Date(),
id: USER_ID,
provider: null,
@ -144,12 +161,52 @@ describe('Portfolio', () => {
it('should return empty details', async () => {
const details = await portfolio.getDetails('1d');
expect(details).toEqual({});
expect(details).toMatchObject({
_GF_CASH: {
accounts: {},
allocationCurrent: NaN, // TODO
allocationInvestment: NaN, // TODO
countries: [],
currency: 'CHF',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: 0,
marketPrice: 0,
marketState: 'open',
name: 'Cash',
quantity: 0,
sectors: [],
symbol: '_GF_CASH',
transactionCount: 0,
type: 'Cash',
value: 0
}
});
});
it('should return empty details', async () => {
const details = await portfolio.getDetails('max');
expect(details).toEqual({});
expect(details).toMatchObject({
_GF_CASH: {
accounts: {},
allocationCurrent: NaN, // TODO
allocationInvestment: NaN, // TODO
countries: [],
currency: 'CHF',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: 0,
marketPrice: 0,
marketState: 'open',
name: 'Cash',
quantity: 0,
sectors: [],
symbol: '_GF_CASH',
transactionCount: 0,
type: 'Cash',
value: 0
}
});
});
it('should return zero performance for 1d', async () => {
@ -189,6 +246,7 @@ describe('Portfolio', () => {
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
quantity: 1,
symbol: 'BTCUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 49631.24,
updatedAt: null,
@ -223,6 +281,7 @@ describe('Portfolio', () => {
},
allocationCurrent: 1,
allocationInvestment: 1,
countries: [],
currency: Currency.USD,
exchange: UNKNOWN_KEY,
grossPerformance: 0,
@ -272,7 +331,9 @@ describe('Portfolio', () => {
expect(portfolio.getPositions(getYesterday())).toMatchObject({});
expect(portfolio.getSymbols(getYesterday())).toEqual(['BTCUSD']);
expect(portfolio.getSymbols(getYesterday())).toEqual([]);
expect(portfolio.getSymbols(new Date())).toEqual(['BTCUSD']);
});
});
@ -290,6 +351,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
@ -305,16 +367,16 @@ describe('Portfolio', () => {
)
);
const details = await portfolio.getDetails('1d');
/*const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({
ETHUSD: {
accounts: {
[UNKNOWN_KEY]: {
/*current: exchangeRateDataService.toCurrency(
current: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),*/
),
original: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
@ -324,6 +386,7 @@ describe('Portfolio', () => {
},
// allocationCurrent: 1,
allocationInvestment: 1,
countries: [],
currency: Currency.USD,
exchange: UNKNOWN_KEY,
// grossPerformance: 0,
@ -340,7 +403,7 @@ describe('Portfolio', () => {
symbol: 'ETHUSD',
type: 'Cryptocurrency'
}
});
});*/
expect(portfolio.getFees()).toEqual(0);
@ -385,6 +448,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
@ -401,6 +465,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.3,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 1050,
updatedAt: null,
@ -461,6 +526,7 @@ describe('Portfolio', () => {
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
quantity: 0.05614682,
symbol: 'BTCUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 3562.089535970158,
updatedAt: null,
@ -477,6 +543,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
@ -550,6 +617,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
@ -566,6 +634,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.1,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.SELL,
unitPrice: 1050,
updatedAt: null,
@ -582,6 +651,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 1050,
updatedAt: null,

View File

@ -1,4 +1,6 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
import {
PortfolioItem,
@ -8,7 +10,11 @@ import {
Position,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import { Currency, Prisma } from '@prisma/client';
import { continents, countries } from 'countries-list';
import {
add,
format,
@ -30,7 +36,7 @@ import * as roundTo from 'round-to';
import { DataProviderService } from '../services/data-provider.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { IOrder } from '../services/interfaces/interfaces';
import { IOrder, MarketState, Type } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { PortfolioInterface } from './interfaces/portfolio.interface';
import { Order } from './order';
@ -50,6 +56,7 @@ export class Portfolio implements PortfolioInterface {
private user: UserWithSettings;
public constructor(
private accountService: AccountService,
private dataProviderService: DataProviderService,
private exchangeRateDataService: ExchangeRateDataService,
private rulesService: RulesService
@ -69,7 +76,7 @@ export class Portfolio implements PortfolioInterface {
const [portfolioItemsYesterday] = this.get(yesterday);
let positions: { [symbol: string]: Position } = {};
const positions: { [symbol: string]: Position } = {};
this.getSymbols().forEach((symbol) => {
positions[symbol] = {
@ -101,14 +108,45 @@ export class Portfolio implements PortfolioInterface {
);
// Set value after pushing today's portfolio items
this.portfolioItems[portfolioItemsLength - 1].value = this.getValue(
today
);
this.portfolioItems[portfolioItemsLength - 1].value =
this.getValue(today);
}
return this;
}
public async addFuturePortfolioItems() {
let investment = this.getInvestment(new Date());
this.getOrders()
.filter((order) => order.getIsDraft() === true)
.forEach((order) => {
investment += this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
const portfolioItem = this.portfolioItems.find((item) => {
return item.date === order.getDate();
});
if (portfolioItem) {
portfolioItem.investment = investment;
} else {
this.portfolioItems.push({
investment,
date: order.getDate(),
grossPerformancePercent: 0,
positions: {},
value: 0
});
}
});
return this;
}
public createFromData({
orders,
portfolioItems,
@ -127,6 +165,7 @@ export class Portfolio implements PortfolioInterface {
id,
quantity,
symbol,
symbolProfile,
type,
unitPrice
}) => {
@ -139,6 +178,7 @@ export class Portfolio implements PortfolioInterface {
id,
quantity,
symbol,
symbolProfile,
type,
unitPrice
})
@ -172,6 +212,8 @@ export class Portfolio implements PortfolioInterface {
if (filteredPortfolio) {
return [cloneDeep(filteredPortfolio)];
}
return [];
}
return cloneDeep(this.portfolioItems);
@ -193,10 +235,14 @@ export class Portfolio implements PortfolioInterface {
const [portfolioItemsNow] = await this.get(new Date());
const investment = this.getInvestment(new Date());
const cashDetails = await this.accountService.getCashDetails(
this.user.id,
this.user.Settings.currency
);
const investment = this.getInvestment(new Date()) + cashDetails.balance;
const portfolioItems = this.get(new Date());
const symbols = this.getSymbols(new Date());
const value = this.getValue();
const value = this.getValue() + cashDetails.balance;
const details: { [symbol: string]: PortfolioPosition } = {};
@ -204,6 +250,8 @@ export class Portfolio implements PortfolioInterface {
symbols.forEach((symbol) => {
const accounts: PortfolioPosition['accounts'] = {};
let countriesOfSymbol: Country[];
let sectorsOfSymbol: Sector[];
const [portfolioItem] = portfolioItems;
const ordersBySymbol = this.getOrders().filter((order) => {
@ -231,24 +279,48 @@ export class Portfolio implements PortfolioInterface {
if (
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current
) {
accounts[
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
].current += currentValueOfSymbol;
accounts[
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
].original += originalValueOfSymbol;
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].current +=
currentValueOfSymbol;
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].original +=
originalValueOfSymbol;
} else {
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = {
current: currentValueOfSymbol,
original: originalValueOfSymbol
};
}
countriesOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.countries as Prisma.JsonArray) ??
[]
).map((country) => {
const { code, weight } = country as Prisma.JsonObject;
return {
code: code as string,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY,
weight: weight as number
};
});
sectorsOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.sectors as Prisma.JsonArray) ?? []
).map((sector) => {
const { name, weight } = sector as Prisma.JsonObject;
return {
name: (name as string) ?? UNKNOWN_KEY,
weight: weight as number
};
});
});
let now = portfolioItemsNow.positions[symbol].marketPrice;
// 1d
let before = portfolioItemsBefore.positions[symbol].marketPrice;
let before = portfolioItemsBefore?.positions[symbol].marketPrice;
if (aDateRange === 'ytd') {
before =
@ -265,7 +337,7 @@ export class Portfolio implements PortfolioInterface {
if (
!isBefore(
parseISO(portfolioItemsNow.positions[symbol].firstBuyDate),
parseISO(portfolioItemsBefore.date)
parseISO(portfolioItemsBefore?.date)
)
) {
// Trade was not before the date of portfolioItemsBefore, then override it with average price
@ -289,6 +361,7 @@ export class Portfolio implements PortfolioInterface {
) / value,
allocationInvestment:
portfolioItem.positions[symbol].investment / investment,
countries: countriesOfSymbol,
grossPerformance: roundTo(
portfolioItemsNow.positions[symbol].quantity * (now - before),
2
@ -296,10 +369,22 @@ export class Portfolio implements PortfolioInterface {
grossPerformancePercent: roundTo((now - before) / before, 4),
investment: portfolioItem.positions[symbol].investment,
quantity: portfolioItem.positions[symbol].quantity,
transactionCount: portfolioItem.positions[symbol].transactionCount
sectors: sectorsOfSymbol,
transactionCount: portfolioItem.positions[symbol].transactionCount,
value: this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
)
};
});
details[ghostfolioCashSymbol] = await this.getCashPosition({
cashDetails,
investment,
value
});
return details;
}
@ -324,7 +409,11 @@ export class Portfolio implements PortfolioInterface {
}
public getMinDate() {
if (this.orders.length > 0) {
const orders = this.getOrders().filter(
(order) => order.getIsDraft() === false
);
if (orders.length > 0) {
return new Date(this.orders[0].getDate());
}
@ -451,9 +540,11 @@ export class Portfolio implements PortfolioInterface {
}
}
} else {
symbols = this.orders.map((order) => {
return order.getSymbol();
});
symbols = this.orders
.filter((order) => order.getIsDraft() === false)
.map((order) => {
return order.getSymbol();
});
}
// unique values
@ -462,7 +553,9 @@ export class Portfolio implements PortfolioInterface {
public getTotalBuy() {
return this.orders
.filter((order) => order.getType() === 'BUY')
.filter(
(order) => order.getIsDraft() === false && order.getType() === 'BUY'
)
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.getTotal(),
@ -475,7 +568,9 @@ export class Portfolio implements PortfolioInterface {
public getTotalSell() {
return this.orders
.filter((order) => order.getType() === 'SELL')
.filter(
(order) => order.getIsDraft() === false && order.getType() === 'SELL'
)
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.getTotal(),
@ -486,7 +581,13 @@ export class Portfolio implements PortfolioInterface {
.reduce((previous, current) => previous + current, 0);
}
public getOrders() {
public getOrders(aSymbol?: string) {
if (aSymbol) {
return this.orders.filter((order) => {
return order.getSymbol() === aSymbol;
});
}
return this.orders;
}
@ -538,6 +639,7 @@ export class Portfolio implements PortfolioInterface {
fee: order.fee,
quantity: order.quantity,
symbol: order.symbol,
symbolProfile: order.SymbolProfile,
type: <OrderType>order.type,
unitPrice: order.unitPrice
})
@ -555,6 +657,46 @@ export class Portfolio implements PortfolioInterface {
return this;
}
private async getCashPosition({
cashDetails,
investment,
value
}: {
cashDetails: CashDetails;
investment: number;
value: number;
}) {
const accounts = {};
const cashValue = cashDetails.balance;
cashDetails.accounts.forEach((account) => {
accounts[account.name] = {
current: account.balance,
original: account.balance
};
});
return {
accounts,
allocationCurrent: cashValue / value,
allocationInvestment: cashValue / investment,
countries: [],
currency: Currency.CHF,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: cashValue,
marketPrice: 0,
marketState: MarketState.open,
name: Type.Cash,
quantity: 0,
sectors: [],
symbol: ghostfolioCashSymbol,
type: Type.Cash,
transactionCount: 0,
value: cashValue
};
}
/**
* TODO: Refactor
*/
@ -638,10 +780,10 @@ export class Portfolio implements PortfolioInterface {
this.portfolioItems.push(
cloneDeep({
positions,
date: yesterday.toISOString(),
grossPerformancePercent: 0,
investment: 0,
positions: positions,
value: 0
})
);
@ -698,8 +840,6 @@ export class Portfolio implements PortfolioInterface {
}
private updatePortfolioItems() {
// console.time('update-portfolio-items');
let currentDate = new Date();
const year = getYear(currentDate);
@ -723,107 +863,99 @@ export class Portfolio implements PortfolioInterface {
}
this.orders.forEach((order) => {
let index = this.portfolioItems.findIndex((item) => {
const dateOfOrder = setDate(parseISO(order.getDate()), 1);
return isSameDay(parseISO(item.date), dateOfOrder);
});
if (order.getIsDraft() === false) {
let index = this.portfolioItems.findIndex((item) => {
const dateOfOrder = setDate(parseISO(order.getDate()), 1);
return isSameDay(parseISO(item.date), dateOfOrder);
});
if (index === -1) {
// if not found, we only have one order, which means we do not loop below
index = 0;
}
for (let i = index; i < this.portfolioItems.length; i++) {
// Set currency
this.portfolioItems[i].positions[
order.getSymbol()
].currency = order.getCurrency();
this.portfolioItems[i].positions[
order.getSymbol()
].transactionCount += 1;
if (order.getType() === 'BUY') {
if (
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate
) {
this.portfolioItems[i].positions[
order.getSymbol()
].firstBuyDate = resetHours(
parseISO(order.getDate())
).toISOString();
}
this.portfolioItems[i].positions[
order.getSymbol()
].quantity += order.getQuantity();
this.portfolioItems[i].positions[
order.getSymbol()
].investment += this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency += order.getTotal();
this.portfolioItems[
i
].investment += this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
} else if (order.getType() === 'SELL') {
this.portfolioItems[i].positions[
order.getSymbol()
].quantity -= order.getQuantity();
if (
this.portfolioItems[i].positions[order.getSymbol()].quantity === 0
) {
this.portfolioItems[i].positions[order.getSymbol()].investment = 0;
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency = 0;
} else {
this.portfolioItems[i].positions[
order.getSymbol()
].investment -= this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency -= order.getTotal();
}
this.portfolioItems[
i
].investment -= this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
if (index === -1) {
// if not found, we only have one order, which means we do not loop below
index = 0;
}
this.portfolioItems[i].positions[order.getSymbol()].averagePrice =
this.portfolioItems[i].positions[order.getSymbol()]
.investmentInOriginalCurrency /
this.portfolioItems[i].positions[order.getSymbol()].quantity;
for (let i = index; i < this.portfolioItems.length; i++) {
// Set currency
this.portfolioItems[i].positions[order.getSymbol()].currency =
order.getCurrency();
const currentValue = this.getValue(
parseISO(this.portfolioItems[i].date)
);
this.portfolioItems[i].positions[
order.getSymbol()
].transactionCount += 1;
this.portfolioItems[i].grossPerformancePercent =
currentValue / this.portfolioItems[i].investment - 1 || 0;
this.portfolioItems[i].value = currentValue;
if (order.getType() === 'BUY') {
if (
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate
) {
this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate =
resetHours(parseISO(order.getDate())).toISOString();
}
this.portfolioItems[i].positions[order.getSymbol()].quantity +=
order.getQuantity();
this.portfolioItems[i].positions[order.getSymbol()].investment +=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency += order.getTotal();
this.portfolioItems[i].investment +=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
} else if (order.getType() === 'SELL') {
this.portfolioItems[i].positions[order.getSymbol()].quantity -=
order.getQuantity();
if (
this.portfolioItems[i].positions[order.getSymbol()].quantity === 0
) {
this.portfolioItems[i].positions[
order.getSymbol()
].investment = 0;
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency = 0;
} else {
this.portfolioItems[i].positions[order.getSymbol()].investment -=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency -= order.getTotal();
}
this.portfolioItems[i].investment -=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
}
this.portfolioItems[i].positions[order.getSymbol()].averagePrice =
this.portfolioItems[i].positions[order.getSymbol()]
.investmentInOriginalCurrency /
this.portfolioItems[i].positions[order.getSymbol()].quantity;
const currentValue = this.getValue(
parseISO(this.portfolioItems[i].date)
);
this.portfolioItems[i].grossPerformancePercent =
currentValue / this.portfolioItems[i].investment - 1 || 0;
this.portfolioItems[i].value = currentValue;
}
}
});
// console.timeEnd('update-portfolio-items');
}
}

View File

@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { bool, cleanEnv, json, num, port, str } from 'envalid';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
import { environment } from '../environments/environment';
import { Environment } from './interfaces/environment.interface';
@Injectable()
@ -16,7 +17,9 @@ export class ConfigurationService {
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }),
@ -26,7 +29,10 @@ export class ConfigurationService {
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }),
REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' })
ROOT_URL: str({ default: 'http://localhost:4200' }),
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }),
WEB_AUTH_RP_ID: host({ default: 'localhost' })
});
}

View File

@ -8,6 +8,7 @@ import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import {
differenceInHours,
endOfToday,
format,
getDate,
getMonth,
@ -187,7 +188,8 @@ export class DataGatheringService {
public async getCustomSymbolsToGather(
startDate?: Date
): Promise<IDataGatheringItem[]> {
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations();
const scraperConfigurations =
await this.ghostfolioScraperApi.getScraperConfigurations();
return scraperConfigurations.map((scraperConfiguration) => {
return {
@ -224,7 +226,12 @@ export class DataGatheringService {
const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true }
select: { dataSource: true, symbol: true },
where: {
date: {
lt: endOfToday() // no draft
}
}
});
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
@ -280,7 +287,12 @@ export class DataGatheringService {
const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'],
orderBy: [{ date: 'asc' }],
select: { dataSource: true, date: true, symbol: true }
select: { dataSource: true, date: true, symbol: true },
where: {
date: {
lt: endOfToday() // no draft
}
}
});
return [

View File

@ -12,9 +12,7 @@ import { DataProviderInterface } from '../../interfaces/data-provider.interface'
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
Industry,
MarketState,
Sector,
Type
} from '../../interfaces/interfaces';
import {
@ -70,16 +68,6 @@ export class YahooFinanceService implements DataProviderInterface {
type: this.parseType(this.getType(symbol, value))
};
const industry = this.parseIndustry(value.summaryProfile?.industry);
if (industry) {
response[symbol].industry = industry;
}
const sector = this.parseSector(value.summaryProfile?.sector);
if (sector) {
response[symbol].sector = sector;
}
const url = value.summaryProfile?.website;
if (url) {
response[symbol].url = url;
@ -228,55 +216,6 @@ export class YahooFinanceService implements DataProviderInterface {
return aString;
}
private parseIndustry(aString: string): Industry {
if (aString === undefined) {
return undefined;
}
if (aString?.toLowerCase() === 'auto manufacturers') {
return Industry.Automotive;
} else if (aString?.toLowerCase() === 'biotechnology') {
return Industry.Biotechnology;
} else if (
aString?.toLowerCase() === 'drug manufacturers—specialty & generic'
) {
return Industry.Pharmaceutical;
} else if (
aString?.toLowerCase() === 'internet content & information' ||
aString?.toLowerCase() === 'internet retail'
) {
return Industry.Internet;
} else if (aString?.toLowerCase() === 'packaged foods') {
return Industry.Food;
} else if (aString?.toLowerCase() === 'software—application') {
return Industry.Software;
}
return Industry.Unknown;
}
private parseSector(aString: string): Sector {
if (aString === undefined) {
return undefined;
}
if (
aString?.toLowerCase() === 'consumer cyclical' ||
aString?.toLowerCase() === 'consumer defensive'
) {
return Sector.Consumer;
} else if (aString?.toLowerCase() === 'healthcare') {
return Sector.Healthcare;
} else if (
aString?.toLowerCase() === 'communication services' ||
aString?.toLowerCase() === 'technology'
) {
return Sector.Technology;
}
return Sector.Unknown;
}
private parseType(aString: string): Type {
if (aString?.toLowerCase() === 'cryptocurrency') {
return Type.Cryptocurrency;
@ -291,6 +230,6 @@ export class YahooFinanceService implements DataProviderInterface {
}
export const convertFromYahooSymbol = (aSymbol: string) => {
let symbol = aSymbol.replace('-', '');
const symbol = aSymbol.replace('-', '');
return symbol.replace('=X', '');
};

View File

@ -7,7 +7,9 @@ export interface Environment extends CleanedEnvAccessors {
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_IMPORT: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean;
GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string;
@ -18,4 +20,7 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_HOST: string;
REDIS_PORT: number;
ROOT_URL: string;
STRIPE_PUBLIC_KEY: string;
STRIPE_SECRET_KEY: string;
WEB_AUTH_RP_ID: string;
}

View File

@ -1,32 +1,16 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Account, Currency, DataSource } from '@prisma/client';
import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
import { OrderType } from '../../models/order-type';
export const Industry = {
Automotive: 'Automotive',
Biotechnology: 'Biotechnology',
Food: 'Food',
Internet: 'Internet',
Pharmaceutical: 'Pharmaceutical',
Software: 'Software',
Unknown: UNKNOWN_KEY
};
export const MarketState = {
closed: 'closed',
delayed: 'delayed',
open: 'open'
};
export const Sector = {
Consumer: 'Consumer',
Healthcare: 'Healthcare',
Technology: 'Technology',
Unknown: UNKNOWN_KEY
};
export const Type = {
Cash: 'Cash',
Cryptocurrency: 'Cryptocurrency',
ETF: 'ETF',
Stock: 'Stock',
@ -41,6 +25,7 @@ export interface IOrder {
id?: string;
quantity: number;
symbol: string;
symbolProfile: SymbolProfile;
type: OrderType;
unitPrice: number;
}
@ -54,13 +39,11 @@ export interface IDataProviderResponse {
currency: Currency;
dataSource: DataSource;
exchange?: string;
industry?: Industry;
marketChange?: number;
marketChangePercent?: number;
marketPrice: number;
marketState: MarketState;
name: string;
sector?: Sector;
type?: Type;
url?: string;
}
@ -71,10 +54,6 @@ export interface IDataGatheringItem {
symbol: string;
}
export type Industry = typeof Industry[keyof typeof Industry];
export type MarketState = typeof MarketState[keyof typeof MarketState];
export type Sector = typeof Sector[keyof typeof Sector];
export type Type = typeof Type[keyof typeof Type];

View File

@ -5,19 +5,14 @@ module.exports = {
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
astTransformers: {
before: [
'jest-preset-angular/build/InlineFilesTransformer',
'jest-preset-angular/build/StripStylesTransformer'
]
}
stringifyContentPathRegex: '\\.(html|svg)$'
}
},
coverageDirectory: '../../coverage/apps/client',
snapshotSerializers: [
'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js',
'jest-preset-angular/build/AngularSnapshotSerializer.js',
'jest-preset-angular/build/HTMLCommentSerializer.js'
]
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment'
],
transform: { '^.+\\.(ts|js|html)$': 'jest-preset-angular' }
};

View File

@ -45,6 +45,13 @@ const routes: Routes = [
(m) => m.PricingPageModule
)
},
{
path: 'register',
loadChildren: () =>
import('./pages/register/register-page.module').then(
(m) => m.RegisterPageModule
)
},
{
path: 'resources',
loadChildren: () =>
@ -55,7 +62,9 @@ const routes: Routes = [
{
path: 'start',
loadChildren: () =>
import('./pages/login/login-page.module').then((m) => m.LoginPageModule)
import('./pages/landing/landing-page.module').then(
(m) => m.LandingPageModule
)
},
{
path: 'tools',
@ -83,6 +92,13 @@ const routes: Routes = [
(m) => m.TransactionsPageModule
)
},
{
path: 'webauthn',
loadChildren: () =>
import('./pages/webauthn/webauthn-page.module').then(
(m) => m.WebauthnPageModule
)
},
{
path: 'zen',
loadChildren: () =>

View File

@ -12,13 +12,15 @@
<div *ngIf="canCreateAccount" class="container create-account-container">
<div class="row mb-5">
<div class="col-md-6 offset-md-3">
<div
class="create-account-box p-2 text-center"
(click)="onCreateAccount()"
<a [routerLink]="['/']">
<mat-card
class="create-account-box p-2 text-center"
(click)="onCreateAccount()"
>
<div class="mt-1" i18n>You are using the Live Demo.</div>
<button mat-button color="primary" i18n>Create Account</button>
</mat-card></a
>
<div class="mt-1" i18n>You are using the Live Demo.</div>
<button mat-button color="primary" i18n>Create Account</button>
</div>
</div>
</div>
</div>

View File

@ -5,14 +5,8 @@
padding: 5rem 0;
.create-account-box {
border: 1px solid rgba(var(--palette-primary-500), 1);
border-radius: 0.25rem;
cursor: pointer;
font-size: 90%;
.link {
color: rgba(var(--palette-primary-500), 1);
}
}
}

View File

@ -6,7 +6,11 @@ import {
OnInit
} from '@angular/core';
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config';
import {
primaryColorHex,
secondaryColorHex,
warnColorHex
} from '@ghostfolio/common/config';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { MaterialCssVarsService } from 'angular-material-css-vars';
@ -52,10 +56,6 @@ export class AppComponent implements OnDestroy, OnInit {
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService.fetchInfo().subscribe((info) => {
this.info = info;
});
this.router.events
.pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => {
@ -63,22 +63,19 @@ export class AppComponent implements OnDestroy, OnInit {
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path;
this.info = this.dataService.fetchInfo();
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.user = state.user;
this.canCreateAccount = hasPermission(
this.user.permissions,
permissions.createUserAccount
);
} else if (!this.tokenStorageService.getToken()) {
// User has not been logged in
this.user = null;
}
this.canCreateAccount = hasPermission(
this.user?.permissions,
permissions.createUserAccount
);
this.changeDetectorRef.markForCheck();
});
@ -86,7 +83,6 @@ export class AppComponent implements OnDestroy, OnInit {
public onCreateAccount() {
this.tokenStorageService.signOut();
window.location.reload();
}
public onSignOut() {
@ -112,5 +108,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
this.materialCssVarsService.setAccentColor(secondaryColorHex);
this.materialCssVarsService.setWarnColor(warnColorHex);
}
}

View File

@ -2,6 +2,7 @@ import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import {
DateAdapter,
MAT_DATE_FORMATS,
@ -14,7 +15,9 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MaterialCssVarsModule } from 'angular-material-css-vars';
import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe';
import { environment } from '../environments/environment';
import { CustomDateAdapter } from './adapter/custom-date-adapter';
import { DateFormats } from './adapter/date-formats';
import { AppRoutingModule } from './app-routing.module';
@ -24,6 +27,10 @@ import { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
import { LanguageService } from './core/language.service';
export function NgxStripeFactory(): string {
return environment.stripePublicKey;
}
@NgModule({
declarations: [AppComponent],
imports: [
@ -34,6 +41,7 @@ import { LanguageService } from './core/language.service';
HttpClientModule,
MarkdownModule.forRoot(),
MatButtonModule,
MatCardModule,
MaterialCssVarsModule.forRoot({
darkThemeClass: 'is-dark-theme',
isAutoContrast: true,
@ -41,7 +49,8 @@ import { LanguageService } from './core/language.service';
}),
MatNativeDateModule,
MatSnackBarModule,
NgxSkeletonLoaderModule
NgxSkeletonLoaderModule,
NgxStripeModule.forRoot(environment.stripePublicKey)
],
providers: [
authInterceptorProviders,
@ -52,7 +61,11 @@ import { LanguageService } from './core/language.service';
useClass: CustomDateAdapter,
deps: [LanguageService, MAT_DATE_LOCALE, Platform]
},
{ provide: MAT_DATE_FORMATS, useValue: DateFormats }
{ provide: MAT_DATE_FORMATS, useValue: DateFormats },
{
provide: STRIPE_PUBLISHABLE_KEY,
useFactory: NgxStripeFactory
}
],
bootstrap: [AppComponent]
})

View File

@ -3,6 +3,11 @@
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }}
<span
*ngIf="element.isDefault"
class="d-lg-inline-block d-none text-muted"
>(Default)</span
>
</td>
</ng-container>
@ -21,6 +26,27 @@
</td>
</ng-container>
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Transactions
</th>
<td *matCellDef="let element" class="text-right" mat-cell>
{{ element.Order?.length }}
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>Balance</th>
<td *matCellDef="let element" class="text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[currency]="element.currency"
[locale]="locale"
[value]="element.balance"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
@ -48,13 +74,6 @@
</td>
</ng-container>
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef i18n mat-header-cell>Transactions</th>
<td *matCellDef="let element" mat-cell>
{{ element.Order?.length }}
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>

View File

@ -28,7 +28,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>();
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
public dataSource: MatTableDataSource<AccountModel> =
new MatTableDataSource();
public displayedColumns = [];
public isLoading = true;
public routeQueryParams: Subscription;
@ -40,7 +41,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = ['account', 'platform', 'transactions'];
this.displayedColumns = ['account', 'platform', 'transactions', 'balance'];
if (this.showActions) {
this.displayedColumns.push('actions');

View File

@ -8,9 +8,11 @@
class="d-none d-sm-block"
i18n
mat-flat-button
[color]="
currentRoute === 'home' || currentRoute === 'zen' ? 'primary' : null
"
[ngClass]="{
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen',
'text-decoration-underline':
currentRoute === 'home' || currentRoute === 'zen'
}"
[routerLink]="['/']"
>Overview</a
>
@ -19,13 +21,16 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools'
? 'primary'
: null
"
[ngClass]="{
'font-weight-bold':
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools',
'text-decoration-underline':
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools'
}"
[routerLink]="['/tools']"
>Tools</a
>
@ -33,7 +38,10 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'transactions' ? 'primary' : null"
[ngClass]="{
'font-weight-bold': currentRoute === 'transactions',
'text-decoration-underline': currentRoute === 'transactions'
}"
[routerLink]="['/transactions']"
>Transactions</a
>
@ -41,7 +49,10 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'accounts' ? 'primary' : null"
[ngClass]="{
'font-weight-bold': currentRoute === 'accounts',
'text-decoration-underline': currentRoute === 'accounts'
}"
[routerLink]="['/accounts']"
>Accounts</a
>
@ -50,7 +61,10 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'admin' ? 'primary' : null"
[ngClass]="{
'font-weight-bold': currentRoute === 'admin',
'text-decoration-underline': currentRoute === 'admin'
}"
[routerLink]="['/admin']"
>Admin Control</a
>
@ -58,7 +72,10 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'resources' ? 'primary' : null"
[ngClass]="{
'font-weight-bold': currentRoute === 'resources',
'text-decoration-underline': currentRoute === 'resources'
}"
[routerLink]="['/resources']"
>Resources</a
>
@ -67,7 +84,10 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'pricing' ? 'primary' : null"
[ngClass]="{
'font-weight-bold': currentRoute === 'pricing',
'text-decoration-underline': currentRoute === 'pricing'
}"
[routerLink]="['/pricing']"
>Pricing</a
>
@ -75,7 +95,10 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'about' ? 'primary' : null"
[ngClass]="{
'font-weight-bold': currentRoute === 'about',
'text-decoration-underline': currentRoute === 'about'
}"
[routerLink]="['/about']"
>About</a
>
@ -226,28 +249,44 @@
<gf-logo></gf-logo>
</a>
<span class="spacer"></span>
<a
*ngIf="hasPermissionForSubscription"
i18n
mat-flat-button
[color]="currentRoute === 'pricing' ? 'primary' : null"
[routerLink]="['/pricing']"
>Pricing</a
>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'about' ? 'primary' : null"
[ngClass]="{
'font-weight-bold': currentRoute === 'about',
'text-decoration-underline': currentRoute === 'about'
}"
[routerLink]="['/about']"
>About</a
>
<a
class="d-none d-sm-block mx-1"
*ngIf="hasPermissionForSubscription"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'pricing',
'text-decoration-underline': currentRoute === 'pricing'
}"
[routerLink]="['/pricing']"
>Pricing</a
>
<a
class="d-none d-sm-block mx-1 no-min-width px-1"
href="https://github.com/ghostfolio/ghostfolio"
mat-flat-button
>GitHub</a
>
<button i18n mat-flat-button (click)="openLoginDialog()">Sign in</button>
><ion-icon name="logo-github"></ion-icon
></a>
<button class="mx-1" i18n mat-flat-button (click)="openLoginDialog()">
Sign In
</button>
<a
class="d-none d-sm-block"
color="primary"
i18n
mat-flat-button
[routerLink]="['/register']"
>Get Started
</a>
</ng-container>
</mat-toolbar>

View File

@ -5,10 +5,7 @@
z-index: 999;
.mat-toolbar {
background-color: rgba(
var(--light-primary-text),
var(--palette-foreground-disabled-alpha)
);
background-color: rgba(var(--light-disabled-text));
.spacer {
flex: 1 1 auto;
@ -28,11 +25,6 @@
:host-context(.is-dark-theme) {
.mat-toolbar {
background-color: rgba(
39,
39,
39,
var(--palette-foreground-disabled-alpha)
);
background-color: rgba(39, 39, 39, $alpha-disabled-text);
}
}

View File

@ -8,9 +8,13 @@ import {
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -42,10 +46,12 @@ export class HeaderComponent implements OnChanges {
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService
) {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((id) => {
this.impersonationId = id;
});
@ -87,33 +93,45 @@ export class HeaderComponent implements OnChanges {
autoFocus: false,
data: {
accessToken: '',
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin,
title: 'Sign in'
},
width: '30rem'
});
dialogRef.afterClosed().subscribe((data) => {
if (data?.accessToken) {
this.dataService
.loginAnonymous(data?.accessToken)
.pipe(
catchError(() => {
alert('Oops! Incorrect Security Token.');
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
if (data?.accessToken) {
this.dataService
.loginAnonymous(data?.accessToken)
.pipe(
catchError(() => {
alert('Oops! Incorrect Security Token.');
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ authToken }) => {
this.setToken(authToken);
});
}
});
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ authToken }) => {
this.setToken(authToken);
});
}
});
}
public setToken(aToken: string) {
this.tokenStorageService.saveToken(aToken);
this.tokenStorageService.saveToken(
aToken,
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
);
this.router.navigate(['/']);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -4,9 +4,9 @@ import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.module';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { GfLogoModule } from '../logo/logo.module';
import { HeaderComponent } from './header.component';
@NgModule({

View File

@ -2,12 +2,12 @@
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '12rem',
height: '100%',
width: '100%'
}"
></ngx-skeleton-loader>
<canvas
#chartCanvas
height="50"
class="h-100"
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas>

View File

@ -19,6 +19,7 @@ import {
TimeScale
} from 'chart.js';
import { Chart } from 'chart.js';
import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
@Component({
selector: 'gf-investment-chart',
@ -52,9 +53,30 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
}
}
public ngOnDestroy() {
this.chart?.destroy();
}
private initialize() {
this.isLoading = true;
if (this.portfolioItems?.length > 0) {
// Extend chart by three months (before)
const firstItem = this.portfolioItems[0];
this.portfolioItems.unshift({
...firstItem,
date: subMonths(parseISO(firstItem.date), 3).toISOString(),
investment: 0
});
// Extend chart by three months (after)
const lastItem = this.portfolioItems[this.portfolioItems.length - 1];
this.portfolioItems.push({
...lastItem,
date: addMonths(parseISO(lastItem.date), 3).toISOString()
});
}
const data = {
labels: this.portfolioItems.map((position) => {
return position.date;
@ -65,7 +87,16 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
borderWidth: 2,
data: this.portfolioItems.map((position) => {
return position.investment;
})
}),
segment: {
borderColor: (context: unknown) =>
this.isInFuture(
context,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.67)`
),
borderDash: (context: unknown) => this.isInFuture(context, [2, 2])
},
stepped: true
}
]
};
@ -123,7 +154,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
}
}
public ngOnDestroy() {
this.chart?.destroy();
private isInFuture(aContext: any, aValue: any) {
return isAfter(new Date(aContext?.p0?.parsed?.x), new Date())
? aValue
: undefined;
}
}

View File

@ -2,6 +2,7 @@ import 'chartjs-adapter-date-fns';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnChanges,
@ -44,7 +45,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
public chart: Chart;
public isLoading = true;
public constructor() {
public constructor(private changeDetectorRef: ChangeDetectorRef) {
Chart.register(
Filler,
LineController,
@ -59,7 +60,12 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
public ngOnChanges() {
if (this.historicalDataItems) {
this.initialize();
setTimeout(() => {
// Wait for the chartCanvas
this.initialize();
this.changeDetectorRef.markForCheck();
});
}
}
@ -79,8 +85,6 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
marketPrices.push(historicalDataItem.value);
});
const canvas = document.getElementById('chartCanvas');
const gradient = this.chartCanvas?.nativeElement
?.getContext('2d')
.createLinearGradient(
@ -89,11 +93,14 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
0,
(this.chartCanvas.nativeElement.parentNode.offsetHeight * 4) / 5
);
gradient.addColorStop(
0,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
);
gradient.addColorStop(1, getBackgroundColor());
if (gradient) {
gradient.addColorStop(
0,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
);
gradient.addColorStop(1, getBackgroundColor());
}
const data = {
labels,

View File

@ -0,0 +1,34 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
@Component({
selector: 'gf-login-with-access-token-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./login-with-access-token-dialog.scss'],
templateUrl: 'login-with-access-token-dialog.html'
})
export class LoginWithAccessTokenDialog {
public constructor(
@Inject(MAT_DIALOG_DATA) public data: any,
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
private settingsStorageService: SettingsStorageService
) {}
ngOnInit() {}
public onChangeStaySignedIn(aValue: MatCheckboxChange) {
this.settingsStorageService.setSetting(
STAY_SIGNED_IN,
aValue.checked?.toString()
);
}
public onClose() {
this.dialogRef.close();
}
}

View File

@ -1,4 +1,9 @@
<h1 mat-dialog-title i18n>Sign in</h1>
<gf-dialog-header
mat-dialog-title
[title]="data.title"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
<div mat-dialog-content>
<div>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
@ -21,15 +26,21 @@
</mat-form-field>
</div>
</div>
<div class="float-right" mat-dialog-actions>
<button i18n mat-flat-button [mat-dialog-close]="undefined">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!data.accessToken"
[mat-dialog-close]="data"
>
Sign in
</button>
<div mat-dialog-actions>
<div class="flex-grow-1">
<mat-checkbox i18n (change)="onChangeStaySignedIn($event)"
>Stay signed in</mat-checkbox
>
</div>
<div>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!data.accessToken"
[mat-dialog-close]="data"
>
Sign in
</button>
</div>
</div>

View File

@ -3,10 +3,12 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component';
@NgModule({
@ -15,7 +17,9 @@ import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.com
imports: [
CommonModule,
FormsModule,
GfDialogHeaderModule,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,

View File

@ -0,0 +1,15 @@
:host {
display: block;
textarea.mat-input-element.cdk-textarea-autosize {
box-sizing: content-box;
}
.mat-checkbox {
::ng-deep {
label {
margin-bottom: 0;
}
}
}
}

View File

@ -1,4 +1,4 @@
<span class="align-items-center d-flex"
><span class="d-inline-block logo mr-1"></span>
<span class="name">Ghostfolio</span></span
<span *ngIf="!hideName" class="name">Ghostfolio</span></span
>

View File

@ -14,10 +14,12 @@ import {
})
export class LogoComponent implements OnInit {
@HostBinding('class') @Input() size: 'large' | 'medium';
@Input() hideName: boolean;
public constructor() {}
public ngOnInit() {
this.size = this.size || 'medium';
this.hideName = this.hideName ?? false;
this.size = this.size ?? 'medium';
}
}

View File

@ -1,9 +1,13 @@
<a
class="align-items-center justify-content-center"
color="primary"
[routerLink]="['/transactions']"
mat-button
>
<ion-icon class="mr-1" name="time-outline" size="large"></ion-icon>
<span i18n>Time to add your first transaction.</span>
</a>
<div class="p-3">
<div class="d-flex justify-content-center mb-1">
<gf-logo size="large" [hideName]="true"></gf-logo>
</div>
<a
class="align-items-center justify-content-center"
color="primary"
[routerLink]="['/transactions']"
mat-button
>
<span i18n>Time to add your first transaction.</span>
</a>
</div>

View File

@ -1,3 +1,13 @@
:host {
border: 1px solid rgba(var(--dark-dividers));
border-radius: 0.25rem;
display: block;
gf-logo {
opacity: 0.25;
}
}
:host-context(.is-dark-theme) {
border-color: rgba(var(--light-dividers));
}

View File

@ -2,13 +2,14 @@ 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 { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { NoTransactionsInfoComponent } from './no-transactions-info.component';
@NgModule({
declarations: [NoTransactionsInfoComponent],
exports: [NoTransactionsInfoComponent],
imports: [CommonModule, MatButtonModule, RouterModule],
imports: [CommonModule, GfLogoModule, MatButtonModule, RouterModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

View File

@ -7,6 +7,8 @@ import {
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { isToday, parse } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { LineChartItem } from '../line-chart/interfaces/line-chart.interface';
import { PositionDetailDialogParams } from './interfaces/interfaces';
@ -27,6 +29,8 @@ export class PerformanceChartDialog {
public historicalDataItems: LineChartItem[];
public title: string;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -35,6 +39,7 @@ export class PerformanceChartDialog {
) {
this.dataService
.fetchPositionDetail(this.benchmarkSymbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currency, firstBuyDate, historicalData, marketPrice }) => {
this.benchmarkDataItems = [];
this.currency = currency;
@ -84,4 +89,9 @@ export class PerformanceChartDialog {
public onClose(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -1,4 +1,18 @@
<div class="container p-0">
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Cash</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.cash"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Buy</div>
<div class="d-flex justify-content-end">

View File

@ -1,158 +0,0 @@
// import 'chartjs-chart-timeline';
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit
} from '@angular/core';
import { PortfolioItem } from '@ghostfolio/common/interfaces';
import { endOfDay, parseISO, startOfDay } from 'date-fns';
@Component({
selector: 'gf-portfolio-positions-chart',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-positions-chart.component.html',
styleUrls: ['./portfolio-positions-chart.component.scss']
})
export class PortfolioPositionsChartComponent implements OnChanges, OnInit {
@Input() portfolioItems: PortfolioItem[];
// @ViewChild('timelineCanvas') timeline;
public isLoading = true;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
if (this.portfolioItems) {
this.initialize();
}
}
private initialize() {
this.isLoading = true;
let datasets = [];
const fromToPosition = {};
this.portfolioItems.forEach((positionsByDay) => {
Object.keys(positionsByDay.positions).forEach((symbol) => {
if (fromToPosition[symbol]) {
fromToPosition[symbol].push({
date: positionsByDay.date,
quantity: positionsByDay.positions[symbol].quantity
});
} else {
fromToPosition[symbol] = [
{
date: positionsByDay.date,
quantity: positionsByDay.positions[symbol].quantity
}
];
}
});
});
Object.keys(fromToPosition).forEach((symbol) => {
let currentDate = null;
let currentQuantity = null;
let data = [];
let hasStock = false;
fromToPosition[symbol].forEach((x, index) => {
if (x.quantity > 0 && index === 0) {
currentDate = x.date;
hasStock = true;
}
if (x.quantity === 0 || index === fromToPosition[symbol].length - 1) {
if (hasStock) {
data.push([
startOfDay(parseISO(currentDate)),
endOfDay(parseISO(x.date)),
currentQuantity
]);
hasStock = false;
} else {
// Do nothing
}
} else {
if (hasStock) {
// Do nothing
} else {
currentDate = x.date;
hasStock = true;
}
}
currentQuantity = x.quantity;
});
if (data.length === 0) {
// Fill data for today
data.push([
startOfDay(new Date()),
endOfDay(new Date()),
currentQuantity
]);
}
datasets.push({ data, symbol });
});
// Sort by date
datasets = datasets.sort((a: any, b: any) => {
return a.data[0][0].getTime() - b.data[0][0].getTime();
});
/*new Chart(this.timeline.nativeElement, {
type: 'timeline',
options: {
elements: {
colorFunction: (text, data, dataset, index) => {
return `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`;
},
showText: false,
textPadding: 4
},
maintainAspectRatio: true,
responsive: true,
scales: {
xAxes: [
{
gridLines: {
display: false
},
position: 'top',
time: {
unit: 'year'
}
}
],
yAxes: [
{
gridLines: {
display: false
},
ticks: {
display: false
}
}
]
}
},
data: {
datasets,
labels: datasets.map((dataset) => {
return dataset.symbol;
})
}
});*/
this.isLoading = false;
}
}

View File

@ -8,7 +8,7 @@ import {
ViewChild
} from '@angular/core';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getCssVariable, getTextColor } from '@ghostfolio/common/helper';
import { getTextColor } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { Tooltip } from 'chart.js';
@ -24,11 +24,13 @@ import { Chart } from 'chart.js';
styleUrls: ['./portfolio-proportion-chart.component.scss']
})
export class PortfolioProportionChartComponent
implements OnChanges, OnDestroy, OnInit {
implements OnChanges, OnDestroy, OnInit
{
@Input() baseCurrency: Currency;
@Input() isInPercent: boolean;
@Input() key: string;
@Input() locale: string;
@Input() maxItems?: number;
@Input() positions: {
[symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number };
};
@ -41,9 +43,7 @@ export class PortfolioProportionChartComponent
private colorMap: {
[symbol: string]: string;
} = {
[UNKNOWN_KEY]: `rgba(${getTextColor()}, ${getCssVariable(
'--palette-foreground-divider-alpha'
)})`
[UNKNOWN_KEY]: `rgba(${getTextColor()}, 0.12)`
};
public constructor() {
@ -71,9 +71,8 @@ export class PortfolioProportionChartComponent
Object.keys(this.positions).forEach((symbol) => {
if (this.positions[symbol][this.key]) {
if (chartData[this.positions[symbol][this.key]]) {
chartData[this.positions[symbol][this.key]].value += this.positions[
symbol
].value;
chartData[this.positions[symbol][this.key]].value +=
this.positions[symbol].value;
} else {
chartData[this.positions[symbol][this.key]] = {
value: this.positions[symbol].value
@ -90,12 +89,44 @@ export class PortfolioProportionChartComponent
}
});
const chartDataSorted = Object.entries(chartData)
let chartDataSorted = Object.entries(chartData)
.sort((a, b) => {
return a[1].value - b[1].value;
})
.reverse();
if (this.maxItems && chartDataSorted.length > this.maxItems) {
// Add surplus items to unknown group
const rest = chartDataSorted.splice(
this.maxItems,
chartDataSorted.length - 1
);
let unknownItem = chartDataSorted.find((charDataItem) => {
return charDataItem[0] === UNKNOWN_KEY;
});
if (!unknownItem) {
const index = chartDataSorted.push([UNKNOWN_KEY, { value: 0 }]);
unknownItem = chartDataSorted[index];
}
rest.forEach((restItem) => {
if (unknownItem?.[1]) {
unknownItem[1] = {
value: unknownItem[1].value + restItem[1].value
};
}
});
// Sort data again
chartDataSorted = chartDataSorted
.sort((a, b) => {
return a[1].value - b[1].value;
})
.reverse();
}
chartDataSorted.forEach(([symbol, item], index) => {
if (this.colorMap[symbol]) {
// Reuse color

View File

@ -2,11 +2,14 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject
Inject,
OnDestroy
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { LineChartItem } from '../../line-chart/interfaces/line-chart.interface';
import { PositionDetailDialogParams } from './interfaces/interfaces';
@ -18,7 +21,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'position-detail-dialog.html',
styleUrls: ['./position-detail-dialog.component.scss']
})
export class PositionDetailDialog {
export class PositionDetailDialog implements OnDestroy {
public averagePrice: number;
public benchmarkDataItems: LineChartItem[];
public currency: string;
@ -33,6 +36,8 @@ export class PositionDetailDialog {
public quantity: number;
public transactionCount: number;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -41,6 +46,7 @@ export class PositionDetailDialog {
) {
this.dataService
.fetchPositionDetail(data.symbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
averagePrice,
@ -135,4 +141,9 @@ export class PositionDetailDialog {
public onClose(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -72,8 +72,11 @@ export class PositionComponent implements OnDestroy, OnInit {
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
}

View File

@ -82,7 +82,13 @@
<tr
*matRowDef="let row; columns: displayedColumns"
mat-row
(click)="onOpenPositionDialog({ symbol: row.symbol, title: row.name })"
[ngClass]="{
'cursor-pointer': !this.ignoreTypes.includes(row.type)
}"
(click)="
!this.ignoreTypes.includes(row.type) &&
onOpenPositionDialog({ symbol: row.symbol, title: row.name })
"
></tr>
</table>

View File

@ -19,7 +19,9 @@
}
.mat-row {
cursor: pointer;
&.cursor-pointer {
cursor: pointer;
}
}
}
}

View File

@ -4,6 +4,7 @@ import {
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
ViewChild
@ -13,6 +14,7 @@ import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { Type } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
@ -26,7 +28,7 @@ import { PositionDetailDialog } from '../position/position-detail-dialog/positio
templateUrl: './positions-table.component.html',
styleUrls: ['./positions-table.component.scss']
})
export class PositionsTableComponent implements OnChanges, OnInit {
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() locale: string;
@ -38,8 +40,10 @@ export class PositionsTableComponent implements OnChanges, OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<PortfolioPosition> = new MatTableDataSource();
public dataSource: MatTableDataSource<PortfolioPosition> =
new MatTableDataSource();
public displayedColumns = [];
public ignoreTypes = [Type.Cash];
public isLoading = true;
public pageSize = 7;
public routeQueryParams: Subscription;
@ -133,9 +137,12 @@ export class PositionsTableComponent implements OnChanges, OnInit {
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() {

View File

@ -1,12 +1,11 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
gf-position {
&:nth-child(even) {
background-color: rgba(
var(--dark-primary-text),
var(--palette-background-hover-alpha)
);
background-color: rgba(0, 0, 0, $alpha-hover);
}
}
}
@ -14,10 +13,7 @@
:host-context(.is-dark-theme) {
gf-position {
&:nth-child(even) {
background-color: rgba(
var(--light-primary-text),
var(--palette-background-hover-alpha)
);
background-color: rgba(255, 255, 255, $alpha-hover);
}
}
}

View File

@ -5,7 +5,10 @@ import {
OnChanges,
OnInit
} from '@angular/core';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import {
MarketState,
Type
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioPosition } from '@ghostfolio/common/interfaces/portfolio-position.interface';
@Component({
@ -25,6 +28,8 @@ export class PositionsComponent implements OnChanges, OnInit {
public positionsRest: PortfolioPosition[] = [];
public positionsWithPriority: PortfolioPosition[] = [];
private ignoreTypes = [Type.Cash];
public constructor() {}
public ngOnInit() {}
@ -41,6 +46,10 @@ export class PositionsComponent implements OnChanges, OnInit {
this.positionsWithPriority = [];
for (const [, portfolioPosition] of Object.entries(this.positions)) {
if (this.ignoreTypes.includes(portfolioPosition.type)) {
continue;
}
if (
portfolioPosition.marketState === MarketState.open ||
this.range !== '1d'

View File

@ -7,10 +7,7 @@
padding: 0.15rem 0.75rem;
&.mat-radio-checked {
background-color: rgba(
var(--dark-primary-text),
var(--palette-foreground-divider-alpha)
);
background-color: rgba(var(--dark-dividers));
}
::ng-deep {
@ -33,15 +30,8 @@
:host-context(.is-dark-theme) {
.mat-radio-button {
&.mat-radio-checked {
background-color: rgba(
var(--light-primary-text),
var(--palette-foreground-divider-alpha)
);
border: 1px solid
rgba(
var(--light-primary-text),
var(--palette-foreground-disabled-button-alpha)
);
background-color: rgba(var(--light-dividers));
border: 1px solid rgba(var(--light-disabled-text));
}
::ng-deep {

View File

@ -40,47 +40,51 @@
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="date">
<ng-container matColumnDef="count">
<th
*matHeaderCellDef
class="justify-content-center px-1"
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
mat-sort-header
>
#
</th>
<td
*matCellDef="let element; let i = index"
class="d-none d-lg-table-cell px-1 text-right"
mat-cell
>
{{ dataSource.data.length - i }}
</td>
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Date
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-center">
<div class="d-flex">
{{ element.date | date: defaultDateFormat }}
</div>
</td>
</ng-container>
<ng-container matColumnDef="type">
<th
*matHeaderCellDef
class="justify-content-center px-1"
i18n
mat-header-cell
mat-sort-header
>
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Type
</th>
<td mat-cell *matCellDef="let element" class="px-1 text-center">
<td mat-cell *matCellDef="let element" class="px-1">
<div
class="d-inline-flex justify-content-center pl-1 pr-2 py-1 type-badge"
class="d-inline-flex p-1 type-badge"
[ngClass]="element.type == 'BUY' ? 'buy' : 'sell'"
>
<ion-icon
class="mr-1"
[name]="
element.type === 'BUY'
? 'arrow-forward-circle-outline'
: 'arrow-back-circle-outline'
"
></ion-icon>
<span>{{ element.type }}</span>
<span class="d-none d-lg-block mx-1">{{ element.type }}</span>
</div>
</td>
</ng-container>
@ -90,24 +94,30 @@
Symbol
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.symbol | gfSymbol }}
<div class="d-flex align-items-center">
{{ element.symbol | gfSymbol }}
<span
*ngIf="isAfter(element.date, endOfToday)"
class="badge badge-secondary ml-1"
i18n
>Draft</span
>
</div>
</td>
</ng-container>
<ng-container matColumnDef="currency">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-center px-1"
mat-header-cell
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
mat-sort-header
>
Currency
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-center">
{{ element.currency }}
</div>
{{ element.currency }}
</td>
</ng-container>
@ -175,7 +185,9 @@
</ng-container>
<ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Account</th>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<span class="d-none d-lg-block" i18n>Account</span>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
<gf-symbol-icon
@ -190,17 +202,45 @@
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
[matMenuTriggerFor]="transactionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<mat-menu #transactionsMenu="matMenu" xPosition="before">
<button
*ngIf="hasPermissionToImportOrders"
class="align-items-center d-flex"
mat-menu-item
(click)="onImport()"
>
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<span i18n>Import</span>
</button>
<button
class="align-items-center d-flex"
mat-menu-item
(click)="onExport()"
>
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
<span i18n>Export</span>
</button>
</mat-menu>
</th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="transactionMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #transactionMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onUpdateTransaction(element)">
Edit
</button>

View File

@ -27,7 +27,7 @@
cursor: pointer;
.type-badge {
background-color: rgba(var(--dark-primary-text), 0.05);
background-color: rgba(var(--palette-foreground-text), 0.05);
border-radius: 1rem;
line-height: 1em;
@ -54,7 +54,10 @@
.mat-table {
.type-badge {
background-color: rgba(var(--light-primary-text), 0.1);
background-color: rgba(
var(--palette-foreground-text-dark),
0.1
) !important;
}
}
}

View File

@ -23,7 +23,7 @@ import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { format } from 'date-fns';
import { endOfToday, format, isAfter } from 'date-fns';
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -39,13 +39,17 @@ const SEARCH_STRING_SEPARATOR = ',';
styleUrls: ['./transactions-table.component.scss']
})
export class TransactionsTableComponent
implements OnChanges, OnDestroy, OnInit {
implements OnChanges, OnDestroy, OnInit
{
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToImportOrders: boolean;
@Input() locale: string;
@Input() showActions: boolean;
@Input() transactions: OrderWithAccount[];
@Output() export = new EventEmitter<void>();
@Output() import = new EventEmitter<void>();
@Output() transactionDeleted = new EventEmitter<string>();
@Output() transactionToClone = new EventEmitter<OrderWithAccount>();
@Output() transactionToUpdate = new EventEmitter<OrderWithAccount>();
@ -54,11 +58,14 @@ export class TransactionsTableComponent
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<OrderWithAccount> = new MatTableDataSource();
public dataSource: MatTableDataSource<OrderWithAccount> =
new MatTableDataSource();
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public displayedColumns = [];
public endOfToday = endOfToday();
public filters$: Subject<string[]> = new BehaviorSubject([]);
public filters: Observable<string[]> = this.filters$.asObservable();
public isAfter = isAfter;
public isLoading = true;
public placeholder = '';
public routeQueryParams: Subscription;
@ -85,18 +92,20 @@ export class TransactionsTableComponent
}
});
this.searchControl.valueChanges.subscribe((keyword) => {
if (keyword) {
const filterValue = keyword.toLowerCase();
this.filters$.next(
this.allFilters.filter(
(filter) => filter.toLowerCase().indexOf(filterValue) === 0
)
);
} else {
this.filters$.next(this.allFilters);
}
});
this.searchControl.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((keyword) => {
if (keyword) {
const filterValue = keyword.toLowerCase();
this.filters$.next(
this.allFilters.filter(
(filter) => filter.toLowerCase().indexOf(filterValue) === 0
)
);
} else {
this.filters$.next(this.allFilters);
}
});
}
public addKeyword({ input, value }: MatChipInputEvent): void {
@ -133,6 +142,7 @@ export class TransactionsTableComponent
public ngOnChanges() {
this.displayedColumns = [
'count',
'date',
'type',
'symbol',
@ -178,6 +188,14 @@ export class TransactionsTableComponent
}
}
public onExport() {
this.export.emit();
}
public onImport() {
this.import.emit();
}
public onOpenPositionDialog({
symbol,
title
@ -218,9 +236,12 @@ export class TransactionsTableComponent
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
public ngOnDestroy() {

View File

@ -1,12 +1,10 @@
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="h-100"
[theme]="{
height: '30rem',
width: '100%'
}"
></ngx-skeleton-loader>
<canvas
#timelineCanvas
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas>
<div class="align-items-center d-flex h-100 w-100" id="svgMap"></div>

View File

@ -0,0 +1,32 @@
:host {
display: block;
height: 100%;
::ng-deep {
.loader {
height: 100% !important;
}
.svgMap-map-wrapper {
background: transparent;
.svgMap-country {
stroke: #e5e5e5;
}
.svgMap-map-controls-wrapper {
display: none;
}
}
}
}
:host-context(.is-dark-theme) {
::ng-deep {
.svgMap-map-wrapper {
.svgMap-country {
stroke: #414141;
}
}
}
}

View File

@ -0,0 +1,77 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnChanges,
OnDestroy,
OnInit
} from '@angular/core';
import { Currency } from '@prisma/client';
import svgMap from 'svgmap';
@Component({
selector: 'gf-world-map-chart',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './world-map-chart.component.html',
styleUrls: ['./world-map-chart.component.scss']
})
export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: Currency;
@Input() countries: { [code: string]: { name: string; value: number } };
public isLoading = true;
public svgMapElement;
public constructor(private changeDetectorRef: ChangeDetectorRef) {}
public ngOnInit() {}
public ngOnChanges() {
if (this.countries) {
this.isLoading = true;
this.destroySvgMap();
this.initialize();
}
}
public ngOnDestroy() {
this.destroySvgMap();
}
private initialize() {
this.svgMapElement = new svgMap({
colorMax: '#22bdb9',
colorMin: '#c3f1f0',
colorNoData: 'transparent',
data: {
applyData: 'value',
data: {
value: {
format: `{0} ${this.baseCurrency}`
}
},
values: this.countries
},
hideFlag: true,
minZoom: 1.06,
maxZoom: 1.06,
targetElementID: 'svgMap'
});
setTimeout(() => {
this.isLoading = false;
this.changeDetectorRef.markForCheck();
}, 500);
}
private destroySvgMap() {
this.svgMapElement?.mapWrapper?.remove();
this.svgMapElement?.tooltip?.remove();
this.svgMapElement = null;
}
}

View File

@ -2,12 +2,12 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PortfolioPositionsChartComponent } from './portfolio-positions-chart.component';
import { WorldMapChartComponent } from './world-map-chart.component';
@NgModule({
declarations: [PortfolioPositionsChartComponent],
exports: [PortfolioPositionsChartComponent],
declarations: [WorldMapChartComponent],
exports: [WorldMapChartComponent],
imports: [CommonModule, NgxSkeletonLoaderModule],
providers: []
})
export class PortfolioPositionsChartModule {}
export class GfWorldMapChartModule {}

View File

@ -14,7 +14,12 @@ import { UserService } from '../services/user/user.service';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
private static PUBLIC_PAGE_ROUTES = ['/about', '/pricing', '/resources'];
private static PUBLIC_PAGE_ROUTES = [
'/about',
'/pricing',
'/register',
'/resources'
];
constructor(
private router: Router,
@ -35,7 +40,10 @@ export class AuthGuard implements CanActivate {
.get()
.pipe(
catchError(() => {
if (AuthGuard.PUBLIC_PAGE_ROUTES.includes(state.url)) {
if (route.queryParams?.utm_source) {
this.router.navigate(['/register']);
resolve(false);
} else if (AuthGuard.PUBLIC_PAGE_ROUTES.includes(state.url)) {
resolve(true);
return EMPTY;
} else if (state.url !== '/start') {

View File

@ -2,12 +2,10 @@ import {
HTTP_INTERCEPTORS,
HttpErrorResponse,
HttpEvent,
HttpResponse
} from '@angular/common/http';
import {
HttpHandler,
HttpInterceptor,
HttpRequest
HttpRequest,
HttpResponse
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import {
@ -16,6 +14,7 @@ import {
TextOnlySnackBar
} from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { StatusCodes } from 'http-status-codes';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
@ -29,7 +28,8 @@ export class HttpResponseInterceptor implements HttpInterceptor {
public constructor(
private router: Router,
private tokenStorageService: TokenStorageService,
private snackBar: MatSnackBar
private snackBar: MatSnackBar,
private webAuthnService: WebAuthnService
) {}
public intercept(
@ -78,7 +78,11 @@ export class HttpResponseInterceptor implements HttpInterceptor {
});
}
} else if (error.status === StatusCodes.UNAUTHORIZED) {
this.tokenStorageService.signOut();
if (this.webAuthnService.isEnabled()) {
this.router.navigate(['/webauthn']);
} else {
this.tokenStorageService.signOut();
}
}
return throwError('');

View File

@ -1,7 +1,10 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -12,10 +15,12 @@ import { environment } from '../../../environments/environment';
templateUrl: './about-page.html',
styleUrls: ['./about-page.scss']
})
export class AboutPageComponent implements OnInit {
export class AboutPageComponent implements OnDestroy, OnInit {
public baseCurrency = baseCurrency;
public hasPermissionForStatistics: boolean;
public isLoggedIn: boolean;
public lastPublish = environment.lastPublish;
public statistics: Statistics;
public user: User;
public version = environment.version;
@ -26,6 +31,7 @@ export class AboutPageComponent implements OnInit {
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private userService: UserService
) {}
@ -33,6 +39,14 @@ export class AboutPageComponent implements OnInit {
* Initializes the controller
*/
public ngOnInit() {
const { globalPermissions, statistics } = this.dataService.fetchInfo();
this.hasPermissionForStatistics = hasPermission(
globalPermissions,
permissions.enableStatistics
);
this.statistics = statistics;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {

View File

@ -2,14 +2,21 @@
<div class="mb-5 row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
<mat-card class="mb-3">
<mat-card>
<mat-card-content>
<p>
<strong>Ghostfolio</strong> is open source software which empowers
busy folks to have a sharp look of their financial assets and to
make solid, data-driven investment decisions by evaluating automated
static portfolio analysis rules. The project has been initiated by
<a href="https://dotsilver.ch">Thomas Kaul</a>.
<strong>Ghostfolio</strong> is a lightweight wealth management
application for individuals to keep track of their wealth like
stocks, ETFs or cryptocurrencies and make solid, data-driven
investment decisions. The source code is fully available as open
source software (OSS). The project has been initiated by
<a href="https://dotsilver.ch">Thomas Kaul</a> and is driven by the
efforts of its
<a
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
title="Contributors to Ghostfolio"
>contributors</a
>.
<ng-container *ngIf="lastPublish">
This instance is running Ghostfolio {{ version }} and has been
last published on {{ lastPublish }}.</ng-container
@ -17,21 +24,13 @@
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new feature, please open an issue at
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>, tweet
to <a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or
send an e-mail to
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
new feature, please tweet to
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>, send an
e-mail to <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or open
an issue at
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
@ -48,6 +47,14 @@
>
<ion-icon name="mail" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
</p>
<div class="d-flex justify-content-center">
<div
@ -60,10 +67,44 @@
</div>
</div>
<div *ngIf="hasPermissionForStatistics" class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Ghostfolio in Numbers</h3>
<mat-card>
<mat-card-content>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0" [hidden]="!statistics?.activeUsers1d">
{{ statistics?.activeUsers1d ?? '-' }}
</h3>
<div class="h6 mb-0">
Active Users <small class="text-muted">(Last 24 hours)</small>
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0" [hidden]="!statistics?.activeUsers30d">
{{ statistics?.activeUsers30d ?? '-' }}
</h3>
<div class="h6 mb-0">
Active Users <small class="text-muted">(Last 30 days)</small>
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0" [hidden]="!statistics?.gitHubStargazers">
{{ statistics?.gitHubStargazers ?? '-' }}
</h3>
<div class="h6 mb-0">Stars on GitHub</div>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Changelog</h3>
<mat-card class="changelog mb-3">
<mat-card class="changelog">
<mat-card-content>
<markdown [src]="'CHANGELOG.md'"></markdown>
</mat-card-content>
@ -74,7 +115,7 @@
<div class="row">
<div class="col">
<h3 class="mb-3 text-center" i18n>License</h3>
<mat-card class="mb-3">
<mat-card>
<mat-card-content>
<markdown [src]="'LICENSE'"></markdown>
</mat-card-content>

View File

@ -1,12 +1,24 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import {
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import {
MatSlideToggle,
MatSlideToggleChange
} from '@angular/material/slide-toggle';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Currency } from '@prisma/client';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-account-page',
@ -14,11 +26,20 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./account-page.scss']
})
export class AccountPageComponent implements OnDestroy, OnInit {
@ViewChild('toggleSignInWithFingerprintEnabledElement')
signInWithFingerprintElement: MatSlideToggle;
public accesses: Access[];
public baseCurrency: Currency;
public baseCurrency = baseCurrency;
public coupon: number;
public couponId: string;
public currencies: Currency[] = [];
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public hasPermissionForSubscription;
public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public price: number;
public priceId: string;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -29,14 +50,23 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private userService: UserService
private stripeService: StripeService,
private userService: UserService,
public webAuthnService: WebAuthnService
) {
this.dataService
.fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currencies }) => {
this.currencies = currencies;
});
const { currencies, globalPermissions, subscriptions } =
this.dataService.fetchInfo();
this.coupon = subscriptions?.[0]?.coupon;
this.couponId = subscriptions?.[0]?.couponId;
this.currencies = currencies;
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
this.price = subscriptions?.[0]?.price;
this.priceId = subscriptions?.[0]?.priceId;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
@ -49,6 +79,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
permissions.updateUserSettings
);
this.hasPermissionToUpdateViewMode = hasPermission(
this.user.permissions,
permissions.updateViewMode
);
this.changeDetectorRef.markForCheck();
}
});
@ -84,11 +119,76 @@ export class AccountPageComponent implements OnDestroy, OnInit {
});
}
public onCheckout() {
this.dataService
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
.pipe(
switchMap(({ sessionId }: { sessionId: string }) => {
return this.stripeService.redirectToCheckout({
sessionId
});
})
)
.subscribe((result) => {
if (result.error) {
alert(result.error.message);
}
});
}
public onSignInWithFingerprintChange(aEvent: MatSlideToggleChange) {
if (aEvent.checked) {
this.registerDevice();
} else {
const confirmation = confirm(
'Do you really want to remove this sign in method?'
);
if (confirmation) {
this.deregisterDevice();
} else {
this.update();
}
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private deregisterDevice() {
this.webAuthnService
.deregister()
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.update();
return EMPTY;
})
)
.subscribe(() => {
this.update();
});
}
private registerDevice() {
this.webAuthnService
.register()
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.update();
return EMPTY;
})
)
.subscribe(() => {
this.update();
});
}
private update() {
this.dataService
.fetchAccesses()
@ -96,6 +196,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
.subscribe((response) => {
this.accesses = response;
if (this.signInWithFingerprintElement) {
this.signInWithFingerprintElement.checked =
this.webAuthnService.isEnabled() ?? false;
}
this.changeDetectorRef.markForCheck();
});
}

View File

@ -1,10 +1,7 @@
<div class="container">
<div class="row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3">
<ng-container *ngIf="user?.alias">{{ user.alias }}</ng-container>
<ng-container *ngIf="!user?.alias" i18n>Account</ng-container>
</h3>
<h3 class="d-flex justify-content-center mb-3" i18n>Account</h3>
</div>
</div>
<div *ngIf="user?.settings" class="mb-5 row">
@ -19,51 +16,99 @@
<div class="w-50" i18n>Membership</div>
<div class="w-50">
<div class="align-items-center d-flex mb-1">
{{ user.subscription.type }}
{{ user?.subscription?.type }}
<ion-icon
*ngIf="user?.subscription?.type === 'Premium'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
</div>
<div *ngIf="user.subscription.expiresAt">
Valid until {{ user.subscription.expiresAt | date:
<div *ngIf="user?.subscription?.expiresAt">
Valid until {{ user?.subscription?.expiresAt | date:
defaultDateFormat }}
</div>
<div *ngIf="!user.subscription.expiresAt">
<button color="primary" disabled i18n mat-flat-button>
<div
*ngIf="hasPermissionForSubscription && !user?.subscription?.expiresAt"
>
<button
color="primary"
i18n
mat-flat-button
(click)="onCheckout(priceId)"
>
Upgrade
</button>
<div *ngIf="price" class="mt-1">
{{ baseCurrency }}
<ng-container *ngIf="coupon"
>{{ price - coupon | number : '1.2-2' }}
<del>{{ price }}</del>
</ng-container>
<ng-container *ngIf="!coupon">{{ price }}</ng-container>
<span i18n> per year</span>
</div>
</div>
</div>
</div>
<div class="d-flex mt-4 py-1">
<div class="pt-4 w-50" i18n>Settings</div>
<div class="w-50">
<form #changeUserSettingsForm="ngForm">
<mat-form-field appearance="outline" class="mb-3 w-100">
<mat-label i18n>Base Currency</mat-label>
<mat-select
name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency"
(selectionChange)="onChangeUserSettings('baseCurrency', $event.value)"
>
<mat-option
*ngFor="let currency of currencies"
[value]="currency"
>{{ currency }}</mat-option
<form #changeUserSettingsForm="ngForm" class="w-100">
<div class="d-flex mb-2">
<div class="align-items-center d-flex pt-1 w-50" i18n>
Base Currency
</div>
<div class="w-50">
<mat-form-field appearance="outline" class="w-100">
<mat-select
name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency"
(selectionChange)="onChangeUserSettings('baseCurrency', $event.value)"
>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>View Mode</mat-label>
<mat-select
name="viewMode"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.viewMode"
(selectionChange)="onChangeUserSettings('viewMode', $event.value)"
>
<mat-option value="DEFAULT">Default</mat-option>
<mat-option value="ZEN">Zen</mat-option>
</mat-select>
</mat-form-field>
</form>
<mat-option
*ngFor="let currency of currencies"
[value]="currency"
>{{ currency }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="d-flex">
<div class="align-items-center d-flex pt-1 w-50" i18n>
View Mode
<ion-icon
*ngIf="!hasPermissionToUpdateViewMode"
class="mx-1 text-muted"
name="diamond-outline"
></ion-icon>
</div>
<div class="w-50">
<div class="align-items-center d-flex overflow-hidden">
<mat-form-field appearance="outline" class="w-100">
<mat-select
name="viewMode"
[disabled]="!hasPermissionToUpdateViewMode"
[value]="user.settings.viewMode"
(selectionChange)="onChangeUserSettings('viewMode', $event.value)"
>
<mat-option value="DEFAULT">Default</mat-option>
<mat-option value="ZEN">Zen</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</div>
</form>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="w-50" i18n>Sign in with fingerprint</div>
<div class="w-50">
<mat-slide-toggle
#toggleSignInWithFingerprintEnabledElement
color="primary"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onSignInWithFingerprintChange($event)"
></mat-slide-toggle>
</div>
</div>
</mat-card-content>

Some files were not shown because too many files have changed in this diff Show More