Compare commits
88 Commits
Author | SHA1 | Date | |
---|---|---|---|
e126f9ec54 | |||
09bbda3502 | |||
ee9a521813 | |||
169c151547 | |||
3a95ec0f81 | |||
ad00cd9d81 | |||
373a2015c0 | |||
66c955ad6c | |||
a2440fc067 | |||
3d7624d997 | |||
0264b592b9 | |||
198eaf57d3 | |||
6783ea2ebb | |||
a35701fe24 | |||
5db90f1787 | |||
81fe538484 | |||
51884913be | |||
8886082dfa | |||
3b12e5b85b | |||
6c1119caec | |||
698d5ec3b7 | |||
e87c942cb8 | |||
f7860a9799 | |||
c519eb0e99 | |||
8314b98f81 | |||
194cf1ddcc | |||
7da6478699 | |||
4f2bbba782 | |||
9eb25f6c9e | |||
f74b00446c | |||
beb7e6ec34 | |||
2eafc042ad | |||
74954bc51d | |||
6a03120225 | |||
21504573b4 | |||
fabd912fba | |||
00b42855b6 | |||
ef272360fb | |||
026a5011d4 | |||
aa4206af0e | |||
7788465272 | |||
3066dfd805 | |||
34303163bc | |||
e7fbcd4fa0 | |||
7c22969de1 | |||
6623bc0113 | |||
146b5201b5 | |||
b021fbde59 | |||
ec046b81a7 | |||
aea497154a | |||
dc736d53b4 | |||
5957b33779 | |||
bafdce56ad | |||
42a2d404e4 | |||
11b2379d98 | |||
c0657a2e9e | |||
646dcb91c5 | |||
ad961f3039 | |||
c16f743b07 | |||
8e13f6ef9b | |||
95bcdea69b | |||
0d6fe4a232 | |||
ced4519412 | |||
ef8b7718b1 | |||
b4762dc463 | |||
9851cce382 | |||
a1460a98fd | |||
1a553a296f | |||
f5bd6b0d58 | |||
78a4946e8b | |||
702ee956a2 | |||
200a7d2d65 | |||
79edc09710 | |||
77255df4be | |||
277133fa1a | |||
abd0e08566 | |||
561d8dbc70 | |||
c973ffd3ba | |||
368de7dedc | |||
e56514629f | |||
7a8a25c4c0 | |||
5d36d3a6bb | |||
0ef35fd31f | |||
c1c22c195d | |||
111d8d8e3c | |||
b0a24e4fc0 | |||
694b9b8991 | |||
fada347aa5 |
4
.env
4
.env
@ -5,12 +5,12 @@ REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# POSTGRES
|
||||
POSTGRES_DB=ghostfolio-db
|
||||
POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=password
|
||||
POSTGRES_DB=ghostfolio-db
|
||||
|
||||
ACCESS_TOKEN_SALT=GHOSTFOLIO
|
||||
ALPHA_VANTAGE_API_KEY=
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/ghostfolio-db?sslmode=prefer
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
||||
JWT_SECRET_KEY=123456
|
||||
PORT=3333
|
||||
|
@ -7,3 +7,5 @@ before_script:
|
||||
- yarn
|
||||
script:
|
||||
- yarn format:check
|
||||
- yarn test
|
||||
- yarn build:all
|
||||
|
217
CHANGELOG.md
217
CHANGELOG.md
@ -5,6 +5,213 @@ 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.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
|
||||
|
||||
- Fixed an optional type in the user interface
|
||||
|
||||
## 1.10.0 - 02.06.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the tools to a sub path (`/tools`)
|
||||
- Extended the pricing page and aligned with the subscription model
|
||||
|
||||
## 1.9.0 - 01.06.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the year labels to the investment chart on the x-axis
|
||||
|
||||
### Changed
|
||||
|
||||
- Respected the data source attribute of the transactions model in the data management for historical data
|
||||
- Prettified the generic scraper symbols in the transaction filtering component
|
||||
- Changed to the strict mode of distance formatting between two given dates
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the sorting in various tables
|
||||
- Made the order of the rules in the _X-ray_ section consistent
|
||||
|
||||
## 1.8.0 - 24.05.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a section for _Analysis_, _X-ray_ and upcoming tools
|
||||
|
||||
### Changed
|
||||
|
||||
- Introduced a user service implemented as an observable store (single source of truth for state)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the performance chart by considering the investment
|
||||
- Fixed missing header of public pages (_About_, _Pricing_, _Resources_)
|
||||
|
||||
## 1.7.0 - 22.05.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Hid footer on mobile (except on landing page)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the internal navigation of the _Zen Mode_ in combination with a query parameter
|
||||
|
||||
## 1.6.0 - 22.05.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added an index in the users table of the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the alignment in the users table of the admin control panel
|
||||
|
||||
## 1.5.0 - 22.05.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added _Zen Mode_: the distraction-free view
|
||||
|
||||
## 1.4.0 - 20.05.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added filtering by year in the transaction filtering component
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed _Ghostfolio Account_ to _My Ghostfolio_
|
||||
- Hid unknown exchange in the position overview
|
||||
- Disable the base currency selector for the demo user
|
||||
- Refactored the portfolio unit tests to work without database
|
||||
- Refactored the search functionality of the data management (aligned with data source)
|
||||
- Renamed shared helper to `@ghostfolio/common/helper`
|
||||
- Moved shared interfaces to `@ghostfolio/common/interfaces`
|
||||
- Moved shared types to `@ghostfolio/common/types`
|
||||
|
||||
## 1.3.0 - 15.05.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored the active menu item state by parsing the current url
|
||||
- Used a desaturated background color for unknown types in pie charts
|
||||
- Renamed the columns _Initial Share_ and _Current Share_ to _Initial Allocation_ and _Current Allocation_ in the positions table
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the link to the pricing page
|
||||
|
||||
## 1.2.1 - 14.05.2021
|
||||
|
||||
### Changed
|
||||
@ -100,7 +307,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
|
||||
@ -108,7 +315,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
|
||||
|
||||
@ -135,7 +342,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
|
||||
|
||||
@ -166,7 +373,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
|
||||
|
||||
@ -181,7 +388,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
|
||||
|
||||
|
24
README.md
24
README.md
@ -7,16 +7,15 @@
|
||||
<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>
|
||||
<img src="https://travis-ci.org/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>
|
||||
<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. 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 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.
|
||||
|
||||
## Why Ghostfolio?
|
||||
|
||||
@ -43,10 +42,13 @@ Ghostfolio is for you if you are...
|
||||
## Features
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`)
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Dark Mode
|
||||
- ✅ Zen Mode
|
||||
- ✅ Mobile-first design
|
||||
|
||||
## Technology Stack
|
||||
|
||||
@ -54,11 +56,11 @@ Ghostfolio is a modern web application written in [TypeScript](https://www.types
|
||||
|
||||
### Backend
|
||||
|
||||
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database and [Redis](https://redis.io) for caching.
|
||||
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database together with [Prisma](https://www.prisma.io) and [Redis](https://redis.io) for caching.
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend is built with [Angular](https://angular.io).
|
||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||
|
||||
## Getting Started
|
||||
|
||||
@ -86,12 +88,14 @@ 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
|
||||
|
||||
|
24
angular.json
24
angular.json
@ -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",
|
||||
{
|
||||
@ -121,7 +120,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 +157,8 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
@ -208,22 +214,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"helper": {
|
||||
"root": "libs/helper",
|
||||
"sourceRoot": "libs/helper/src",
|
||||
"common": {
|
||||
"root": "libs/common",
|
||||
"sourceRoot": "libs/common/src",
|
||||
"projectType": "library",
|
||||
"architect": {
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/helper/**/*.ts"]
|
||||
"lintFilePatterns": ["libs/common/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/helper"],
|
||||
"outputs": ["coverage/libs/common"],
|
||||
"options": {
|
||||
"jestConfig": "libs/helper/jest.config.js",
|
||||
"jestConfig": "libs/common/jest.config.js",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
import { Access } 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 { AccessService } from './access.service';
|
||||
import { Access } from './interfaces/access.interface';
|
||||
|
||||
@Controller('access')
|
||||
export class AccessController {
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { AccessWithGranteeUser } from './interfaces/access-with-grantee-user.type';
|
||||
|
||||
@Injectable()
|
||||
export class AccessService {
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -17,7 +21,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Account as AccountModel, Order } from '@prisma/client';
|
||||
import { Account as AccountModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccountService } from './account.service';
|
||||
|
@ -1,6 +1,11 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
|
||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@ -14,7 +19,6 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AdminService } from './admin.service';
|
||||
import { AdminData } from './interfaces/admin-data.interface';
|
||||
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { AdminData } from './interfaces/admin-data.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
public constructor(
|
||||
@ -109,7 +108,7 @@ export class AdminService {
|
||||
createdAt: true,
|
||||
id: true
|
||||
},
|
||||
take: 20,
|
||||
take: 30,
|
||||
where: {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
|
@ -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';
|
||||
@ -26,6 +27,7 @@ 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,6 +36,7 @@ import { UserModule } from './user/user.module';
|
||||
AdminModule,
|
||||
AccessModule,
|
||||
AccountModule,
|
||||
AuthDeviceModule,
|
||||
AuthModule,
|
||||
CacheModule,
|
||||
ConfigModule.forRoot(),
|
||||
@ -57,6 +60,7 @@ import { UserModule } from './user/user.module';
|
||||
rootPath: join(__dirname, '..', 'client'),
|
||||
exclude: ['/api*']
|
||||
}),
|
||||
SubscriptionModule,
|
||||
SymbolModule,
|
||||
UserModule
|
||||
],
|
||||
|
44
apps/api/src/app/auth-device/auth-device.controller.ts
Normal file
44
apps/api/src/app/auth-device/auth-device.controller.ts
Normal 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 });
|
||||
}
|
||||
}
|
4
apps/api/src/app/auth-device/auth-device.dto.ts
Normal file
4
apps/api/src/app/auth-device/auth-device.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface AuthDeviceDto {
|
||||
createdAt: string;
|
||||
id: string;
|
||||
}
|
18
apps/api/src/app/auth-device/auth-device.module.ts
Normal file
18
apps/api/src/app/auth-device/auth-device.module.ts
Normal 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 {}
|
65
apps/api/src/app/auth-device/auth-device.service.ts
Normal file
65
apps/api/src/app/auth-device/auth-device.service.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {}
|
||||
|
@ -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;
|
||||
|
226
apps/api/src/app/auth/interfaces/simplewebauthn.ts
Normal file
226
apps/api/src/app/auth/interfaces/simplewebauthn.ts
Normal 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[];
|
||||
}
|
216
apps/api/src/app/auth/web-auth.service.ts
Normal file
216
apps/api/src/app/auth/web-auth.service.ts
Normal 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();
|
||||
}
|
||||
}
|
4
apps/api/src/app/cache/cache.controller.ts
vendored
4
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,5 +1,5 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
import { Controller, Inject, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
|
@ -1,9 +1,6 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
import {
|
||||
baseCurrency,
|
||||
benchmarks,
|
||||
isApiTokenAuthorized
|
||||
} from '@ghostfolio/helper';
|
||||
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
|
||||
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -40,7 +37,9 @@ export class ExperimentalController {
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
return benchmarks.map(({ symbol }) => {
|
||||
return symbol;
|
||||
});
|
||||
}
|
||||
|
||||
@Get('benchmarks/:symbol')
|
||||
|
@ -3,11 +3,11 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider.serv
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency, Type } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
import { OrderWithAccount } from '../order/interfaces/order-with-account.type';
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { Data } from './interfaces/data.interface';
|
||||
|
||||
@ -44,6 +44,7 @@ export class ExperimentalService {
|
||||
fee: 0,
|
||||
id: undefined,
|
||||
platformId: undefined,
|
||||
symbolProfileId: undefined,
|
||||
type: Type.BUY,
|
||||
updatedAt: undefined,
|
||||
userId: undefined
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
import { InfoService } from './info.service';
|
||||
import { InfoItem } from './interfaces/info-item.interface';
|
||||
|
||||
@Controller('info')
|
||||
export class InfoController {
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { permissions } from '@ghostfolio/helper';
|
||||
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 { InfoItem } from './interfaces/info-item.interface';
|
||||
import * as bent from 'bent';
|
||||
import { subDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class InfoService {
|
||||
@ -29,6 +31,10 @@ export class InfoService {
|
||||
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);
|
||||
}
|
||||
@ -38,10 +44,59 @@ export class InfoService {
|
||||
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
|
||||
@ -55,4 +110,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 [];
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +0,0 @@
|
||||
import { Account, Settings, User } from '@prisma/client';
|
||||
|
||||
export type UserWithSettings = User & {
|
||||
Account: Account[];
|
||||
Settings: Settings;
|
||||
};
|
@ -1,5 +0,0 @@
|
||||
import { Account, Order, Platform } from '@prisma/client';
|
||||
|
||||
type AccountWithPlatform = Account & { Platform?: Platform };
|
||||
|
||||
export type OrderWithAccount = Order & { Account?: AccountWithPlatform };
|
@ -1,7 +1,11 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -128,12 +132,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
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Order, Prisma } from '@prisma/client';
|
||||
import { DataSource, Order, Prisma } from '@prisma/client';
|
||||
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||
import { OrderWithAccount } from './interfaces/order-with-account.type';
|
||||
|
||||
@Injectable()
|
||||
export class OrderService {
|
||||
@ -53,6 +53,7 @@ export class OrderService {
|
||||
// Gather symbol data of order in the background
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: data.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: data.symbol
|
||||
}
|
||||
@ -90,6 +91,7 @@ export class OrderService {
|
||||
// Gather symbol data of order in the background
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: <DataSource>data.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: <string>data.symbol
|
||||
}
|
||||
|
@ -4,7 +4,19 @@ import {
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
|
||||
import {
|
||||
PortfolioItem,
|
||||
PortfolioOverview,
|
||||
PortfolioPerformance,
|
||||
PortfolioPosition,
|
||||
PortfolioReport
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@ -21,16 +33,10 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { RequestWithUser } from '../interfaces/request-with-user.type';
|
||||
import { PortfolioItem } from './interfaces/portfolio-item.interface';
|
||||
import { PortfolioOverview } from './interfaces/portfolio-overview.interface';
|
||||
import { PortfolioPerformance } from './interfaces/portfolio-performance.interface';
|
||||
import {
|
||||
HistoricalDataItem,
|
||||
PortfolioPositionDetail
|
||||
} from './interfaces/portfolio-position-detail.interface';
|
||||
import { PortfolioPosition } from './interfaces/portfolio-position.interface';
|
||||
import { PortfolioReport } from './interfaces/portfolio-report.interface';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
@Controller('portfolio')
|
||||
@ -309,18 +315,6 @@ export class PortfolioController {
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
|
||||
let report = await portfolio.getReport();
|
||||
|
||||
if (
|
||||
impersonationId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
) {
|
||||
// TODO: Filter out absolute numbers
|
||||
}
|
||||
|
||||
return report;
|
||||
return await portfolio.getReport();
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,17 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
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';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import {
|
||||
PortfolioItem,
|
||||
PortfolioOverview
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
add,
|
||||
format,
|
||||
@ -15,6 +20,7 @@ import {
|
||||
getYear,
|
||||
isAfter,
|
||||
isSameDay,
|
||||
parse,
|
||||
parseISO,
|
||||
setDate,
|
||||
setMonth,
|
||||
@ -26,9 +32,6 @@ 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 { DateRange } from './interfaces/date-range.type';
|
||||
import { PortfolioItem } from './interfaces/portfolio-item.interface';
|
||||
import { PortfolioOverview } from './interfaces/portfolio-overview.interface';
|
||||
import {
|
||||
HistoricalDataItem,
|
||||
PortfolioPositionDetail
|
||||
@ -73,7 +76,8 @@ 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 }
|
||||
@ -157,8 +161,8 @@ export class PortfolioService {
|
||||
return {
|
||||
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
|
||||
grossPerformancePercent: portfolioItem.grossPerformancePercent,
|
||||
marketPrice: portfolioItem.value || null,
|
||||
value: portfolioItem.value || null
|
||||
marketPrice: portfolioItem.value ?? null,
|
||||
value: portfolioItem.value - portfolioItem.investment ?? null
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -213,6 +217,8 @@ export class PortfolioService {
|
||||
transactionCount
|
||||
} = portfolio.getPositions(new Date())[aSymbol];
|
||||
|
||||
const orders = portfolio.getOrders(aSymbol);
|
||||
|
||||
const historicalData = await this.dataProviderService.getHistorical(
|
||||
[aSymbol],
|
||||
'day',
|
||||
@ -225,6 +231,7 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const historicalDataArray: HistoricalDataItem[] = [];
|
||||
let currentAveragePrice: number;
|
||||
let maxPrice = marketPrice;
|
||||
let minPrice = marketPrice;
|
||||
|
||||
@ -232,9 +239,24 @@ 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 month
|
||||
const snapshot = portfolio.get(setDate(currentDate, 1))[0]
|
||||
.positions[aSymbol];
|
||||
orders.shift();
|
||||
|
||||
if (snapshot?.averagePrice) {
|
||||
currentAveragePrice = snapshot?.averagePrice;
|
||||
}
|
||||
}
|
||||
|
||||
historicalDataArray.push({
|
||||
averagePrice,
|
||||
date,
|
||||
averagePrice: currentAveragePrice,
|
||||
value: marketPrice
|
||||
});
|
||||
|
||||
@ -288,7 +310,7 @@ export class PortfolioService {
|
||||
|
||||
if (isEmpty(historicalData)) {
|
||||
historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[aSymbol],
|
||||
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
|
||||
portfolio.getMinDate(),
|
||||
new Date()
|
||||
);
|
||||
|
57
apps/api/src/app/subscription/subscription.controller.ts
Normal file
57
apps/api/src/app/subscription/subscription.controller.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
13
apps/api/src/app/subscription/subscription.module.ts
Normal file
13
apps/api/src/app/subscription/subscription.module.ts
Normal 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 {}
|
88
apps/api/src/app/subscription/subscription.service.ts
Normal file
88
apps/api/src/app/subscription/subscription.service.ts
Normal file
@ -0,0 +1,88 @@
|
||||
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
|
||||
}
|
||||
],
|
||||
metadata: {
|
||||
user_id: userId
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,7 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface LookupItem {
|
||||
dataSource: DataSource;
|
||||
name: string;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@ -28,9 +28,12 @@ export class SymbolController {
|
||||
*/
|
||||
@Get('lookup')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async lookupSymbol(@Query() { query }): Promise<LookupItem[]> {
|
||||
public async lookupSymbol(
|
||||
@Query() { query = '' }
|
||||
): Promise<{ items: LookupItem[] }> {
|
||||
try {
|
||||
return this.symbolService.lookup(query);
|
||||
const encodedQuery = encodeURIComponent(query.toLowerCase());
|
||||
return this.symbolService.lookup(encodedQuery);
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
|
@ -1,10 +1,8 @@
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import { Currency, DataSource } from '@prisma/client';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
@ -27,62 +25,30 @@ export class SymbolService {
|
||||
};
|
||||
}
|
||||
|
||||
public async lookup(aQuery = ''): Promise<LookupItem[]> {
|
||||
const query = aQuery.toLowerCase();
|
||||
const results: LookupItem[] = [];
|
||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const results: { items: LookupItem[] } = { items: [] };
|
||||
|
||||
if (!query) {
|
||||
if (!aQuery) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const get = bent(
|
||||
`https://query1.finance.yahoo.com/v1/finance/search?q=${query}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
|
||||
// Add custom symbols
|
||||
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
|
||||
scraperConfigurations.forEach((scraperConfiguration) => {
|
||||
if (scraperConfiguration.name.toLowerCase().startsWith(query)) {
|
||||
results.push({
|
||||
name: scraperConfiguration.name,
|
||||
symbol: scraperConfiguration.symbol
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const { quotes } = await get();
|
||||
const { items } = await this.dataProviderService.search(aQuery);
|
||||
results.items = items;
|
||||
|
||||
const searchResult = quotes
|
||||
.filter(({ isYahooFinance }) => {
|
||||
return isYahooFinance;
|
||||
})
|
||||
.filter(({ quoteType }) => {
|
||||
return (
|
||||
quoteType === 'CRYPTOCURRENCY' ||
|
||||
quoteType === 'EQUITY' ||
|
||||
quoteType === 'ETF'
|
||||
);
|
||||
})
|
||||
.filter(({ quoteType, symbol }) => {
|
||||
if (quoteType === 'CRYPTOCURRENCY') {
|
||||
// Only allow cryptocurrencies in USD
|
||||
return symbol.includes('USD');
|
||||
}
|
||||
// Add custom symbols
|
||||
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
|
||||
scraperConfigurations.forEach((scraperConfiguration) => {
|
||||
if (scraperConfiguration.name.toLowerCase().startsWith(aQuery)) {
|
||||
results.items.push({
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
name: scraperConfiguration.name,
|
||||
symbol: scraperConfiguration.symbol
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
})
|
||||
.map(({ longname, shortname, symbol }) => {
|
||||
return {
|
||||
name: longname || shortname,
|
||||
symbol: convertFromYahooSymbol(symbol)
|
||||
};
|
||||
});
|
||||
|
||||
return results.concat(searchResult);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
|
@ -0,0 +1,7 @@
|
||||
import { Currency, ViewMode } from '@prisma/client';
|
||||
|
||||
export interface UserSettingsParams {
|
||||
currency?: Currency;
|
||||
userId: string;
|
||||
viewMode?: ViewMode;
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
import { Account, Currency } from '@prisma/client';
|
||||
|
||||
import { Access } from './access.interface';
|
||||
|
||||
export interface User {
|
||||
access: Access[];
|
||||
accounts: Account[];
|
||||
alias?: string;
|
||||
id: string;
|
||||
permissions: string[];
|
||||
settings: UserSettings;
|
||||
subscription: {
|
||||
expiresAt: Date;
|
||||
type: 'Trial';
|
||||
};
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
baseCurrency: Currency;
|
||||
locale: string;
|
||||
}
|
@ -1,7 +1,10 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
import { Currency, ViewMode } from '@prisma/client';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class UpdateUserSettingsDto {
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
baseCurrency: Currency;
|
||||
|
||||
@IsString()
|
||||
viewMode: ViewMode;
|
||||
}
|
||||
|
@ -1,5 +1,10 @@
|
||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
||||
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -20,7 +25,7 @@ import { User as UserModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { UserItem } from './interfaces/user-item.interface';
|
||||
import { User } from './interfaces/user.interface';
|
||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||
import { UpdateUserSettingsDto } from './update-user-settings.dto';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@ -88,9 +93,20 @@ export class UserController {
|
||||
);
|
||||
}
|
||||
|
||||
return await this.userService.updateUserSettings({
|
||||
currency: data.currency,
|
||||
const userSettings: UserSettingsParams = {
|
||||
currency: data.baseCurrency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
};
|
||||
|
||||
if (
|
||||
hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.updateViewMode
|
||||
)
|
||||
) {
|
||||
userSettings.viewMode = data.viewMode;
|
||||
}
|
||||
|
||||
return await this.userService.updateUserSettings(userSettings);
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,14 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import {
|
||||
getPermissions,
|
||||
locale,
|
||||
permissions,
|
||||
resetHours
|
||||
} from '@ghostfolio/helper';
|
||||
import { locale } from '@ghostfolio/common/config';
|
||||
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 } from '@prisma/client';
|
||||
import { add } from 'date-fns';
|
||||
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
|
||||
import { isBefore } from 'date-fns';
|
||||
|
||||
import { UserWithSettings } from '../interfaces/user-with-settings';
|
||||
import { User as IUser } from './interfaces/user.interface';
|
||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
@ -28,8 +25,9 @@ export class UserService {
|
||||
Account,
|
||||
alias,
|
||||
id,
|
||||
role,
|
||||
Settings
|
||||
permissions,
|
||||
Settings,
|
||||
subscription
|
||||
}: UserWithSettings): Promise<IUser> {
|
||||
const access = await this.prisma.access.findMany({
|
||||
include: {
|
||||
@ -39,15 +37,11 @@ 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 {
|
||||
alias: accessItem.User.alias,
|
||||
@ -55,14 +49,10 @@ export class UserService {
|
||||
};
|
||||
}),
|
||||
accounts: Account,
|
||||
permissions: currentPermissions,
|
||||
settings: {
|
||||
baseCurrency: Settings?.currency || UserService.DEFAULT_CURRENCY,
|
||||
locale
|
||||
},
|
||||
subscription: {
|
||||
expiresAt: resetHours(add(new Date(), { days: 7 })),
|
||||
type: 'Trial'
|
||||
locale,
|
||||
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
||||
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -70,25 +60,64 @@ export class UserService {
|
||||
public async user(
|
||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
||||
): Promise<UserWithSettings | null> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
include: { Account: true, Settings: true },
|
||||
const userFromDatabase = await this.prisma.user.findUnique({
|
||||
include: { Account: true, Settings: true, Subscription: true },
|
||||
where: userWhereUniqueInput
|
||||
});
|
||||
|
||||
if (user?.Settings) {
|
||||
if (!user.Settings.currency) {
|
||||
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
|
||||
user.Settings.currency = UserService.DEFAULT_CURRENCY;
|
||||
userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
|
||||
}
|
||||
} else if (user) {
|
||||
} else if (userFromDatabase) {
|
||||
// Set default settings if needed
|
||||
user.Settings = {
|
||||
userFromDatabase.Settings = {
|
||||
currency: UserService.DEFAULT_CURRENCY,
|
||||
updatedAt: new Date(),
|
||||
userId: user?.id
|
||||
userId: userFromDatabase?.id,
|
||||
viewMode: ViewMode.DEFAULT
|
||||
};
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (userFromDatabase?.Subscription?.length > 0) {
|
||||
const latestSubscription = userFromDatabase.Subscription.reduce(
|
||||
(a, b) => {
|
||||
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||
}
|
||||
);
|
||||
|
||||
user.subscription = {
|
||||
expiresAt: latestSubscription.expiresAt,
|
||||
type: isBefore(new Date(), latestSubscription.expiresAt)
|
||||
? SubscriptionType.Premium
|
||||
: SubscriptionType.Basic
|
||||
};
|
||||
} else {
|
||||
user.subscription = {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -192,11 +221,9 @@ export class UserService {
|
||||
|
||||
public async updateUserSettings({
|
||||
currency,
|
||||
userId
|
||||
}: {
|
||||
currency: Currency;
|
||||
userId: string;
|
||||
}) {
|
||||
userId,
|
||||
viewMode
|
||||
}: UserSettingsParams) {
|
||||
await this.prisma.settings.upsert({
|
||||
create: {
|
||||
currency,
|
||||
@ -204,10 +231,12 @@ export class UserService {
|
||||
connect: {
|
||||
id: userId
|
||||
}
|
||||
}
|
||||
},
|
||||
viewMode
|
||||
},
|
||||
update: {
|
||||
currency
|
||||
currency,
|
||||
viewMode
|
||||
},
|
||||
where: {
|
||||
userId: userId
|
||||
|
@ -1,7 +1,4 @@
|
||||
import {
|
||||
PortfolioItem,
|
||||
Position
|
||||
} from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
|
||||
import { PortfolioItem, Position } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Order } from '../order';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { EvaluationResult } from './evaluation-result.interface';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Account, Currency, Platform } from '@prisma/client';
|
||||
import { Account, Currency, Platform, SymbolProfile } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { IOrder } from '../services/interfaces/interfaces';
|
||||
@ -12,6 +12,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 +25,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;
|
||||
|
||||
@ -58,6 +60,10 @@ export class Order {
|
||||
return this.symbol;
|
||||
}
|
||||
|
||||
getSymbolProfile() {
|
||||
return this.symbolProfile;
|
||||
}
|
||||
|
||||
public getTotal() {
|
||||
return this.total;
|
||||
}
|
||||
|
@ -1,65 +1,102 @@
|
||||
import { baseCurrency, getUtc, getYesterday } from '@ghostfolio/helper';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { AccountType, Currency, DataSource, Role, Type } from '@prisma/client';
|
||||
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
|
||||
import { getUtc, getYesterday } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AccountType,
|
||||
Currency,
|
||||
DataSource,
|
||||
Role,
|
||||
Type,
|
||||
ViewMode
|
||||
} from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { ConfigurationService } from '../services/configuration.service';
|
||||
import { DataProviderService } from '../services/data-provider.service';
|
||||
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from '../services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||
import { MarketState } from '../services/interfaces/interfaces';
|
||||
import { PrismaService } from '../services/prisma.service';
|
||||
import { RulesService } from '../services/rules.service';
|
||||
import { Portfolio } from './portfolio';
|
||||
|
||||
jest.mock('../services/data-provider.service', () => {
|
||||
return {
|
||||
DataProviderService: jest.fn().mockImplementation(() => {
|
||||
const today = format(new Date(), 'yyyy-MM-dd');
|
||||
const yesterday = format(getYesterday(), 'yyyy-MM-dd');
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
return Promise.resolve({
|
||||
BTCUSD: {
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
exchange: UNKNOWN_KEY,
|
||||
marketPrice: 57973.008,
|
||||
marketState: MarketState.open,
|
||||
name: 'Bitcoin USD',
|
||||
type: 'Cryptocurrency'
|
||||
},
|
||||
ETHUSD: {
|
||||
currency: Currency.USD,
|
||||
dataSource: DataSource.YAHOO,
|
||||
exchange: UNKNOWN_KEY,
|
||||
marketPrice: 3915.337,
|
||||
marketState: MarketState.open,
|
||||
name: 'Ethereum USD',
|
||||
type: 'Cryptocurrency'
|
||||
}
|
||||
});
|
||||
},
|
||||
getHistorical: () => {
|
||||
return Promise.resolve({
|
||||
BTCUSD: {
|
||||
[yesterday]: 56710.122,
|
||||
[today]: 57973.008
|
||||
},
|
||||
ETHUSD: {
|
||||
[yesterday]: 3641.984,
|
||||
[today]: 3915.337
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../services/exchange-rate-data.service', () => {
|
||||
return {
|
||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
initialize: () => Promise.resolve(),
|
||||
toCurrency: (value: number) => value
|
||||
};
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../services/data-provider.service');
|
||||
jest.mock('../services/exchange-rate-data.service');
|
||||
jest.mock('../services/rules.service');
|
||||
|
||||
const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
|
||||
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
|
||||
|
||||
describe('Portfolio', () => {
|
||||
let alphaVantageService: AlphaVantageService;
|
||||
let configurationService: ConfigurationService;
|
||||
let dataProviderService: DataProviderService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let ghostfolioScraperApiService: GhostfolioScraperApiService;
|
||||
let portfolio: Portfolio;
|
||||
let prismaService: PrismaService;
|
||||
let rakutenRapidApiService: RakutenRapidApiService;
|
||||
let rulesService: RulesService;
|
||||
let yahooFinanceService: YahooFinanceService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = await Test.createTestingModule({
|
||||
imports: [],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
RulesService,
|
||||
YahooFinanceService
|
||||
]
|
||||
}).compile();
|
||||
|
||||
alphaVantageService = app.get<AlphaVantageService>(AlphaVantageService);
|
||||
configurationService = app.get<ConfigurationService>(ConfigurationService);
|
||||
dataProviderService = app.get<DataProviderService>(DataProviderService);
|
||||
exchangeRateDataService = app.get<ExchangeRateDataService>(
|
||||
ExchangeRateDataService
|
||||
dataProviderService = new DataProviderService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
ghostfolioScraperApiService = app.get<GhostfolioScraperApiService>(
|
||||
GhostfolioScraperApiService
|
||||
);
|
||||
prismaService = app.get<PrismaService>(PrismaService);
|
||||
rakutenRapidApiService = app.get<RakutenRapidApiService>(
|
||||
RakutenRapidApiService
|
||||
);
|
||||
rulesService = app.get<RulesService>(RulesService);
|
||||
yahooFinanceService = app.get<YahooFinanceService>(YahooFinanceService);
|
||||
exchangeRateDataService = new ExchangeRateDataService(null);
|
||||
rulesService = new RulesService();
|
||||
|
||||
await exchangeRateDataService.initialize();
|
||||
|
||||
@ -83,6 +120,7 @@ describe('Portfolio', () => {
|
||||
}
|
||||
],
|
||||
alias: 'Test',
|
||||
authChallenge: null,
|
||||
createdAt: new Date(),
|
||||
id: USER_ID,
|
||||
provider: null,
|
||||
@ -90,7 +128,8 @@ describe('Portfolio', () => {
|
||||
Settings: {
|
||||
currency: Currency.CHF,
|
||||
updatedAt: new Date(),
|
||||
userId: USER_ID
|
||||
userId: USER_ID,
|
||||
viewMode: ViewMode.DEFAULT
|
||||
},
|
||||
thirdPartyId: null,
|
||||
updatedAt: new Date()
|
||||
@ -151,6 +190,7 @@ describe('Portfolio', () => {
|
||||
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
|
||||
quantity: 1,
|
||||
symbol: 'BTCUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 49631.24,
|
||||
updatedAt: null,
|
||||
@ -170,7 +210,7 @@ describe('Portfolio', () => {
|
||||
expect(details).toMatchObject({
|
||||
BTCUSD: {
|
||||
accounts: {
|
||||
Other: {
|
||||
[UNKNOWN_KEY]: {
|
||||
/*current: exchangeRateDataService.toCurrency(
|
||||
1 * 49631.24,
|
||||
Currency.USD,
|
||||
@ -183,8 +223,11 @@ describe('Portfolio', () => {
|
||||
)
|
||||
}
|
||||
},
|
||||
allocationCurrent: 1,
|
||||
allocationInvestment: 1,
|
||||
countries: [],
|
||||
currency: Currency.USD,
|
||||
exchange: 'Other',
|
||||
exchange: UNKNOWN_KEY,
|
||||
grossPerformance: 0,
|
||||
grossPerformancePercent: 0,
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
@ -192,12 +235,10 @@ describe('Portfolio', () => {
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),
|
||||
// marketPrice: 57973.008,
|
||||
marketPrice: 57973.008,
|
||||
marketState: MarketState.open,
|
||||
name: 'Bitcoin USD',
|
||||
quantity: 1,
|
||||
// shareCurrent: 0.9999999559148652,
|
||||
shareInvestment: 1,
|
||||
symbol: 'BTCUSD',
|
||||
transactionCount: 1,
|
||||
type: 'Cryptocurrency'
|
||||
@ -252,6 +293,7 @@ describe('Portfolio', () => {
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
@ -271,7 +313,7 @@ describe('Portfolio', () => {
|
||||
expect(details).toMatchObject({
|
||||
ETHUSD: {
|
||||
accounts: {
|
||||
Other: {
|
||||
[UNKNOWN_KEY]: {
|
||||
/*current: exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
@ -284,8 +326,11 @@ describe('Portfolio', () => {
|
||||
)
|
||||
}
|
||||
},
|
||||
// allocationCurrent: 1,
|
||||
allocationInvestment: 1,
|
||||
countries: [],
|
||||
currency: Currency.USD,
|
||||
exchange: 'Other',
|
||||
exchange: UNKNOWN_KEY,
|
||||
// grossPerformance: 0,
|
||||
// grossPerformancePercent: 0,
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
@ -293,11 +338,9 @@ describe('Portfolio', () => {
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),
|
||||
// marketPrice: 57973.008,
|
||||
marketPrice: 3915.337,
|
||||
name: 'Ethereum USD',
|
||||
quantity: 0.2,
|
||||
// shareCurrent: 1,
|
||||
shareInvestment: 1,
|
||||
transactionCount: 1,
|
||||
symbol: 'ETHUSD',
|
||||
type: 'Cryptocurrency'
|
||||
@ -326,7 +369,7 @@ describe('Portfolio', () => {
|
||||
baseCurrency
|
||||
),
|
||||
investmentInOriginalCurrency: 0.2 * 991.49,
|
||||
// marketPrice: 0,
|
||||
// marketPrice: 3915.337,
|
||||
quantity: 0.2
|
||||
}
|
||||
});
|
||||
@ -347,6 +390,7 @@ describe('Portfolio', () => {
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
@ -363,6 +407,7 @@ describe('Portfolio', () => {
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
quantity: 0.3,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 1050,
|
||||
updatedAt: null,
|
||||
@ -402,7 +447,7 @@ describe('Portfolio', () => {
|
||||
baseCurrency
|
||||
),
|
||||
investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050,
|
||||
// marketPrice: 0,
|
||||
// marketPrice: 3641.984,
|
||||
quantity: 0.5
|
||||
}
|
||||
});
|
||||
@ -423,6 +468,7 @@ describe('Portfolio', () => {
|
||||
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
|
||||
quantity: 0.05614682,
|
||||
symbol: 'BTCUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 3562.089535970158,
|
||||
updatedAt: null,
|
||||
@ -439,6 +485,7 @@ describe('Portfolio', () => {
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
@ -512,6 +559,7 @@ describe('Portfolio', () => {
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 991.49,
|
||||
updatedAt: null,
|
||||
@ -528,6 +576,7 @@ describe('Portfolio', () => {
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
quantity: 0.1,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.SELL,
|
||||
unitPrice: 1050,
|
||||
updatedAt: null,
|
||||
@ -544,6 +593,7 @@ describe('Portfolio', () => {
|
||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||
quantity: 0.2,
|
||||
symbol: 'ETHUSD',
|
||||
symbolProfileId: null,
|
||||
type: Type.BUY,
|
||||
unitPrice: 1050,
|
||||
updatedAt: null,
|
||||
@ -551,8 +601,7 @@ describe('Portfolio', () => {
|
||||
}
|
||||
]);
|
||||
|
||||
// TODO: Fix
|
||||
/*expect(portfolio.getCommittedFunds()).toEqual(
|
||||
expect(portfolio.getCommittedFunds()).toEqual(
|
||||
exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49,
|
||||
Currency.USD,
|
||||
@ -568,7 +617,7 @@ describe('Portfolio', () => {
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
)
|
||||
);*/
|
||||
);
|
||||
|
||||
expect(portfolio.getFees()).toEqual(
|
||||
exchangeRateDataService.toCurrency(3, Currency.USD, baseCurrency)
|
||||
@ -580,12 +629,11 @@ describe('Portfolio', () => {
|
||||
(0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050) / (0.2 - 0.1 + 0.2),
|
||||
currency: Currency.USD,
|
||||
firstBuyDate: '2018-01-05T00:00:00.000Z',
|
||||
// TODO: Fix
|
||||
/*investment: exchangeRateDataService.toCurrency(
|
||||
investment: exchangeRateDataService.toCurrency(
|
||||
0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
|
||||
Currency.USD,
|
||||
baseCurrency
|
||||
),*/
|
||||
),
|
||||
investmentInOriginalCurrency: 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
|
||||
// marketPrice: 0,
|
||||
quantity: 0.2 - 0.1 + 0.2
|
||||
@ -595,8 +643,4 @@ describe('Portfolio', () => {
|
||||
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
prismaService.$disconnect();
|
||||
});
|
||||
});
|
||||
|
@ -1,8 +1,18 @@
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
PortfolioItem,
|
||||
Position
|
||||
} from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
|
||||
import { getToday, getYesterday, resetHours } from '@ghostfolio/helper';
|
||||
PortfolioPerformance,
|
||||
PortfolioPosition,
|
||||
PortfolioReport,
|
||||
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 { Prisma } from '@prisma/client';
|
||||
import { continents, countries } from 'countries-list';
|
||||
import {
|
||||
add,
|
||||
format,
|
||||
@ -22,18 +32,13 @@ import {
|
||||
import { cloneDeep, isEmpty } from 'lodash';
|
||||
import * as roundTo from 'round-to';
|
||||
|
||||
import { UserWithSettings } from '../app/interfaces/user-with-settings';
|
||||
import { OrderWithAccount } from '../app/order/interfaces/order-with-account.type';
|
||||
import { DateRange } from '../app/portfolio/interfaces/date-range.type';
|
||||
import { PortfolioPerformance } from '../app/portfolio/interfaces/portfolio-performance.interface';
|
||||
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioReport } from '../app/portfolio/interfaces/portfolio-report.interface';
|
||||
import { DataProviderService } from '../services/data-provider.service';
|
||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||
import { IOrder } from '../services/interfaces/interfaces';
|
||||
import { RulesService } from '../services/rules.service';
|
||||
import { PortfolioInterface } from './interfaces/portfolio.interface';
|
||||
import { Order } from './order';
|
||||
import { OrderType } from './order-type';
|
||||
import { AccountClusterRiskCurrentInvestment } from './rules/account-cluster-risk/current-investment';
|
||||
import { AccountClusterRiskInitialInvestment } from './rules/account-cluster-risk/initial-investment';
|
||||
import { AccountClusterRiskSingleAccount } from './rules/account-cluster-risk/single-account';
|
||||
@ -126,6 +131,7 @@ export class Portfolio implements PortfolioInterface {
|
||||
id,
|
||||
quantity,
|
||||
symbol,
|
||||
symbolProfile,
|
||||
type,
|
||||
unitPrice
|
||||
}) => {
|
||||
@ -138,6 +144,7 @@ export class Portfolio implements PortfolioInterface {
|
||||
id,
|
||||
quantity,
|
||||
symbol,
|
||||
symbolProfile,
|
||||
type,
|
||||
unitPrice
|
||||
})
|
||||
@ -203,6 +210,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) => {
|
||||
@ -227,19 +236,47 @@ export class Portfolio implements PortfolioInterface {
|
||||
originalValueOfSymbol *= -1;
|
||||
}
|
||||
|
||||
if (accounts[orderOfSymbol.getAccount()?.name || 'Other']?.current) {
|
||||
if (
|
||||
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current
|
||||
) {
|
||||
accounts[
|
||||
orderOfSymbol.getAccount()?.name || 'Other'
|
||||
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
|
||||
].current += currentValueOfSymbol;
|
||||
accounts[
|
||||
orderOfSymbol.getAccount()?.name || 'Other'
|
||||
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
|
||||
].original += originalValueOfSymbol;
|
||||
} else {
|
||||
accounts[orderOfSymbol.getAccount()?.name || 'Other'] = {
|
||||
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;
|
||||
@ -278,6 +315,15 @@ export class Portfolio implements PortfolioInterface {
|
||||
...data[symbol],
|
||||
accounts,
|
||||
symbol,
|
||||
allocationCurrent:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
portfolioItem.positions[symbol].quantity * now,
|
||||
data[symbol]?.currency,
|
||||
this.user.Settings.currency
|
||||
) / value,
|
||||
allocationInvestment:
|
||||
portfolioItem.positions[symbol].investment / investment,
|
||||
countries: countriesOfSymbol,
|
||||
grossPerformance: roundTo(
|
||||
portfolioItemsNow.positions[symbol].quantity * (now - before),
|
||||
2
|
||||
@ -285,15 +331,13 @@ export class Portfolio implements PortfolioInterface {
|
||||
grossPerformancePercent: roundTo((now - before) / before, 4),
|
||||
investment: portfolioItem.positions[symbol].investment,
|
||||
quantity: portfolioItem.positions[symbol].quantity,
|
||||
shareCurrent:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
portfolioItem.positions[symbol].quantity * now,
|
||||
data[symbol]?.currency,
|
||||
this.user.Settings.currency
|
||||
) / value,
|
||||
shareInvestment:
|
||||
portfolioItem.positions[symbol].investment / investment,
|
||||
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
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
@ -399,10 +443,10 @@ export class Portfolio implements PortfolioInterface {
|
||||
accountClusterRisk: await this.rulesService.evaluate(
|
||||
this,
|
||||
[
|
||||
new AccountClusterRiskCurrentInvestment(
|
||||
new AccountClusterRiskInitialInvestment(
|
||||
this.exchangeRateDataService
|
||||
),
|
||||
new AccountClusterRiskInitialInvestment(
|
||||
new AccountClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService
|
||||
),
|
||||
new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
|
||||
@ -483,7 +527,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;
|
||||
}
|
||||
|
||||
@ -530,12 +580,13 @@ export class Portfolio implements PortfolioInterface {
|
||||
this.orders.push(
|
||||
new Order({
|
||||
account: order.Account,
|
||||
currency: <any>order.currency,
|
||||
currency: order.currency,
|
||||
date: order.date.toISOString(),
|
||||
fee: order.fee,
|
||||
quantity: order.quantity,
|
||||
symbol: order.symbol,
|
||||
type: <any>order.type,
|
||||
symbolProfile: order.SymbolProfile,
|
||||
type: <OrderType>order.type,
|
||||
unitPrice: order.unitPrice
|
||||
})
|
||||
);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { groupBy } from '@ghostfolio/helper';
|
||||
import { groupBy } from '@ghostfolio/common/helper';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
||||
import { RuleInterface } from './interfaces/rule.interface';
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { bool, cleanEnv, num, port, str } from 'envalid';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
|
||||
|
||||
import { Environment } from './interfaces/environment.interface';
|
||||
|
||||
@ -12,9 +13,11 @@ export class ConfigurationService {
|
||||
ACCESS_TOKEN_SALT: str(),
|
||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
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_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' }),
|
||||
@ -24,7 +27,9 @@ 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_SECRET_KEY: str({ default: '' }),
|
||||
WEB_AUTH_RP_ID: host({ default: 'localhost' })
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
|
||||
import {
|
||||
benchmarks,
|
||||
currencyPairs,
|
||||
getUtc,
|
||||
isGhostfolioScraperApiSymbol,
|
||||
resetHours
|
||||
} from '@ghostfolio/helper';
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
differenceInHours,
|
||||
format,
|
||||
@ -19,6 +19,7 @@ import {
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { DataProviderService } from './data-provider.service';
|
||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
@ -116,15 +117,13 @@ export class DataGatheringService {
|
||||
}
|
||||
}
|
||||
|
||||
public async gatherSymbols(
|
||||
aSymbolsWithStartDate: { date: Date; symbol: string }[]
|
||||
) {
|
||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||
let hasError = false;
|
||||
|
||||
for (const { date, symbol } of aSymbolsWithStartDate) {
|
||||
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
||||
try {
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[symbol],
|
||||
[{ dataSource, symbol }],
|
||||
date,
|
||||
new Date()
|
||||
);
|
||||
@ -185,20 +184,24 @@ export class DataGatheringService {
|
||||
}
|
||||
}
|
||||
|
||||
public async getCustomSymbolsToGather(startDate?: Date) {
|
||||
public async getCustomSymbolsToGather(
|
||||
startDate?: Date
|
||||
): Promise<IDataGatheringItem[]> {
|
||||
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations();
|
||||
|
||||
return scraperConfigurations.map((scraperConfiguration) => {
|
||||
return {
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
date: startDate,
|
||||
symbol: scraperConfiguration.symbol
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getBenchmarksToGather(startDate: Date) {
|
||||
const benchmarksToGather = benchmarks.map((symbol) => {
|
||||
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
|
||||
const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
@ -206,6 +209,7 @@ export class DataGatheringService {
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
benchmarksToGather.push({
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
date: startDate,
|
||||
symbol: 'GF.FEAR_AND_GREED_INDEX'
|
||||
});
|
||||
@ -214,16 +218,16 @@ export class DataGatheringService {
|
||||
return benchmarksToGather;
|
||||
}
|
||||
|
||||
private async getSymbols7D(): Promise<{ date: Date; symbol: string }[]> {
|
||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const distinctOrders = await this.prisma.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: { symbol: true }
|
||||
select: { dataSource: true, symbol: true }
|
||||
});
|
||||
|
||||
const distinctOrdersWithDate = distinctOrders
|
||||
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
|
||||
.filter((distinctOrder) => {
|
||||
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol);
|
||||
})
|
||||
@ -234,12 +238,15 @@ export class DataGatheringService {
|
||||
};
|
||||
});
|
||||
|
||||
const currencyPairsToGather = currencyPairs.map((symbol) => {
|
||||
return {
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
const currencyPairsToGather = currencyPairs.map(
|
||||
({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const customSymbolsToGather = await this.getCustomSymbolsToGather(
|
||||
startDate
|
||||
@ -253,24 +260,27 @@ export class DataGatheringService {
|
||||
];
|
||||
}
|
||||
|
||||
private async getSymbolsMax() {
|
||||
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = new Date(getUtc('2015-01-01'));
|
||||
|
||||
const customSymbolsToGather = await this.getCustomSymbolsToGather(
|
||||
startDate
|
||||
);
|
||||
|
||||
const currencyPairsToGather = currencyPairs.map((symbol) => {
|
||||
return {
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
const currencyPairsToGather = currencyPairs.map(
|
||||
({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const distinctOrders = await this.prisma.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true, symbol: true }
|
||||
select: { dataSource: true, date: true, symbol: true }
|
||||
});
|
||||
|
||||
return [
|
||||
|
@ -1,10 +1,10 @@
|
||||
import {
|
||||
isCrypto,
|
||||
isGhostfolioScraperApiSymbol,
|
||||
isRakutenRapidApiSymbol
|
||||
} from '@ghostfolio/helper';
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MarketData } from '@prisma/client';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
@ -12,16 +12,15 @@ import { AlphaVantageService } from './data-provider/alpha-vantage/alpha-vantage
|
||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { DataProviderInterface } from './interfaces/data-provider.interface';
|
||||
import { Granularity } from './interfaces/granularity.type';
|
||||
import {
|
||||
IDataGatheringItem,
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class DataProviderService implements DataProviderInterface {
|
||||
export class DataProviderService {
|
||||
public constructor(
|
||||
private readonly alphaVantageService: AlphaVantageService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
@ -120,68 +119,57 @@ export class DataProviderService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async getHistoricalRaw(
|
||||
aSymbols: string[],
|
||||
aDataGatheringItems: IDataGatheringItem[],
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
const filteredSymbols = aSymbols.filter((symbol) => {
|
||||
return !isGhostfolioScraperApiSymbol(symbol);
|
||||
});
|
||||
const result: {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {};
|
||||
|
||||
const dataOfYahoo = await this.yahooFinanceService.getHistorical(
|
||||
filteredSymbols,
|
||||
undefined,
|
||||
from,
|
||||
to
|
||||
);
|
||||
|
||||
if (aSymbols.length === 1) {
|
||||
const symbol = aSymbols[0];
|
||||
|
||||
if (
|
||||
isCrypto(symbol) &&
|
||||
this.configurationService.get('ALPHA_VANTAGE_API_KEY')
|
||||
) {
|
||||
// Merge data from Yahoo with data from Alpha Vantage
|
||||
const dataOfAlphaVantage = await this.alphaVantageService.getHistorical(
|
||||
[symbol],
|
||||
undefined,
|
||||
from,
|
||||
to
|
||||
const promises: Promise<{
|
||||
data: { [date: string]: IDataProviderHistoricalResponse };
|
||||
symbol: string;
|
||||
}>[] = [];
|
||||
for (const { dataSource, symbol } of aDataGatheringItems) {
|
||||
const dataProvider = this.getDataProvider(dataSource);
|
||||
if (dataProvider.canHandle(symbol)) {
|
||||
promises.push(
|
||||
dataProvider
|
||||
.getHistorical([symbol], undefined, from, to)
|
||||
.then((data) => ({ data: data?.[symbol], symbol }))
|
||||
);
|
||||
|
||||
return {
|
||||
[symbol]: {
|
||||
...dataOfYahoo[symbol],
|
||||
...dataOfAlphaVantage[symbol]
|
||||
}
|
||||
};
|
||||
} else if (isGhostfolioScraperApiSymbol(symbol)) {
|
||||
const dataOfGhostfolioScraperApi = await this.ghostfolioScraperApiService.getHistorical(
|
||||
[symbol],
|
||||
undefined,
|
||||
from,
|
||||
to
|
||||
);
|
||||
|
||||
return dataOfGhostfolioScraperApi;
|
||||
} else if (
|
||||
isRakutenRapidApiSymbol(symbol) &&
|
||||
this.configurationService.get('RAKUTEN_RAPID_API_KEY')
|
||||
) {
|
||||
const dataOfRakutenRapidApi = await this.rakutenRapidApiService.getHistorical(
|
||||
[symbol],
|
||||
undefined,
|
||||
from,
|
||||
to
|
||||
);
|
||||
|
||||
return dataOfRakutenRapidApi;
|
||||
}
|
||||
}
|
||||
|
||||
return dataOfYahoo;
|
||||
const allData = await Promise.all(promises);
|
||||
for (const { data, symbol } of allData) {
|
||||
result[symbol] = data;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string) {
|
||||
return this.getDataProvider(
|
||||
<DataSource>this.configurationService.get('DATA_SOURCES')[0]
|
||||
).search(aSymbol);
|
||||
}
|
||||
|
||||
private getDataProvider(providerName: DataSource) {
|
||||
switch (providerName) {
|
||||
case DataSource.ALPHA_VANTAGE:
|
||||
return this.alphaVantageService;
|
||||
case DataSource.GHOSTFOLIO:
|
||||
return this.ghostfolioScraperApiService;
|
||||
case DataSource.RAKUTEN:
|
||||
return this.rakutenRapidApiService;
|
||||
case DataSource.YAHOO:
|
||||
return this.yahooFinanceService;
|
||||
default:
|
||||
throw new Error('No data provider has been found.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { isAfter, isBefore, parse } from 'date-fns';
|
||||
|
||||
import { ConfigurationService } from '../../configuration.service';
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import { Granularity } from '../../interfaces/granularity.type';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
@ -22,6 +24,10 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
});
|
||||
}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY');
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
@ -77,7 +83,17 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
}
|
||||
}
|
||||
|
||||
public search(aSymbol: string) {
|
||||
return this.alphaVantage.data.search(aSymbol);
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
const result = await this.alphaVantage.data.search(aSymbol);
|
||||
|
||||
return {
|
||||
items: result?.bestMatches?.map((bestMatch) => {
|
||||
return {
|
||||
dataSource: DataSource.ALPHA_VANTAGE,
|
||||
name: bestMatch['2. name'],
|
||||
symbol: bestMatch['1. symbol']
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,8 @@
|
||||
import { getYesterday } from '@ghostfolio/helper';
|
||||
import {
|
||||
getYesterday,
|
||||
isGhostfolioScraperApiSymbol
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
@ -6,7 +10,6 @@ import * as cheerio from 'cheerio';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import { Granularity } from '../../interfaces/granularity.type';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
@ -21,6 +24,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return isGhostfolioScraperApiSymbol(symbol);
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
@ -117,6 +124,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
return [];
|
||||
}
|
||||
|
||||
public async search(aSymbol: string) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
private extractNumberFromString(aString: string): number {
|
||||
try {
|
||||
const [numberString] = aString.match(
|
||||
|
@ -1,4 +1,9 @@
|
||||
import { getToday, getYesterday } from '@ghostfolio/helper';
|
||||
import {
|
||||
getToday,
|
||||
getYesterday,
|
||||
isRakutenRapidApiSymbol
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
@ -6,7 +11,6 @@ import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||
|
||||
import { ConfigurationService } from '../../configuration.service';
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import { Granularity } from '../../interfaces/granularity.type';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
@ -24,6 +28,13 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return (
|
||||
isRakutenRapidApiSymbol(symbol) &&
|
||||
!!this.configurationService.get('RAKUTEN_RAPID_API_KEY')
|
||||
);
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
@ -117,6 +128,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async search(aSymbol: string) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
public setPrisma(aPrismaService: PrismaService) {
|
||||
this.prisma = aPrismaService;
|
||||
}
|
||||
|
||||
private async getFearAndGreedIndex(): Promise<{
|
||||
now: { value: number; valueText: string };
|
||||
previousClose: { value: number; valueText: string };
|
||||
@ -147,8 +166,4 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public setPrisma(aPrismaService: PrismaService) {
|
||||
this.prisma = aPrismaService;
|
||||
}
|
||||
}
|
||||
|
@ -1,24 +0,0 @@
|
||||
/*
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { YahooFinanceService } from './yahoo-finance.service';
|
||||
|
||||
describe('AppService', () => {
|
||||
let service: YahooFinanceService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = await Test.createTestingModule({
|
||||
imports: [],
|
||||
providers: [YahooFinanceService]
|
||||
}).compile();
|
||||
|
||||
service = app.get<YahooFinanceService>(YahooFinanceService);
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should return data for USDCHF', () => {
|
||||
expect(service.get(['USDCHF'])).toEqual('{}');
|
||||
});
|
||||
});
|
||||
});
|
||||
*/
|
@ -1,17 +1,18 @@
|
||||
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/helper';
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import { format } from 'date-fns';
|
||||
import * as yahooFinance from 'yahoo-finance';
|
||||
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import { Granularity } from '../../interfaces/granularity.type';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
Industry,
|
||||
MarketState,
|
||||
Sector,
|
||||
Type
|
||||
} from '../../interfaces/interfaces';
|
||||
import {
|
||||
@ -21,8 +22,14 @@ import {
|
||||
|
||||
@Injectable()
|
||||
export class YahooFinanceService implements DataProviderInterface {
|
||||
private yahooFinanceHostname = 'https://query1.finance.yahoo.com';
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
@ -61,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;
|
||||
@ -135,6 +132,49 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
}
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
let items = [];
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
|
||||
const result = await get();
|
||||
items = result.quotes
|
||||
.filter((quote) => {
|
||||
return quote.isYahooFinance;
|
||||
})
|
||||
.filter(({ quoteType }) => {
|
||||
return (
|
||||
quoteType === 'CRYPTOCURRENCY' ||
|
||||
quoteType === 'EQUITY' ||
|
||||
quoteType === 'ETF'
|
||||
);
|
||||
})
|
||||
.filter(({ quoteType, symbol }) => {
|
||||
if (quoteType === 'CRYPTOCURRENCY') {
|
||||
// Only allow cryptocurrencies in USD
|
||||
return symbol.includes('USD');
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map(({ longname, shortname, symbol }) => {
|
||||
return {
|
||||
dataSource: DataSource.YAHOO,
|
||||
name: longname || shortname,
|
||||
symbol: convertFromYahooSymbol(symbol)
|
||||
};
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a symbol to a Yahoo symbol
|
||||
*
|
||||
@ -170,61 +210,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
private parseExchange(aString: string): string {
|
||||
if (aString?.toLowerCase() === 'ccc') {
|
||||
return 'Other';
|
||||
return UNKNOWN_KEY;
|
||||
}
|
||||
|
||||
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.Other;
|
||||
}
|
||||
|
||||
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.Other;
|
||||
}
|
||||
|
||||
private parseType(aString: string): Type {
|
||||
if (aString?.toLowerCase() === 'cryptocurrency') {
|
||||
return Type.Cryptocurrency;
|
||||
@ -234,11 +225,11 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return Type.Stock;
|
||||
}
|
||||
|
||||
return Type.Other;
|
||||
return Type.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export const convertFromYahooSymbol = (aSymbol: string) => {
|
||||
let symbol = aSymbol.replace('-', '');
|
||||
const symbol = aSymbol.replace('-', '');
|
||||
return symbol.replace('=X', '');
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { getYesterday } from '@ghostfolio/helper';
|
||||
import { getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
|
@ -1,10 +1,14 @@
|
||||
import { Granularity } from './granularity.type';
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from './interfaces';
|
||||
|
||||
export interface DataProviderInterface {
|
||||
canHandle(symbol: string): boolean;
|
||||
|
||||
get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>;
|
||||
|
||||
getHistorical(
|
||||
@ -15,4 +19,6 @@ export interface DataProviderInterface {
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}>;
|
||||
|
||||
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
|
||||
}
|
||||
|
@ -4,9 +4,11 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ACCESS_TOKEN_SALT: string;
|
||||
ALPHA_VANTAGE_API_KEY: string;
|
||||
CACHE_TTL: number;
|
||||
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_SOCIAL_LOGIN: boolean;
|
||||
ENABLE_FEATURE_STATISTICS: boolean;
|
||||
ENABLE_FEATURE_SUBSCRIPTION: boolean;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_SECRET: string;
|
||||
@ -17,4 +19,6 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
REDIS_HOST: string;
|
||||
REDIS_PORT: number;
|
||||
ROOT_URL: string;
|
||||
STRIPE_SECRET_KEY: string;
|
||||
WEB_AUTH_RP_ID: string;
|
||||
}
|
||||
|
@ -1,35 +1,19 @@
|
||||
import { Account, Currency, DataSource, Platform } from '@prisma/client';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
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',
|
||||
Other: 'Other',
|
||||
Pharmaceutical: 'Pharmaceutical',
|
||||
Software: 'Software'
|
||||
};
|
||||
|
||||
export const MarketState = {
|
||||
closed: 'closed',
|
||||
delayed: 'delayed',
|
||||
open: 'open'
|
||||
};
|
||||
|
||||
export const Sector = {
|
||||
Consumer: 'Consumer',
|
||||
Healthcare: 'Healthcare',
|
||||
Other: 'Other',
|
||||
Technology: 'Technology'
|
||||
};
|
||||
|
||||
export const Type = {
|
||||
Cryptocurrency: 'Cryptocurrency',
|
||||
ETF: 'ETF',
|
||||
Other: 'Other',
|
||||
Stock: 'Stock'
|
||||
Stock: 'Stock',
|
||||
Unknown: UNKNOWN_KEY
|
||||
};
|
||||
|
||||
export interface IOrder {
|
||||
@ -40,6 +24,7 @@ export interface IOrder {
|
||||
id?: string;
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
symbolProfile: SymbolProfile;
|
||||
type: OrderType;
|
||||
unitPrice: number;
|
||||
}
|
||||
@ -53,21 +38,21 @@ 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;
|
||||
}
|
||||
|
||||
export type Industry = typeof Industry[keyof typeof Industry];
|
||||
export interface IDataGatheringItem {
|
||||
dataSource: DataSource;
|
||||
date?: Date;
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
export type MarketState = typeof MarketState[keyof typeof MarketState];
|
||||
|
||||
export type Sector = typeof Sector[keyof typeof Sector];
|
||||
|
||||
export type Type = typeof Type[keyof typeof Type];
|
||||
|
@ -16,8 +16,8 @@ module.exports = {
|
||||
},
|
||||
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'
|
||||
]
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {
|
||||
DEFAULT_DATE_FORMAT,
|
||||
DEFAULT_DATE_FORMAT_MONTH_YEAR
|
||||
} from '@ghostfolio/helper';
|
||||
} from '@ghostfolio/common/config';
|
||||
|
||||
export const DateFormats = {
|
||||
display: {
|
||||
|
@ -28,13 +28,6 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule)
|
||||
},
|
||||
{
|
||||
path: 'analysis',
|
||||
loadChildren: () =>
|
||||
import('./pages/analysis/analysis-page.module').then(
|
||||
(m) => m.AnalysisPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'auth',
|
||||
loadChildren: () =>
|
||||
@ -53,10 +46,10 @@ const routes: Routes = [
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'report',
|
||||
path: 'register',
|
||||
loadChildren: () =>
|
||||
import('./pages/report/report-page.module').then(
|
||||
(m) => m.ReportPageModule
|
||||
import('./pages/register/register-page.module').then(
|
||||
(m) => m.RegisterPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
@ -69,7 +62,28 @@ 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',
|
||||
loadChildren: () =>
|
||||
import('./pages/tools/tools-page.module').then((m) => m.ToolsPageModule)
|
||||
},
|
||||
{
|
||||
path: 'tools/analysis',
|
||||
loadChildren: () =>
|
||||
import('./pages/tools/analysis/analysis-page.module').then(
|
||||
(m) => m.AnalysisPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'tools/report',
|
||||
loadChildren: () =>
|
||||
import('./pages/tools/report/report-page.module').then(
|
||||
(m) => m.ReportPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'transactions',
|
||||
@ -78,11 +92,23 @@ const routes: Routes = [
|
||||
(m) => m.TransactionsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'webauthn',
|
||||
loadChildren: () =>
|
||||
import('./pages/webauthn/webauthn-page.module').then(
|
||||
(m) => m.WebauthnPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'zen',
|
||||
loadChildren: () =>
|
||||
import('./pages/zen/zen-page.module').then((m) => m.ZenPageModule)
|
||||
},
|
||||
{
|
||||
// wildcard, if requested url doesn't match any paths for routes defined
|
||||
// earlier
|
||||
path: '**',
|
||||
redirectTo: '/home',
|
||||
redirectTo: 'home',
|
||||
pathMatch: 'full'
|
||||
}
|
||||
];
|
||||
|
@ -4,6 +4,7 @@
|
||||
[currentRoute]="currentRoute"
|
||||
[info]="info"
|
||||
[user]="user"
|
||||
(signOut)="onSignOut()"
|
||||
></gf-header>
|
||||
</header>
|
||||
|
||||
@ -11,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>
|
||||
@ -25,7 +28,10 @@
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
|
||||
<footer class="footer d-flex justify-content-center position-absolute w-100">
|
||||
<footer
|
||||
*ngIf="currentRoute === 'start' || deviceType !== 'mobile'"
|
||||
class="footer d-flex justify-content-center position-absolute w-100"
|
||||
>
|
||||
<div class="container text-center">
|
||||
<div>
|
||||
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
||||
|
@ -5,8 +5,6 @@
|
||||
padding: 5rem 0;
|
||||
|
||||
.create-account-box {
|
||||
border: 1px solid rgba(var(--palette-primary-500), 1);
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 90%;
|
||||
|
||||
|
@ -5,22 +5,19 @@ import {
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { InfoItem } from '@ghostfolio/api/app/info/interfaces/info-item.interface';
|
||||
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
|
||||
import {
|
||||
hasPermission,
|
||||
permissions,
|
||||
primaryColorHex,
|
||||
secondaryColorHex
|
||||
} from '@ghostfolio/helper';
|
||||
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
|
||||
import { primaryColorHex, secondaryColorHex } 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';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { filter, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { environment } from '../environments/environment';
|
||||
import { DataService } from './services/data.service';
|
||||
import { TokenStorageService } from './services/token-storage.service';
|
||||
import { UserService } from './services/user/user.service';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-root',
|
||||
@ -32,25 +29,29 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
public canCreateAccount: boolean;
|
||||
public currentRoute: string;
|
||||
public currentYear = new Date().getFullYear();
|
||||
public deviceType: string;
|
||||
public info: InfoItem;
|
||||
public isLoggedIn = false;
|
||||
public user: User;
|
||||
public version = environment.version;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private cd: ChangeDetectorRef,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private materialCssVarsService: MaterialCssVarsService,
|
||||
private router: Router,
|
||||
private tokenStorageService: TokenStorageService
|
||||
private tokenStorageService: TokenStorageService,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.initializeTheme();
|
||||
this.user = undefined;
|
||||
}
|
||||
|
||||
public ngOnInit() {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.dataService.fetchInfo().subscribe((info) => {
|
||||
this.info = info;
|
||||
});
|
||||
@ -58,32 +59,42 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.router.events
|
||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
.subscribe(() => {
|
||||
this.currentRoute = this.router.url.toString().substring(1);
|
||||
const urlTree = this.router.parseUrl(this.router.url);
|
||||
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
|
||||
const urlSegments = urlSegmentGroup.segments;
|
||||
this.currentRoute = urlSegments[0].path;
|
||||
});
|
||||
|
||||
this.tokenStorageService
|
||||
.onChangeHasToken()
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.isLoggedIn = !!this.tokenStorageService.getToken();
|
||||
.subscribe((state) => {
|
||||
this.user = state.user;
|
||||
|
||||
if (this.isLoggedIn) {
|
||||
this.dataService.fetchUser().subscribe((user) => {
|
||||
this.user = user;
|
||||
this.canCreateAccount = hasPermission(
|
||||
this.user?.permissions,
|
||||
permissions.createUserAccount
|
||||
);
|
||||
|
||||
this.canCreateAccount = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createUserAccount
|
||||
);
|
||||
|
||||
this.cd.markForCheck();
|
||||
});
|
||||
} else {
|
||||
this.user = null;
|
||||
}
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public onCreateAccount() {
|
||||
this.tokenStorageService.signOut();
|
||||
}
|
||||
|
||||
public onSignOut() {
|
||||
this.tokenStorageService.signOut();
|
||||
this.userService.remove();
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private initializeTheme() {
|
||||
this.materialCssVarsService.setDarkTheme(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
@ -96,14 +107,4 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
|
||||
this.materialCssVarsService.setAccentColor(secondaryColorHex);
|
||||
}
|
||||
|
||||
public onCreateAccount() {
|
||||
this.tokenStorageService.signOut();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
||||
|
@ -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 } 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';
|
||||
@ -22,7 +25,7 @@ import { AppComponent } from './app.component';
|
||||
import { GfHeaderModule } from './components/header/header.module';
|
||||
import { authInterceptorProviders } from './core/auth.interceptor';
|
||||
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
|
||||
import { LanguageManager } from './core/language-manager.service';
|
||||
import { LanguageService } from './core/language.service';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
@ -34,6 +37,7 @@ import { LanguageManager } from './core/language-manager.service';
|
||||
HttpClientModule,
|
||||
MarkdownModule.forRoot(),
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MaterialCssVarsModule.forRoot({
|
||||
darkThemeClass: 'is-dark-theme',
|
||||
isAutoContrast: true,
|
||||
@ -41,16 +45,17 @@ import { LanguageManager } from './core/language-manager.service';
|
||||
}),
|
||||
MatNativeDateModule,
|
||||
MatSnackBarModule,
|
||||
NgxSkeletonLoaderModule
|
||||
NgxSkeletonLoaderModule,
|
||||
NgxStripeModule.forRoot(environment.stripePublicKey)
|
||||
],
|
||||
providers: [
|
||||
authInterceptorProviders,
|
||||
httpResponseInterceptorProviders,
|
||||
LanguageManager,
|
||||
LanguageService,
|
||||
{
|
||||
provide: DateAdapter,
|
||||
useClass: CustomDateAdapter,
|
||||
deps: [LanguageManager, MAT_DATE_LOCALE, Platform]
|
||||
deps: [LanguageService, MAT_DATE_LOCALE, Platform]
|
||||
},
|
||||
{ provide: MAT_DATE_FORMATS, useValue: DateFormats }
|
||||
],
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { Access } from '@ghostfolio/api/app/access/interfaces/access.interface';
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-access-table',
|
||||
|
@ -1,24 +1,18 @@
|
||||
<table
|
||||
class="gf-table w-100"
|
||||
matSort
|
||||
matSortActive="account"
|
||||
matSortDirection="desc"
|
||||
mat-table
|
||||
[dataSource]="dataSource"
|
||||
>
|
||||
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||
<ng-container matColumnDef="account">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||
Name
|
||||
</th>
|
||||
<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>
|
||||
|
||||
<ng-container matColumnDef="platform">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||
Platform
|
||||
</th>
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Platform</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex">
|
||||
<gf-symbol-icon
|
||||
@ -60,8 +54,10 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="transactions">
|
||||
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Transactions</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
<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>
|
||||
|
@ -6,13 +6,9 @@ import {
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Account as AccountModel } from '@prisma/client';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
|
||||
@ -32,8 +28,6 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
@Output() accountDeleted = new EventEmitter<string>();
|
||||
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
||||
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
|
||||
public displayedColumns = [];
|
||||
public isLoading = true;
|
||||
@ -41,11 +35,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private dialog: MatDialog,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router
|
||||
) {}
|
||||
public constructor() {}
|
||||
|
||||
public ngOnInit() {}
|
||||
|
||||
@ -60,7 +50,6 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
|
||||
if (this.accounts) {
|
||||
this.dataSource = new MatTableDataSource(this.accounts);
|
||||
this.dataSource.sort = this.sort;
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
@ -22,7 +21,6 @@ import { AccountsTableComponent } from './accounts-table.component';
|
||||
MatButtonModule,
|
||||
MatInputModule,
|
||||
MatMenuModule,
|
||||
MatSortModule,
|
||||
MatTableModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
RouterModule
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
OnChanges,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { resolveFearAndGreedIndex } from '@ghostfolio/helper';
|
||||
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-fear-and-greed-index',
|
||||
|
@ -8,31 +8,40 @@
|
||||
class="d-none d-sm-block"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute?.startsWith('home') ? 'primary' : null"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen',
|
||||
'text-decoration-underline':
|
||||
currentRoute === 'home' || currentRoute === 'zen'
|
||||
}"
|
||||
[routerLink]="['/']"
|
||||
>Overview</a
|
||||
>
|
||||
<a
|
||||
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute?.startsWith('analysis') ? 'primary' : null"
|
||||
[routerLink]="['/analysis']"
|
||||
>Analysis</a
|
||||
[ngClass]="{
|
||||
'font-weight-bold':
|
||||
currentRoute === 'analysis' ||
|
||||
currentRoute === 'report' ||
|
||||
currentRoute === 'tools',
|
||||
'text-decoration-underline':
|
||||
currentRoute === 'analysis' ||
|
||||
currentRoute === 'report' ||
|
||||
currentRoute === 'tools'
|
||||
}"
|
||||
[routerLink]="['/tools']"
|
||||
>Tools</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute?.startsWith('report') ? 'primary' : null"
|
||||
[routerLink]="['/report']"
|
||||
>X-ray</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute?.startsWith('transactions') ? 'primary' : null"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'transactions',
|
||||
'text-decoration-underline': currentRoute === 'transactions'
|
||||
}"
|
||||
[routerLink]="['/transactions']"
|
||||
>Transactions</a
|
||||
>
|
||||
@ -40,7 +49,10 @@
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute?.startsWith('accounts') ? 'primary' : null"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'accounts',
|
||||
'text-decoration-underline': currentRoute === 'accounts'
|
||||
}"
|
||||
[routerLink]="['/accounts']"
|
||||
>Accounts</a
|
||||
>
|
||||
@ -49,7 +61,10 @@
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute?.startsWith('admin') ? 'primary' : null"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'admin',
|
||||
'text-decoration-underline': currentRoute === 'admin'
|
||||
}"
|
||||
[routerLink]="['/admin']"
|
||||
>Admin Control</a
|
||||
>
|
||||
@ -57,7 +72,10 @@
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute?.startsWith('resources') ? 'primary' : null"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'resources',
|
||||
'text-decoration-underline': currentRoute === 'resources'
|
||||
}"
|
||||
[routerLink]="['/resources']"
|
||||
>Resources</a
|
||||
>
|
||||
@ -66,7 +84,10 @@
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute?.startsWith('pricing') ? 'primary' : null"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'pricing',
|
||||
'text-decoration-underline': currentRoute === 'pricing'
|
||||
}"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
@ -74,7 +95,10 @@
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute?.startsWith('about') ? 'primary' : null"
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'about',
|
||||
'text-decoration-underline': currentRoute === 'about'
|
||||
}"
|
||||
[routerLink]="['/about']"
|
||||
>About</a
|
||||
>
|
||||
@ -135,27 +159,25 @@
|
||||
<hr class="m-0" />
|
||||
</ng-container>
|
||||
<a
|
||||
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
|
||||
class="d-block d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('analysis') }"
|
||||
[routerLink]="['/analysis']"
|
||||
>Analysis</a
|
||||
>
|
||||
<a
|
||||
class="d-block d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('report') }"
|
||||
[routerLink]="['/report']"
|
||||
>X-ray</a
|
||||
[ngClass]="{
|
||||
'font-weight-bold':
|
||||
currentRoute === 'analysis' ||
|
||||
currentRoute === 'report' ||
|
||||
currentRoute === 'tools'
|
||||
}"
|
||||
[routerLink]="['/tools']"
|
||||
>Tools</a
|
||||
>
|
||||
<a
|
||||
class="d-block d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute?.startsWith('transactions')
|
||||
'font-weight-bold': currentRoute === 'transactions'
|
||||
}"
|
||||
[routerLink]="['/transactions']"
|
||||
>Transactions</a
|
||||
@ -164,7 +186,7 @@
|
||||
class="d-block d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('accounts') }"
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
|
||||
[routerLink]="['/accounts']"
|
||||
>Accounts</a
|
||||
>
|
||||
@ -172,16 +194,16 @@
|
||||
class="align-items-center d-flex"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('account') }"
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
|
||||
[routerLink]="['/account']"
|
||||
>Ghostfolio Account</a
|
||||
>My Ghostfolio</a
|
||||
>
|
||||
<a
|
||||
*ngIf="hasPermissionToAccessAdminControl"
|
||||
class="d-block d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('admin') }"
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
|
||||
[routerLink]="['/admin']"
|
||||
>Admin Control</a
|
||||
>
|
||||
@ -191,7 +213,7 @@
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute?.startsWith('resources')
|
||||
'font-weight-bold': currentRoute === 'resources'
|
||||
}"
|
||||
[routerLink]="['/resources']"
|
||||
>Resources</a
|
||||
@ -201,7 +223,7 @@
|
||||
class="d-block d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('pricing') }"
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
|
||||
[routerLink]="['/pricing']"
|
||||
>Pricing</a
|
||||
>
|
||||
@ -209,7 +231,7 @@
|
||||
class="d-block d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute?.startsWith('about') }"
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
|
||||
[routerLink]="['/about']"
|
||||
>About Ghostfolio</a
|
||||
>
|
||||
@ -231,16 +253,40 @@
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[color]="currentRoute?.startsWith('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>
|
||||
|
@ -1,18 +1,23 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges
|
||||
OnChanges,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Router } from '@angular/router';
|
||||
import { InfoItem } from '@ghostfolio/api/app/info/interfaces/info-item.interface';
|
||||
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
|
||||
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 { hasPermission, permissions } from '@ghostfolio/helper';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -27,6 +32,8 @@ export class HeaderComponent implements OnChanges {
|
||||
@Input() info: InfoItem;
|
||||
@Input() user: User;
|
||||
|
||||
@Output() signOut = new EventEmitter<void>();
|
||||
|
||||
public hasPermissionForSocialLogin: boolean;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToAccessAdminControl: boolean;
|
||||
@ -39,6 +46,7 @@ export class HeaderComponent implements OnChanges {
|
||||
private dialog: MatDialog,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private router: Router,
|
||||
private settingsStorageService: SettingsStorageService,
|
||||
private tokenStorageService: TokenStorageService
|
||||
) {
|
||||
this.impersonationStorageService
|
||||
@ -76,8 +84,7 @@ export class HeaderComponent implements OnChanges {
|
||||
}
|
||||
|
||||
public onSignOut() {
|
||||
this.tokenStorageService.signOut();
|
||||
window.location.reload();
|
||||
this.signOut.next();
|
||||
}
|
||||
|
||||
public openLoginDialog(): void {
|
||||
@ -85,7 +92,8 @@ export class HeaderComponent implements OnChanges {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
accessToken: '',
|
||||
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin
|
||||
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin,
|
||||
title: 'Sign in'
|
||||
},
|
||||
width: '30rem'
|
||||
});
|
||||
@ -110,7 +118,10 @@ export class HeaderComponent implements OnChanges {
|
||||
}
|
||||
|
||||
public setToken(aToken: string) {
|
||||
this.tokenStorageService.saveToken(aToken);
|
||||
this.tokenStorageService.saveToken(
|
||||
aToken,
|
||||
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
|
||||
);
|
||||
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ 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 '../logo/logo.module';
|
||||
import { HeaderComponent } from './header.component';
|
||||
|
@ -9,8 +9,8 @@ import {
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { PortfolioItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
|
||||
import { primaryColorRgb } from '@ghostfolio/helper';
|
||||
import { primaryColorRgb } from '@ghostfolio/common/config';
|
||||
import { PortfolioItem } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
LineController,
|
||||
LineElement,
|
||||
@ -36,10 +36,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
|
||||
public constructor() {
|
||||
Chart.register(
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
TimeScale
|
||||
);
|
||||
}
|
||||
@ -95,7 +95,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
display: false,
|
||||
display: true,
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
|
@ -9,7 +9,8 @@ import {
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/helper';
|
||||
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
|
||||
import { getBackgroundColor } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Chart,
|
||||
Filler,
|
||||
@ -62,6 +63,10 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.chart?.destroy();
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.isLoading = true;
|
||||
const benchmarkPrices = [];
|
||||
@ -76,7 +81,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
|
||||
const canvas = document.getElementById('chartCanvas');
|
||||
|
||||
var gradient = this.chartCanvas?.nativeElement
|
||||
const gradient = this.chartCanvas?.nativeElement
|
||||
?.getContext('2d')
|
||||
.createLinearGradient(
|
||||
0,
|
||||
@ -88,14 +93,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
0,
|
||||
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
|
||||
);
|
||||
gradient.addColorStop(
|
||||
1,
|
||||
getComputedStyle(document.documentElement).getPropertyValue(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? '--dark-background'
|
||||
: '--light-background'
|
||||
)
|
||||
);
|
||||
gradient.addColorStop(1, getBackgroundColor());
|
||||
|
||||
const data = {
|
||||
labels,
|
||||
@ -181,8 +179,4 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.chart?.destroy();
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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>
|
@ -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,
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -28,7 +28,7 @@ export class PerformanceChartDialog {
|
||||
public title: string;
|
||||
|
||||
public constructor(
|
||||
private cd: ChangeDetectorRef,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<PerformanceChartDialog>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
|
||||
@ -46,7 +46,7 @@ export class PerformanceChartDialog {
|
||||
|
||||
this.historicalDataItems = this.data.historicalDataItems;
|
||||
|
||||
this.historicalDataItems.forEach((historicalDataItem) => {
|
||||
this.historicalDataItems?.forEach((historicalDataItem) => {
|
||||
const benchmarkItem = historicalData.find((item) => {
|
||||
return item.date === historicalDataItem.date;
|
||||
});
|
||||
@ -75,7 +75,7 @@ export class PerformanceChartDialog {
|
||||
}
|
||||
});
|
||||
|
||||
this.cd.markForCheck();
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.title = `Performance vs. ${this.benchmarkLabel}`;
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
OnChanges,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { PortfolioOverview } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-overview.interface';
|
||||
import { PortfolioOverview } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
@Component({
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { PortfolioPerformance } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-performance.interface';
|
||||
import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { CountUp } from 'countup.js';
|
||||
import { isNumber } from 'lodash';
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
Input,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { PortfolioPerformance } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-performance.interface';
|
||||
import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
@Component({
|
||||
|
@ -1,158 +0,0 @@
|
||||
// import 'chartjs-chart-timeline';
|
||||
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { PortfolioItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
|
||||
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;
|
||||
}
|
||||
}
|
@ -7,7 +7,9 @@ import {
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { getCssVariable, getTextColor } from '@ghostfolio/common/helper';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { Tooltip } from 'chart.js';
|
||||
import { LinearScale } from 'chart.js';
|
||||
@ -27,6 +29,7 @@ export class PortfolioProportionChartComponent
|
||||
@Input() isInPercent: boolean;
|
||||
@Input() key: string;
|
||||
@Input() locale: string;
|
||||
@Input() maxItems?: number;
|
||||
@Input() positions: {
|
||||
[symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number };
|
||||
};
|
||||
@ -38,7 +41,11 @@ export class PortfolioProportionChartComponent
|
||||
|
||||
private colorMap: {
|
||||
[symbol: string]: string;
|
||||
} = {};
|
||||
} = {
|
||||
[UNKNOWN_KEY]: `rgba(${getTextColor()}, ${getCssVariable(
|
||||
'--palette-foreground-divider-alpha'
|
||||
)})`
|
||||
};
|
||||
|
||||
public constructor() {
|
||||
Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip);
|
||||
@ -74,22 +81,50 @@ export class PortfolioProportionChartComponent
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (chartData['Other']) {
|
||||
chartData['Other'].value += this.positions[symbol].value;
|
||||
if (chartData[UNKNOWN_KEY]) {
|
||||
chartData[UNKNOWN_KEY].value += this.positions[symbol].value;
|
||||
} else {
|
||||
chartData['Other'] = {
|
||||
chartData[UNKNOWN_KEY] = {
|
||||
value: this.positions[symbol].value
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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) => {
|
||||
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
|
||||
@ -134,7 +169,8 @@ export class PortfolioProportionChartComponent
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const label = context.label;
|
||||
const label =
|
||||
context.label === UNKNOWN_KEY ? 'Other' : context.label;
|
||||
|
||||
if (this.isInPercent) {
|
||||
const value = 100 * <number>context.raw;
|
||||
|
@ -34,7 +34,7 @@ export class PositionDetailDialog {
|
||||
public transactionCount: number;
|
||||
|
||||
public constructor(
|
||||
private cd: ChangeDetectorRef,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<PositionDetailDialog>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
|
||||
@ -127,7 +127,7 @@ export class PositionDetailDialog {
|
||||
this.benchmarkDataItems[0].value = this.averagePrice;
|
||||
}
|
||||
|
||||
this.cd.markForCheck();
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -35,7 +35,9 @@
|
||||
<div class="h6 m-0 text-truncate">{{ position?.name }}</div>
|
||||
<div class="d-flex">
|
||||
<span>{{ position?.symbol | gfSymbol }}</span>
|
||||
<span *ngIf="position?.exchange" class="ml-2 text-muted"
|
||||
<span
|
||||
*ngIf="position?.exchange && position?.exchange !== unknownKey"
|
||||
class="ml-2 text-muted"
|
||||
>({{ position.exchange }})</span
|
||||
>
|
||||
</div>
|
||||
|
@ -7,8 +7,9 @@ import {
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component';
|
||||
@ -27,7 +28,7 @@ export class PositionComponent implements OnDestroy, OnInit {
|
||||
@Input() position: PortfolioPosition;
|
||||
@Input() range: string;
|
||||
|
||||
public routeQueryParams: Subscription;
|
||||
public unknownKey = UNKNOWN_KEY;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -36,7 +37,7 @@ export class PositionComponent implements OnDestroy, OnInit {
|
||||
private route: ActivatedRoute,
|
||||
private router: Router
|
||||
) {
|
||||
this.routeQueryParams = route.queryParams
|
||||
route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (
|
||||
|
@ -1,7 +1,7 @@
|
||||
<table
|
||||
class="gf-table w-100"
|
||||
matSort
|
||||
matSortActive="shareCurrent"
|
||||
matSortActive="allocationCurrent"
|
||||
matSortDirection="desc"
|
||||
mat-table
|
||||
[dataSource]="dataSource"
|
||||
@ -36,7 +36,7 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="shareInvestment">
|
||||
<ng-container matColumnDef="allocationInvestment">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="justify-content-end px-1"
|
||||
@ -44,20 +44,20 @@
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
Initial Share
|
||||
Initial Allocation
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<div class="d-flex justify-content-end px-1">
|
||||
<gf-value
|
||||
[isPercent]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.shareInvestment"
|
||||
[value]="isLoading ? undefined : element.allocationInvestment"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="shareCurrent">
|
||||
<ng-container matColumnDef="allocationCurrent">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="justify-content-end px-1"
|
||||
@ -65,14 +65,14 @@
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
Current Share
|
||||
Current Allocation
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isPercent]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.shareCurrent"
|
||||
[value]="isLoading ? undefined : element.allocationCurrent"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
|
@ -13,7 +13,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 { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { Order as OrderModel } from '@prisma/client';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -73,8 +73,8 @@ export class PositionsTableComponent implements OnChanges, OnInit {
|
||||
this.displayedColumns = [
|
||||
'symbol',
|
||||
'performance',
|
||||
'shareInvestment',
|
||||
'shareCurrent'
|
||||
'allocationInvestment',
|
||||
'allocationCurrent'
|
||||
];
|
||||
|
||||
this.isLoading = true;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user