Compare commits
68 Commits
Author | SHA1 | Date | |
---|---|---|---|
b404858904 | |||
7ec033577f | |||
c8ca82b803 | |||
5db2faa17d | |||
1605fb8d48 | |||
b6a7804a26 | |||
de31381fd9 | |||
0d92b8d8bb | |||
7c6ff776d9 | |||
e37a34ed6c | |||
c4d9c00f92 | |||
3af8be89e3 | |||
0f1db71604 | |||
fce9e7fb0c | |||
6301c0c21c | |||
30bb484d5a | |||
f88ee5e5a0 | |||
73b5030972 | |||
a69a3442ab | |||
d4dff744b5 | |||
62c93ad99d | |||
1e42d6bffa | |||
002ac29f2f | |||
20ccf389e9 | |||
a2f99ed4d2 | |||
cc6320acfd | |||
261a0fb0b9 | |||
cfc05cce41 | |||
1f15b70134 | |||
a5b49b286d | |||
f3333f24da | |||
cad8f0d0e2 | |||
edd3e75730 | |||
ab68c2c69a | |||
cbb95f21a3 | |||
74d3954335 | |||
92449b0369 | |||
65276483e0 | |||
dde0d1e465 | |||
3ad802c6f5 | |||
b81377a682 | |||
545180b88f | |||
a9819b9e25 | |||
897e941e7a | |||
aef840c2cc | |||
80d0638922 | |||
494ba36d44 | |||
dab9154092 | |||
cd4a85abbf | |||
e7977a9fbb | |||
684c1e55b0 | |||
1ffa831c5c | |||
40eed0016c | |||
b58631083b | |||
e0c0425d21 | |||
bf2de5d572 | |||
2b4a1dc480 | |||
ce022c024f | |||
0f4bf529d8 | |||
dad6bf7095 | |||
86ca9eaae6 | |||
9d9b805b0e | |||
851401be1e | |||
85052bc9bc | |||
bff09f529d | |||
f438458687 | |||
7125b12631 | |||
0cbf275a2e |
2
.github/workflows/build-code.yml
vendored
2
.github/workflows/build-code.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node_version:
|
node_version:
|
||||||
- 18
|
- 16
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
2
.github/workflows/docker-image.yml
vendored
2
.github/workflows/docker-image.yml
vendored
@ -41,7 +41,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.output.labels }}
|
labels: ${{ steps.meta.output.labels }}
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
# misc
|
# misc
|
||||||
/.angular/cache
|
/.angular/cache
|
||||||
|
.env
|
||||||
.env.prod
|
.env.prod
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
@ -38,4 +39,4 @@ yarn-error.log
|
|||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
stories: [],
|
|
||||||
addons: ['@storybook/addon-essentials']
|
|
||||||
// uncomment the property below if you want to apply some webpack config globally
|
// uncomment the property below if you want to apply some webpack config globally
|
||||||
// webpackFinal: async (config, { configType }) => {
|
// webpackFinal: async (config, { configType }) => {
|
||||||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
||||||
|
|
||||||
// // Return the altered config
|
// // Return the altered config
|
||||||
// return config;
|
// return config;
|
||||||
// },
|
// },
|
||||||
|
156
CHANGELOG.md
156
CHANGELOG.md
@ -5,6 +5,156 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.246.0 - 2023-03-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for asset and asset sub class to the `EOD_HISTORICAL_DATA` data source type
|
||||||
|
- Added `isin` to the asset profile model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the _Trackinsight_ data enhancer for asset profile data by `isin`
|
||||||
|
- Improved the language localization for _Gather Data_
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the border color in the _FIRE_ calculator (dark mode)
|
||||||
|
|
||||||
|
## 1.245.0 - 2023-03-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the search functionality for the `EOD_HISTORICAL_DATA` data source type
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability of the _FIRE_ calculator
|
||||||
|
- Improved the exchange rate service for a specific date used in activities with a manual currency
|
||||||
|
- Upgraded `ngx-device-detector` from version `3.0.0` to `5.0.1`
|
||||||
|
|
||||||
|
## 1.244.0 - 2023-03-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the _FIRE_ calculator by a retirement date setting
|
||||||
|
|
||||||
|
## 1.243.0 - 2023-03-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `COINGECKO` as a default to `DATA_SOURCES`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the validation of the manual currency for the activity fee and unit price
|
||||||
|
- Harmonized the axis style of charts
|
||||||
|
- Made setting `NODE_ENV: production` optional (to avoid `ENOENT: no such file or directory` errors on startup)
|
||||||
|
- Removed the environment variable `ENABLE_FEATURE_CUSTOM_SYMBOLS`
|
||||||
|
|
||||||
|
## 1.242.0 - 2023-03-04
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Simplified the database seeding
|
||||||
|
- Upgraded `ngx-skeleton-loader` from version `5.0.0` to `7.0.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Downgraded `Node.js` from version `18` to `16` (Dockerfile) to resolve `SIGSEGV` (segmentation fault) during the `prisma` database migrations (see https://github.com/prisma/prisma/issues/10649)
|
||||||
|
|
||||||
|
## 1.241.0 - 2023-03-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Filtered activities with type `ITEM` from search results
|
||||||
|
- Considered the user's language in the _Stripe_ checkout
|
||||||
|
- Upgraded the _Stripe_ dependencies
|
||||||
|
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
|
||||||
|
|
||||||
|
## 1.240.0 - 2023-02-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Supported a manual currency for the activity unit price
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the feature graphic of the _Ghostfolio meets Umbrel_ blog post
|
||||||
|
|
||||||
|
## 1.239.0 - 2023-02-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a blog post: _Ghostfolio meets Umbrel_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the dependency `rimraf`
|
||||||
|
|
||||||
|
## 1.238.0 - 2023-02-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `COINGECKO` as a new data source type
|
||||||
|
- Added support for data provider information to the position detail dialog
|
||||||
|
- Added the configuration to publish a `linux/arm/v7` docker image
|
||||||
|
- Added _Reddit_ to the _As seen in_ section on the landing page
|
||||||
|
- Added _Umbrel_ to the _As seen in_ section on the landing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Renamed the example environment variable file from `.env` to `.env.example`
|
||||||
|
- Upgraded `zone.js` from version `0.11.8` to `0.12.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed `RangeError: Maximum call stack size exceeded` for values of type `Big` in the value redaction interceptor for the impersonation mode
|
||||||
|
- Reset the letter spacing in buttons
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Ensure that you still have a `.env` file in your project
|
||||||
|
|
||||||
|
## 1.237.0 - 2023-02-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the support details to the pricing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Increased the file size limit for the activities import
|
||||||
|
- Improved the style of the search results for symbols
|
||||||
|
- Migrated the style of `GfHeaderModule` to `@angular/material` `15` (mdc)
|
||||||
|
- Upgraded `angular` from version `15.1.2` to `15.1.5`
|
||||||
|
- Upgraded `Nx` from version `15.6.3` to `15.7.2`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with exact matches in the activities table filter (`VT` vs. `VTI`)
|
||||||
|
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
|
||||||
|
|
||||||
|
## 1.236.0 - 2023-02-17
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Beautified the ETF names in the asset profile
|
||||||
|
- Removed the data source type `GHOSTFOLIO`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
|
||||||
|
- Fixed the buying power calculation if no emergency fund is set but an activity is tagged as _Emergency Fund_
|
||||||
|
- Fixed the url on logout during the local development
|
||||||
|
|
||||||
|
## 1.235.0 - 2023-02-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the styles on the about page
|
||||||
|
- Eliminated the `GhostfolioScraperApiService`
|
||||||
|
|
||||||
## 1.234.0 - 2023-02-15
|
## 1.234.0 - 2023-02-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -718,7 +868,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- Added the alias to the `Access` database schema
|
- Added the alias to the `Access` database schema
|
||||||
- Added support for translated time distances
|
- Added support for translated time distances
|
||||||
- Added a _GitHub Action_ to create an `arm64` docker image
|
- Added a _GitHub Action_ to create an `linux/arm64` docker image
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@ -1331,7 +1481,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Beautified the ETF names in the symbol profile
|
- Beautified the ETF names in the asset profile
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@ -1756,7 +1906,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Extended the historical data view in the admin control panel
|
- Extended the historical data view in the admin control panel
|
||||||
- Upgraded _Stripe_ dependencies
|
- Upgraded the _Stripe_ dependencies
|
||||||
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
|
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
25
DEVELOPMENT.md
Normal file
25
DEVELOPMENT.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Ghostfolio Development Guide
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
### Rebase
|
||||||
|
|
||||||
|
`git rebase -i --autosquash main`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Nx
|
||||||
|
|
||||||
|
#### Upgrade
|
||||||
|
|
||||||
|
1. Run `yarn nx migrate latest`
|
||||||
|
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||||
|
1. Run `yarn nx migrate --run-migrations`
|
||||||
|
|
||||||
|
### Prisma
|
||||||
|
|
||||||
|
#### Create schema migration (local)
|
||||||
|
|
||||||
|
Run `yarn prisma migrate dev --name added_job_title`
|
||||||
|
|
||||||
|
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
@ -1,4 +1,4 @@
|
|||||||
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
FROM --platform=$BUILDPLATFORM node:16-slim as builder
|
||||||
|
|
||||||
# Build application and add additional files
|
# Build application and add additional files
|
||||||
WORKDIR /ghostfolio
|
WORKDIR /ghostfolio
|
||||||
@ -50,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
|
|||||||
RUN yarn database:generate-typings
|
RUN yarn database:generate-typings
|
||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:18-slim
|
FROM node:16-slim
|
||||||
RUN apt update && apt install -y \
|
RUN apt update && apt install -y \
|
||||||
openssl \
|
openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
47
README.md
47
README.md
@ -75,7 +75,7 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
||||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@ -85,20 +85,19 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
|
|
||||||
### Supported Environment Variables
|
### Supported Environment Variables
|
||||||
|
|
||||||
| Name | Default Value | Description |
|
| Name | Default Value | Description |
|
||||||
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||||
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application.<br />`AUD` \| `CAD` \| `CNY` \| `EUR` \| `GBP` \| `JPY` \| `RUB` \| `USD`<br />Caution: Only set if you intend to track cryptocurrencies in a non-`USD` currency. This cannot be changed later! |
|
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
|
||||||
|
|
||||||
### Run with Docker Compose
|
### Run with Docker Compose
|
||||||
|
|
||||||
@ -106,7 +105,8 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
|
|
||||||
- Basic knowledge of Docker
|
- Basic knowledge of Docker
|
||||||
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- Local copy of this Git repository (clone)
|
- Create a local copy of this Git repository (clone)
|
||||||
|
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||||
|
|
||||||
#### a. Run environment
|
#### a. Run environment
|
||||||
|
|
||||||
@ -125,13 +125,10 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
|||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Fetch Historical Data
|
#### Setup
|
||||||
|
|
||||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
|
||||||
|
|
||||||
|
1. Open http://localhost:3333 in your browser
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
|
||||||
|
|
||||||
#### Upgrade Version
|
#### Upgrade Version
|
||||||
|
|
||||||
@ -150,18 +147,18 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 16)
|
- [Node.js](https://nodejs.org/en/download) (version 16)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- A local copy of this Git repository (clone)
|
- Create a local copy of this Git repository (clone)
|
||||||
|
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
1. Run `yarn build:dev` to build the source code including the assets
|
1. Run `yarn build:dev` to build the source code including the assets
|
||||||
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
1. Run `yarn database:setup` to initialize the database schema
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
|
1. Open http://localhost:4200/en in your browser
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
|
||||||
|
|
||||||
### Start Server
|
### Start Server
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
@ -83,7 +84,7 @@ export class AccountController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||||
): Promise<Accounts> {
|
): Promise<Accounts> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
@ -101,7 +102,7 @@ export class AccountController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAccountById(
|
public async getAccountById(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
): Promise<AccountWithValue> {
|
): Promise<AccountWithValue> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
|
@ -231,12 +231,27 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
let orderBy: any = {
|
||||||
orderBy: {
|
createdAt: 'desc'
|
||||||
|
};
|
||||||
|
let where;
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
orderBy = {
|
||||||
Analytics: {
|
Analytics: {
|
||||||
updatedAt: 'desc'
|
updatedAt: 'desc'
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
|
where = {
|
||||||
|
NOT: {
|
||||||
|
Analytics: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||||
|
orderBy,
|
||||||
|
where,
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { Account: true, Order: true }
|
select: { Account: true, Order: true }
|
||||||
@ -252,19 +267,16 @@ export class AdminService {
|
|||||||
id: true,
|
id: true,
|
||||||
Subscription: true
|
Subscription: true
|
||||||
},
|
},
|
||||||
take: 30,
|
take: 30
|
||||||
where: {
|
|
||||||
NOT: {
|
|
||||||
Analytics: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return usersWithAnalytics.map(
|
return usersWithAnalytics.map(
|
||||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||||
const daysSinceRegistration =
|
const daysSinceRegistration =
|
||||||
differenceInDays(new Date(), createdAt) + 1;
|
differenceInDays(new Date(), createdAt) + 1;
|
||||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
const engagement = Analytics
|
||||||
|
? Analytics.activityCount / daysSinceRegistration
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const subscription = this.configurationService.get(
|
const subscription = this.configurationService.get(
|
||||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||||
@ -278,8 +290,8 @@ export class AdminService {
|
|||||||
id,
|
id,
|
||||||
subscription,
|
subscription,
|
||||||
accountCount: _count.Account || 0,
|
accountCount: _count.Account || 0,
|
||||||
country: Analytics.country,
|
country: Analytics?.country,
|
||||||
lastActivity: Analytics.updatedAt,
|
lastActivity: Analytics?.updatedAt,
|
||||||
transactionCount: _count.Order || 0
|
transactionCount: _count.Order || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,33 +1,46 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
public constructor(
|
public constructor(
|
||||||
readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
passReqToCallback: true,
|
||||||
secretOrKey: configurationService.get('JWT_SECRET_KEY')
|
secretOrKey: configurationService.get('JWT_SECRET_KEY')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async validate({ id }: { id: string }) {
|
public async validate(request: Request, { id }: { id: string }) {
|
||||||
try {
|
try {
|
||||||
|
const timezone = request.headers[HEADER_KEY_TIMEZONE.toLowerCase()];
|
||||||
const user = await this.userService.user({ id });
|
const user = await this.userService.user({ id });
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
await this.prismaService.analytics.upsert({
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
create: { User: { connect: { id: user.id } } },
|
const country =
|
||||||
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
|
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||||
where: { userId: user.id }
|
|
||||||
});
|
await this.prismaService.analytics.upsert({
|
||||||
|
create: { country, User: { connect: { id: user.id } } },
|
||||||
|
update: {
|
||||||
|
country,
|
||||||
|
activityCount: { increment: 1 },
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
where: { userId: user.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,6 +1,13 @@
|
|||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Param,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { ExchangeRateService } from './exchange-rate.service';
|
import { ExchangeRateService } from './exchange-rate.service';
|
||||||
|
|
||||||
@ -18,9 +25,18 @@ export class ExchangeRateController {
|
|||||||
): Promise<IDataProviderHistoricalResponse> {
|
): Promise<IDataProviderHistoricalResponse> {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
|
|
||||||
return this.exchangeRateService.getExchangeRate({
|
const exchangeRate = await this.exchangeRateService.getExchangeRate({
|
||||||
date,
|
date,
|
||||||
symbol
|
symbol
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (exchangeRate) {
|
||||||
|
return { marketPrice: exchangeRate };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -14,16 +13,14 @@ export class ExchangeRateService {
|
|||||||
}: {
|
}: {
|
||||||
date: Date;
|
date: Date;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}): Promise<IDataProviderHistoricalResponse> {
|
}): Promise<number> {
|
||||||
const [currency1, currency2] = symbol.split('-');
|
const [currency1, currency2] = symbol.split('-');
|
||||||
|
|
||||||
const marketPrice = await this.exchangeRateDataService.toCurrencyAtDate(
|
return this.exchangeRateDataService.toCurrencyAtDate(
|
||||||
1,
|
1,
|
||||||
currency1,
|
currency1,
|
||||||
currency2,
|
currency2,
|
||||||
date
|
date
|
||||||
);
|
);
|
||||||
|
|
||||||
return { marketPrice };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
@ -18,18 +18,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
public indexHtmlIt = '';
|
public indexHtmlIt = '';
|
||||||
public indexHtmlNl = '';
|
public indexHtmlNl = '';
|
||||||
public indexHtmlPt = '';
|
public indexHtmlPt = '';
|
||||||
public isProduction: boolean;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configService: ConfigService,
|
|
||||||
private readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService
|
||||||
) {
|
) {
|
||||||
const NODE_ENV =
|
|
||||||
this.configService.get<'development' | 'production'>('NODE_ENV') ??
|
|
||||||
'development';
|
|
||||||
|
|
||||||
this.isProduction = NODE_ENV === 'production';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.indexHtmlDe = fs.readFileSync(
|
this.indexHtmlDe = fs.readFileSync(
|
||||||
this.getPathOfIndexHtmlFile('de'),
|
this.getPathOfIndexHtmlFile('de'),
|
||||||
@ -90,12 +82,17 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
) {
|
) {
|
||||||
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
|
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
|
||||||
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
|
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith('/en/blog/2023/02/ghostfolio-meets-umbrel')
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
|
||||||
|
title = `Ghostfolio meets Umbrel - ${title}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
request.path.startsWith('/api/') ||
|
request.path.startsWith('/api/') ||
|
||||||
this.isFileRequest(request.url) ||
|
this.isFileRequest(request.url) ||
|
||||||
!this.isProduction
|
!environment.production
|
||||||
) {
|
) {
|
||||||
// Skip
|
// Skip
|
||||||
next();
|
next();
|
||||||
|
@ -254,6 +254,7 @@ export class ImportService {
|
|||||||
countries: null,
|
countries: null,
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
|
isin: null,
|
||||||
name: null,
|
name: null,
|
||||||
scraperConfiguration: null,
|
scraperConfiguration: null,
|
||||||
sectors: null,
|
sectors: null,
|
||||||
|
@ -6,8 +6,8 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
DEMO_USER_ID,
|
|
||||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||||
|
PROPERTY_DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
PROPERTY_STRIPE_CONFIG,
|
||||||
@ -59,9 +59,7 @@ export class InfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
if (
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
|
||||||
) {
|
|
||||||
info.fearAndGreedDataSource = encodeDataSource(
|
info.fearAndGreedDataSource = encodeDataSource(
|
||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
);
|
);
|
||||||
@ -120,7 +118,7 @@ export class InfoService {
|
|||||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||||
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||||
demoAuthToken: this.getDemoAuthToken(),
|
demoAuthToken: await this.getDemoAuthToken(),
|
||||||
statistics: await this.getStatistics(),
|
statistics: await this.getStatistics(),
|
||||||
subscriptions: await this.getSubscriptions(),
|
subscriptions: await this.getSubscriptions(),
|
||||||
tags: await this.tagService.get()
|
tags: await this.tagService.get()
|
||||||
@ -248,10 +246,18 @@ export class InfoService {
|
|||||||
)) as string;
|
)) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDemoAuthToken() {
|
private async getDemoAuthToken() {
|
||||||
return this.jwtService.sign({
|
const demoUserId = (await this.propertyService.getByKey(
|
||||||
id: DEMO_USER_ID
|
PROPERTY_DEMO_USER_ID
|
||||||
});
|
)) as string;
|
||||||
|
|
||||||
|
if (demoUserId) {
|
||||||
|
return this.jwtService.sign({
|
||||||
|
id: demoUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getStatistics() {
|
private async getStatistics() {
|
||||||
|
@ -3,6 +3,7 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
|
|||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
@ -66,7 +67,7 @@ export class OrderController {
|
|||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getAllOrders(
|
public async getAllOrders(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
|
@ -110,9 +110,6 @@ export class OrderService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol: id
|
symbol: id
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
data.SymbolProfile.connectOrCreate.create.symbol =
|
|
||||||
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue(
|
await this.dataGatheringService.addJobToQueue(
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||||
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||||
|
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
@ -48,8 +49,11 @@ export const CurrentRateServiceMock = {
|
|||||||
getValues: ({
|
getValues: ({
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery
|
dateQuery
|
||||||
}: GetValuesParams): Promise<GetValueObject[]> => {
|
}: GetValuesParams): Promise<{
|
||||||
const result: GetValueObject[] = [];
|
dataProviderInfos: DataProviderInfo[];
|
||||||
|
values: GetValueObject[];
|
||||||
|
}> => {
|
||||||
|
const values: GetValueObject[] = [];
|
||||||
if (dateQuery.lt) {
|
if (dateQuery.lt) {
|
||||||
for (
|
for (
|
||||||
let date = resetHours(dateQuery.gte);
|
let date = resetHours(dateQuery.gte);
|
||||||
@ -57,7 +61,7 @@ export const CurrentRateServiceMock = {
|
|||||||
date = addDays(date, 1)
|
date = addDays(date, 1)
|
||||||
) {
|
) {
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
values.push({
|
||||||
date,
|
date,
|
||||||
marketPriceInBaseCurrency: mockGetValue(
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
dataGatheringItem.symbol,
|
dataGatheringItem.symbol,
|
||||||
@ -70,7 +74,7 @@ export const CurrentRateServiceMock = {
|
|||||||
} else {
|
} else {
|
||||||
for (const date of dateQuery.in) {
|
for (const date of dateQuery.in) {
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
values.push({
|
||||||
date,
|
date,
|
||||||
marketPriceInBaseCurrency: mockGetValue(
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
dataGatheringItem.symbol,
|
dataGatheringItem.symbol,
|
||||||
@ -81,6 +85,6 @@ export const CurrentRateServiceMock = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.resolve(result);
|
return Promise.resolve({ values, dataProviderInfos: [] });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
|
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
@ -103,17 +104,23 @@ describe('CurrentRateService', () => {
|
|||||||
},
|
},
|
||||||
userCurrency: 'CHF'
|
userCurrency: 'CHF'
|
||||||
})
|
})
|
||||||
).toMatchObject<GetValueObject[]>([
|
).toMatchObject<{
|
||||||
{
|
dataProviderInfos: DataProviderInfo[];
|
||||||
date: undefined,
|
values: GetValueObject[];
|
||||||
marketPriceInBaseCurrency: 1841.823902,
|
}>({
|
||||||
symbol: 'AMZN'
|
dataProviderInfos: [],
|
||||||
},
|
values: [
|
||||||
{
|
{
|
||||||
date: undefined,
|
date: undefined,
|
||||||
marketPriceInBaseCurrency: 1847.839966,
|
marketPriceInBaseCurrency: 1841.823902,
|
||||||
symbol: 'AMZN'
|
symbol: 'AMZN'
|
||||||
}
|
},
|
||||||
]);
|
{
|
||||||
|
date: undefined,
|
||||||
|
marketPriceInBaseCurrency: 1847.839966,
|
||||||
|
symbol: 'AMZN'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isBefore, isToday } from 'date-fns';
|
import { isBefore, isToday } from 'date-fns';
|
||||||
import { flatten } from 'lodash';
|
import { flatten } from 'lodash';
|
||||||
@ -22,7 +23,11 @@ export class CurrentRateService {
|
|||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery,
|
dateQuery,
|
||||||
userCurrency
|
userCurrency
|
||||||
}: GetValuesParams): Promise<GetValueObject[]> {
|
}: GetValuesParams): Promise<{
|
||||||
|
dataProviderInfos: DataProviderInfo[];
|
||||||
|
values: GetValueObject[];
|
||||||
|
}> {
|
||||||
|
const dataProviderInfos: DataProviderInfo[] = [];
|
||||||
const includeToday =
|
const includeToday =
|
||||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||||
@ -38,6 +43,14 @@ export class CurrentRateService {
|
|||||||
.then((dataResultProvider) => {
|
.then((dataResultProvider) => {
|
||||||
const result: GetValueObject[] = [];
|
const result: GetValueObject[] = [];
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
|
if (
|
||||||
|
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
|
||||||
|
) {
|
||||||
|
dataProviderInfos.push(
|
||||||
|
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
date: today,
|
date: today,
|
||||||
marketPriceInBaseCurrency:
|
marketPriceInBaseCurrency:
|
||||||
@ -81,7 +94,10 @@ export class CurrentRateService {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return flatten(await Promise.all(promises));
|
return {
|
||||||
|
dataProviderInfos,
|
||||||
|
values: flatten(await Promise.all(promises))
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private containsToday(dates: Date[]): boolean {
|
private containsToday(dates: Date[]): boolean {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile,
|
||||||
HistoricalDataItem
|
HistoricalDataItem
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
@ -7,6 +8,7 @@ import { Tag } from '@prisma/client';
|
|||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioPositionDetail {
|
||||||
averagePrice: number;
|
averagePrice: number;
|
||||||
|
dataProviderInfo: DataProviderInfo;
|
||||||
dividendInBaseCurrency: number;
|
dividendInBaseCurrency: number;
|
||||||
feeInBaseCurrency: number;
|
feeInBaseCurrency: number;
|
||||||
firstBuyDate: string;
|
firstBuyDate: string;
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
|
ResponseError,
|
||||||
|
TimelinePosition
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { GroupBy } from '@ghostfolio/common/types';
|
import { GroupBy } from '@ghostfolio/common/types';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Type as TypeOfOrder } from '@prisma/client';
|
import { Type as TypeOfOrder } from '@prisma/client';
|
||||||
@ -45,6 +49,7 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
private currency: string;
|
private currency: string;
|
||||||
private currentRateService: CurrentRateService;
|
private currentRateService: CurrentRateService;
|
||||||
|
private dataProviderInfos: DataProviderInfo[];
|
||||||
private orders: PortfolioOrder[];
|
private orders: PortfolioOrder[];
|
||||||
private transactionPoints: TransactionPoint[];
|
private transactionPoints: TransactionPoint[];
|
||||||
|
|
||||||
@ -202,14 +207,17 @@ export class PortfolioCalculator {
|
|||||||
symbols[item.symbol] = true;
|
symbols[item.symbol] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const marketSymbols = await this.currentRateService.getValues({
|
const { dataProviderInfos, values: marketSymbols } =
|
||||||
currencies,
|
await this.currentRateService.getValues({
|
||||||
dataGatheringItems,
|
currencies,
|
||||||
dateQuery: {
|
dataGatheringItems,
|
||||||
in: dates
|
dateQuery: {
|
||||||
},
|
in: dates
|
||||||
userCurrency: this.currency
|
},
|
||||||
});
|
userCurrency: this.currency
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataProviderInfos = dataProviderInfos;
|
||||||
|
|
||||||
const marketSymbolMap: {
|
const marketSymbolMap: {
|
||||||
[date: string]: { [symbol: string]: Big };
|
[date: string]: { [symbol: string]: Big };
|
||||||
@ -368,14 +376,17 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
dates.push(resetHours(end));
|
dates.push(resetHours(end));
|
||||||
|
|
||||||
const marketSymbols = await this.currentRateService.getValues({
|
const { dataProviderInfos, values: marketSymbols } =
|
||||||
currencies,
|
await this.currentRateService.getValues({
|
||||||
dataGatheringItems,
|
currencies,
|
||||||
dateQuery: {
|
dataGatheringItems,
|
||||||
in: dates
|
dateQuery: {
|
||||||
},
|
in: dates
|
||||||
userCurrency: this.currency
|
},
|
||||||
});
|
userCurrency: this.currency
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataProviderInfos = dataProviderInfos;
|
||||||
|
|
||||||
const marketSymbolMap: {
|
const marketSymbolMap: {
|
||||||
[date: string]: { [symbol: string]: Big };
|
[date: string]: { [symbol: string]: Big };
|
||||||
@ -463,6 +474,10 @@ export class PortfolioCalculator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getDataProviderInfos() {
|
||||||
|
return this.dataProviderInfos;
|
||||||
|
}
|
||||||
|
|
||||||
public getInvestments(): { date: string; investment: Big }[] {
|
public getInvestments(): { date: string; investment: Big }[] {
|
||||||
if (this.transactionPoints.length === 0) {
|
if (this.transactionPoints.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@ -748,7 +763,7 @@ export class PortfolioCalculator {
|
|||||||
let marketSymbols: GetValueObject[] = [];
|
let marketSymbols: GetValueObject[] = [];
|
||||||
if (dataGatheringItems.length > 0) {
|
if (dataGatheringItems.length > 0) {
|
||||||
try {
|
try {
|
||||||
marketSymbols = await this.currentRateService.getValues({
|
const { values } = await this.currentRateService.getValues({
|
||||||
currencies,
|
currencies,
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery: {
|
dateQuery: {
|
||||||
@ -757,6 +772,7 @@ export class PortfolioCalculator {
|
|||||||
},
|
},
|
||||||
userCurrency: this.currency
|
userCurrency: this.currency
|
||||||
});
|
});
|
||||||
|
marketSymbols = values;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
`Failed to fetch info for date ${startDate} with exception`,
|
`Failed to fetch info for date ${startDate} with exception`,
|
||||||
|
@ -10,6 +10,7 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
|
|||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioDividends,
|
PortfolioDividends,
|
||||||
@ -65,7 +66,7 @@ export class PortfolioController {
|
|||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@ -189,7 +190,7 @@ export class PortfolioController {
|
|||||||
@Get('dividends')
|
@Get('dividends')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getDividends(
|
public async getDividends(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('groupBy') groupBy?: GroupBy,
|
@Query('groupBy') groupBy?: GroupBy,
|
||||||
@ -239,7 +240,7 @@ export class PortfolioController {
|
|||||||
@Get('investments')
|
@Get('investments')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('groupBy') groupBy?: GroupBy,
|
@Query('groupBy') groupBy?: GroupBy,
|
||||||
@ -291,7 +292,7 @@ export class PortfolioController {
|
|||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@Version('2')
|
@Version('2')
|
||||||
public async getPerformanceV2(
|
public async getPerformanceV2(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@ -360,7 +361,7 @@ export class PortfolioController {
|
|||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPositions(
|
public async getPositions(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@ -451,7 +452,7 @@ export class PortfolioController {
|
|||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Param('dataSource') dataSource,
|
@Param('dataSource') dataSource,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
@ -474,7 +475,7 @@ export class PortfolioController {
|
|||||||
@Get('report')
|
@Get('report')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers('impersonation-id') impersonationId: string
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
||||||
): Promise<PortfolioReport> {
|
): Promise<PortfolioReport> {
|
||||||
const report = await this.portfolioService.getReport(impersonationId);
|
const report = await this.portfolioService.getReport(impersonationId);
|
||||||
|
|
||||||
|
@ -678,6 +678,7 @@ export class PortfolioService {
|
|||||||
return {
|
return {
|
||||||
tags,
|
tags,
|
||||||
averagePrice: undefined,
|
averagePrice: undefined,
|
||||||
|
dataProviderInfo: undefined,
|
||||||
dividendInBaseCurrency: undefined,
|
dividendInBaseCurrency: undefined,
|
||||||
feeInBaseCurrency: undefined,
|
feeInBaseCurrency: undefined,
|
||||||
firstBuyDate: undefined,
|
firstBuyDate: undefined,
|
||||||
@ -849,6 +850,7 @@ export class PortfolioService {
|
|||||||
tags,
|
tags,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
averagePrice: averagePrice.toNumber(),
|
averagePrice: averagePrice.toNumber(),
|
||||||
|
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
|
||||||
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
|
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
fee.toNumber(),
|
fee.toNumber(),
|
||||||
@ -911,6 +913,7 @@ export class PortfolioService {
|
|||||||
SymbolProfile,
|
SymbolProfile,
|
||||||
tags,
|
tags,
|
||||||
averagePrice: 0,
|
averagePrice: 0,
|
||||||
|
dataProviderInfo: undefined,
|
||||||
dividendInBaseCurrency: 0,
|
dividendInBaseCurrency: 0,
|
||||||
feeInBaseCurrency: 0,
|
feeInBaseCurrency: 0,
|
||||||
firstBuyDate: undefined,
|
firstBuyDate: undefined,
|
||||||
@ -1550,7 +1553,10 @@ export class PortfolioService {
|
|||||||
userCurrency
|
userCurrency
|
||||||
}).toNumber();
|
}).toNumber();
|
||||||
const emergencyFund = new Big(
|
const emergencyFund = new Big(
|
||||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
Math.max(
|
||||||
|
emergencyFundPositionsValueInBaseCurrency,
|
||||||
|
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
||||||
const firstOrderDate = activities[0]?.date;
|
const firstOrderDate = activities[0]?.date;
|
||||||
|
@ -117,7 +117,7 @@ export class SubscriptionController {
|
|||||||
return await this.subscriptionService.createCheckoutSession({
|
return await this.subscriptionService.createCheckoutSession({
|
||||||
couponId,
|
couponId,
|
||||||
priceId,
|
priceId,
|
||||||
userId: this.request.user.id
|
user: this.request.user
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'SubscriptionController');
|
Logger.error(error, 'SubscriptionController');
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
DEFAULT_LANGUAGE_CODE,
|
DEFAULT_LANGUAGE_CODE,
|
||||||
PROPERTY_STRIPE_CONFIG
|
PROPERTY_STRIPE_CONFIG
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
|
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
@ -23,7 +24,7 @@ export class SubscriptionService {
|
|||||||
this.stripe = new Stripe(
|
this.stripe = new Stripe(
|
||||||
this.configurationService.get('STRIPE_SECRET_KEY'),
|
this.configurationService.get('STRIPE_SECRET_KEY'),
|
||||||
{
|
{
|
||||||
apiVersion: '2020-08-27'
|
apiVersion: '2022-11-15'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -31,17 +32,17 @@ export class SubscriptionService {
|
|||||||
public async createCheckoutSession({
|
public async createCheckoutSession({
|
||||||
couponId,
|
couponId,
|
||||||
priceId,
|
priceId,
|
||||||
userId
|
user
|
||||||
}: {
|
}: {
|
||||||
couponId?: string;
|
couponId?: string;
|
||||||
priceId: string;
|
priceId: string;
|
||||||
userId: string;
|
user: UserWithSettings;
|
||||||
}) {
|
}) {
|
||||||
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
||||||
cancel_url: `${this.configurationService.get(
|
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
|
||||||
'ROOT_URL'
|
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/account`,
|
}/account`,
|
||||||
client_reference_id: userId,
|
client_reference_id: user.id,
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
price: priceId,
|
price: priceId,
|
||||||
@ -116,10 +117,6 @@ export class SubscriptionService {
|
|||||||
userId: session.client_reference_id
|
userId: session.client_reference_id
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.stripe.customers.update(session.customer as string, {
|
|
||||||
description: session.client_reference_id
|
|
||||||
});
|
|
||||||
|
|
||||||
return session.client_reference_id;
|
return session.client_reference_id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'SubscriptionService');
|
Logger.error(error, 'SubscriptionService');
|
||||||
|
@ -1,15 +1,18 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
@ -21,7 +24,10 @@ import { SymbolService } from './symbol.service';
|
|||||||
|
|
||||||
@Controller('symbol')
|
@Controller('symbol')
|
||||||
export class SymbolController {
|
export class SymbolController {
|
||||||
public constructor(private readonly symbolService: SymbolService) {}
|
public constructor(
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
|
private readonly symbolService: SymbolService
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Must be before /:symbol
|
* Must be before /:symbol
|
||||||
@ -33,7 +39,10 @@ export class SymbolController {
|
|||||||
@Query() { query = '' }
|
@Query() { query = '' }
|
||||||
): Promise<{ items: LookupItem[] }> {
|
): Promise<{ items: LookupItem[] }> {
|
||||||
try {
|
try {
|
||||||
return this.symbolService.lookup(query.toLowerCase());
|
return this.symbolService.lookup({
|
||||||
|
query: query.toLowerCase(),
|
||||||
|
user: this.request.user
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
@ -5,7 +5,10 @@ import {
|
|||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
HistoricalDataItem,
|
||||||
|
UserWithSettings
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { format, subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
|
|
||||||
@ -79,15 +82,24 @@ export class SymbolService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async lookup({
|
||||||
|
query,
|
||||||
|
user
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
user: UserWithSettings;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
const results: { items: LookupItem[] } = { items: [] };
|
const results: { items: LookupItem[] } = { items: [] };
|
||||||
|
|
||||||
if (!aQuery) {
|
if (!query) {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { items } = await this.dataProviderService.search(aQuery);
|
const { items } = await this.dataProviderService.search({
|
||||||
|
query,
|
||||||
|
user
|
||||||
|
});
|
||||||
results.items = items;
|
results.items = items;
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -1,7 +0,0 @@
|
|||||||
import { IsOptional, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateUserDto {
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
country?: string;
|
|
||||||
}
|
|
@ -6,12 +6,17 @@ import type {
|
|||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateUserSettingDto {
|
export class UpdateUserSettingDto {
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
annualInterestRate?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
baseCurrency?: string;
|
baseCurrency?: string;
|
||||||
@ -48,6 +53,14 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
projectedTotalAmount?: number;
|
||||||
|
|
||||||
|
@IsISO8601()
|
||||||
|
@IsOptional()
|
||||||
|
retirementDate?: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
savingsRate?: number;
|
savingsRate?: number;
|
||||||
|
@ -22,7 +22,6 @@ import { User as UserModel } from '@prisma/client';
|
|||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
import { size } from 'lodash';
|
import { size } from 'lodash';
|
||||||
|
|
||||||
import { CreateUserDto } from './create-user.dto';
|
|
||||||
import { UserItem } from './interfaces/user-item.interface';
|
import { UserItem } from './interfaces/user-item.interface';
|
||||||
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
@ -66,7 +65,7 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
public async signupUser(@Body() data: CreateUserDto): Promise<UserItem> {
|
public async signupUser(): Promise<UserItem> {
|
||||||
const isUserSignupEnabled =
|
const isUserSignupEnabled =
|
||||||
await this.propertyService.isUserSignupEnabled();
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
@ -80,7 +79,6 @@ export class UserController {
|
|||||||
const hasAdmin = await this.userService.hasAdmin();
|
const hasAdmin = await this.userService.hasAdmin();
|
||||||
|
|
||||||
const { accessToken, id, role } = await this.userService.createUser({
|
const { accessToken, id, role } = await this.userService.createUser({
|
||||||
country: data.country,
|
|
||||||
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
|
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -18,8 +18,6 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { Prisma, Role, User } from '@prisma/client';
|
import { Prisma, Role, User } from '@prisma/client';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
|
|
||||||
import { CreateUserDto } from './create-user.dto';
|
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -234,9 +232,10 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async createUser({
|
public async createUser({
|
||||||
country,
|
|
||||||
data
|
data
|
||||||
}: CreateUserDto & { data: Prisma.UserCreateInput }): Promise<User> {
|
}: {
|
||||||
|
data: Prisma.UserCreateInput;
|
||||||
|
}): Promise<User> {
|
||||||
if (!data?.provider) {
|
if (!data?.provider) {
|
||||||
data.provider = 'ANONYMOUS';
|
data.provider = 'ANONYMOUS';
|
||||||
}
|
}
|
||||||
@ -264,7 +263,6 @@ export class UserService {
|
|||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
await this.prismaService.analytics.create({
|
await this.prismaService.analytics.create({
|
||||||
data: {
|
data: {
|
||||||
country,
|
|
||||||
User: { connect: { id: user.id } }
|
User: { connect: { id: user.id } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import Big from 'big.js';
|
||||||
import { cloneDeep, isArray, isObject } from 'lodash';
|
import { cloneDeep, isArray, isObject } from 'lodash';
|
||||||
|
|
||||||
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||||
@ -59,7 +60,10 @@ export function redactAttributes({
|
|||||||
return redactAttributes({ options, object: currentObject });
|
return redactAttributes({ options, object: currentObject });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else if (isObject(redactedObject[property])) {
|
} else if (
|
||||||
|
isObject(redactedObject[property]) &&
|
||||||
|
!(redactedObject[property] instanceof Big)
|
||||||
|
) {
|
||||||
// Recursively call the function on the nested object
|
// Recursively call the function on the nested object
|
||||||
redactedObject[property] = redactAttributes({
|
redactedObject[property] = redactAttributes({
|
||||||
options,
|
options,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
CallHandler,
|
CallHandler,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
@ -22,7 +23,8 @@ export class RedactValuesInResponseInterceptor<T>
|
|||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map((data: any) => {
|
map((data: any) => {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const hasImpersonationId = !!request.headers?.['impersonation-id'];
|
const hasImpersonationId =
|
||||||
|
!!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hasImpersonationId ||
|
hasImpersonationId ||
|
||||||
|
@ -24,7 +24,7 @@ export class TransformDataSourceInRequestInterceptor<T>
|
|||||||
const http = context.switchToHttp();
|
const http = context.switchToHttp();
|
||||||
const request = http.getRequest();
|
const request = http.getRequest();
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
if (request.body.dataSource) {
|
if (request.body.dataSource) {
|
||||||
request.body.dataSource = decodeDataSource(request.body.dataSource);
|
request.body.dataSource = decodeDataSource(request.body.dataSource);
|
||||||
}
|
}
|
||||||
|
@ -26,9 +26,7 @@ export class TransformDataSourceInResponseInterceptor<T>
|
|||||||
): Observable<any> {
|
): Observable<any> {
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map((data: any) => {
|
map((data: any) => {
|
||||||
if (
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
|
||||||
) {
|
|
||||||
data = redactAttributes({
|
data = redactAttributes({
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import * as bodyParser from 'body-parser';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
import { environment } from './environments/environment';
|
import { environment } from './environments/environment';
|
||||||
@ -9,15 +10,10 @@ async function bootstrap() {
|
|||||||
const configApp = await NestFactory.create(AppModule);
|
const configApp = await NestFactory.create(AppModule);
|
||||||
const configService = configApp.get<ConfigService>(ConfigService);
|
const configService = configApp.get<ConfigService>(ConfigService);
|
||||||
|
|
||||||
const NODE_ENV =
|
|
||||||
configService.get<'development' | 'production'>('NODE_ENV') ??
|
|
||||||
'development';
|
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
logger:
|
logger: environment.production
|
||||||
NODE_ENV === 'production'
|
? ['error', 'log', 'warn']
|
||||||
? ['error', 'log', 'warn']
|
: ['debug', 'error', 'log', 'verbose', 'warn']
|
||||||
: ['debug', 'error', 'log', 'verbose', 'warn']
|
|
||||||
});
|
});
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
app.enableVersioning({
|
app.enableVersioning({
|
||||||
@ -33,6 +29,9 @@ async function bootstrap() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Support 10mb csv/json files for importing activities
|
||||||
|
app.use(bodyParser.json({ limit: '10mb' }));
|
||||||
|
|
||||||
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
||||||
const PORT = configService.get<number>('PORT') || 3333;
|
const PORT = configService.get<number>('PORT') || 3333;
|
||||||
await app.listen(PORT, HOST, () => {
|
await app.listen(PORT, HOST, () => {
|
||||||
|
@ -19,10 +19,9 @@ export class ConfigurationService {
|
|||||||
CACHE_TTL: num({ default: 1 }),
|
CACHE_TTL: num({ default: 1 }),
|
||||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||||
DATA_SOURCES: json({
|
DATA_SOURCES: json({
|
||||||
default: [DataSource.GHOSTFOLIO, DataSource.MANUAL, DataSource.YAHOO]
|
default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO]
|
||||||
}),
|
}),
|
||||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||||
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
|
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
|
||||||
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
||||||
|
@ -152,10 +152,11 @@ export class DataGatheringService {
|
|||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
isin,
|
||||||
name,
|
name,
|
||||||
sectors,
|
sectors,
|
||||||
url
|
url
|
||||||
} = assetProfiles[symbol];
|
} = assetProfile;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.prismaService.symbolProfile.upsert({
|
await this.prismaService.symbolProfile.upsert({
|
||||||
@ -165,6 +166,7 @@ export class DataGatheringService {
|
|||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
isin,
|
||||||
name,
|
name,
|
||||||
sectors,
|
sectors,
|
||||||
symbol,
|
symbol,
|
||||||
@ -175,6 +177,7 @@ export class DataGatheringService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
currency,
|
currency,
|
||||||
|
isin,
|
||||||
name,
|
name,
|
||||||
sectors,
|
sectors,
|
||||||
url
|
url
|
||||||
@ -207,10 +210,6 @@ export class DataGatheringService {
|
|||||||
|
|
||||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||||
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
||||||
if (dataSource === 'MANUAL') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.addJobToQueue(
|
await this.addJobToQueue(
|
||||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||||
{
|
{
|
||||||
@ -253,11 +252,6 @@ export class DataGatheringService {
|
|||||||
},
|
},
|
||||||
scraperConfiguration: true,
|
scraperConfiguration: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
},
|
|
||||||
where: {
|
|
||||||
dataSource: {
|
|
||||||
not: 'MANUAL'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).map((symbolProfile) => {
|
).map((symbolProfile) => {
|
||||||
@ -278,7 +272,6 @@ export class DataGatheringService {
|
|||||||
return symbolProfiles
|
return symbolProfiles
|
||||||
.filter(({ dataSource }) => {
|
.filter(({ dataSource }) => {
|
||||||
return (
|
return (
|
||||||
dataSource !== DataSource.GHOSTFOLIO &&
|
|
||||||
dataSource !== DataSource.MANUAL &&
|
dataSource !== DataSource.MANUAL &&
|
||||||
dataSource !== DataSource.RAPID_API
|
dataSource !== DataSource.RAPID_API
|
||||||
);
|
);
|
||||||
@ -300,11 +293,6 @@ export class DataGatheringService {
|
|||||||
dataSource: true,
|
dataSource: true,
|
||||||
scraperConfiguration: true,
|
scraperConfiguration: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
},
|
|
||||||
where: {
|
|
||||||
dataSource: {
|
|
||||||
not: 'MANUAL'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -0,0 +1,196 @@
|
|||||||
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
|
import {
|
||||||
|
IDataProviderHistoricalResponse,
|
||||||
|
IDataProviderResponse
|
||||||
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
SymbolProfile
|
||||||
|
} from '@prisma/client';
|
||||||
|
import bent from 'bent';
|
||||||
|
import { format, fromUnixTime, getUnixTime } from 'date-fns';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CoinGeckoService implements DataProviderInterface {
|
||||||
|
private baseCurrency: string;
|
||||||
|
private readonly URL = 'https://api.coingecko.com/api/v3';
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {
|
||||||
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
|
}
|
||||||
|
|
||||||
|
public canHandle(symbol: string) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAssetProfile(
|
||||||
|
aSymbol: string
|
||||||
|
): Promise<Partial<SymbolProfile>> {
|
||||||
|
const response: Partial<SymbolProfile> = {
|
||||||
|
assetClass: AssetClass.CASH,
|
||||||
|
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
|
||||||
|
currency: this.baseCurrency,
|
||||||
|
dataSource: this.getName(),
|
||||||
|
symbol: aSymbol
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const get = bent(`${this.URL}/coins/${aSymbol}`, 'GET', 'json', 200);
|
||||||
|
const { name } = await get();
|
||||||
|
|
||||||
|
response.name = name;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'CoinGeckoService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
from,
|
||||||
|
granularity = 'day',
|
||||||
|
symbol,
|
||||||
|
to
|
||||||
|
}: {
|
||||||
|
from: Date;
|
||||||
|
granularity: Granularity;
|
||||||
|
symbol: string;
|
||||||
|
to: Date;
|
||||||
|
}) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getHistorical(
|
||||||
|
aSymbol: string,
|
||||||
|
aGranularity: Granularity = 'day',
|
||||||
|
from: Date,
|
||||||
|
to: Date
|
||||||
|
): Promise<{
|
||||||
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const get = bent(
|
||||||
|
`${
|
||||||
|
this.URL
|
||||||
|
}/coins/${aSymbol}/market_chart/range?vs_currency=${this.baseCurrency.toLowerCase()}&from=${getUnixTime(
|
||||||
|
from
|
||||||
|
)}&to=${getUnixTime(to)}`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200
|
||||||
|
);
|
||||||
|
const { prices } = await get();
|
||||||
|
|
||||||
|
const result: {
|
||||||
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
|
} = {
|
||||||
|
[aSymbol]: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [timestamp, marketPrice] of prices) {
|
||||||
|
result[aSymbol][format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = {
|
||||||
|
marketPrice
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
||||||
|
from,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMaxNumberOfSymbolsPerRequest() {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName(): DataSource {
|
||||||
|
return DataSource.COINGECKO;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getQuotes(
|
||||||
|
aSymbols: string[]
|
||||||
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
const results: { [symbol: string]: IDataProviderResponse } = {};
|
||||||
|
|
||||||
|
if (aSymbols.length <= 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const get = bent(
|
||||||
|
`${this.URL}/simple/price?ids=${aSymbols.join(
|
||||||
|
','
|
||||||
|
)}&vs_currencies=${this.baseCurrency.toLowerCase()}`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200
|
||||||
|
);
|
||||||
|
const response = await get();
|
||||||
|
|
||||||
|
for (const symbol in response) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(response, symbol)) {
|
||||||
|
results[symbol] = {
|
||||||
|
currency: this.baseCurrency,
|
||||||
|
dataProviderInfo: this.getDataProviderInfo(),
|
||||||
|
dataSource: DataSource.COINGECKO,
|
||||||
|
marketPrice: response[symbol][this.baseCurrency.toLowerCase()],
|
||||||
|
marketState: 'open'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'CoinGeckoService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
|
let items: LookupItem[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const get = bent(
|
||||||
|
`${this.URL}/search?query=${aQuery}`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200
|
||||||
|
);
|
||||||
|
const { coins } = await get();
|
||||||
|
|
||||||
|
items = coins.map(({ id: symbol, name }) => {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
symbol,
|
||||||
|
currency: this.baseCurrency,
|
||||||
|
dataSource: this.getName()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'CoinGeckoService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDataProviderInfo(): DataProviderInfo {
|
||||||
|
return {
|
||||||
|
name: 'CoinGecko',
|
||||||
|
url: 'https://coingecko.com'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -7,7 +7,7 @@ import bent from 'bent';
|
|||||||
const getJSON = bent('json');
|
const getJSON = bent('json');
|
||||||
|
|
||||||
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||||
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
private static baseUrl = 'https://data.trackinsight.com';
|
||||||
private static countries = require('countries-list/dist/countries.json');
|
private static countries = require('countries-list/dist/countries.json');
|
||||||
private static countriesMapping = {
|
private static countriesMapping = {
|
||||||
'Russian Federation': 'Russia'
|
'Russian Federation': 'Russia'
|
||||||
@ -32,17 +32,29 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await getJSON(
|
const profile = await getJSON(
|
||||||
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
|
`${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${symbol}.json`
|
||||||
|
).catch(() => {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const isin = profile.isin?.split(';')?.[0];
|
||||||
|
|
||||||
|
if (isin) {
|
||||||
|
response.isin = isin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const holdings = await getJSON(
|
||||||
|
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`
|
||||||
).catch(() => {
|
).catch(() => {
|
||||||
return getJSON(
|
return getJSON(
|
||||||
`${TrackinsightDataEnhancerService.baseUrl}/${
|
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
|
||||||
symbol.split('.')[0]
|
symbol.split('.')?.[0]
|
||||||
}.json`
|
}.json`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.weight < 0.95) {
|
if (holdings?.weight < 0.95) {
|
||||||
// Skip if data is inaccurate
|
// Skip if data is inaccurate
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@ -52,7 +64,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
(response.countries as unknown as Country[]).length === 0
|
(response.countries as unknown as Country[]).length === 0
|
||||||
) {
|
) {
|
||||||
response.countries = [];
|
response.countries = [];
|
||||||
for (const [name, value] of Object.entries<any>(result.countries)) {
|
for (const [name, value] of Object.entries<any>(
|
||||||
|
holdings?.countries ?? {}
|
||||||
|
)) {
|
||||||
let countryCode: string;
|
let countryCode: string;
|
||||||
|
|
||||||
for (const [key, country] of Object.entries<any>(
|
for (const [key, country] of Object.entries<any>(
|
||||||
@ -80,7 +94,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
(response.sectors as unknown as Sector[]).length === 0
|
(response.sectors as unknown as Sector[]).length === 0
|
||||||
) {
|
) {
|
||||||
response.sectors = [];
|
response.sectors = [];
|
||||||
for (const [name, value] of Object.entries<any>(result.sectors)) {
|
for (const [name, value] of Object.entries<any>(
|
||||||
|
holdings?.sectors ?? {}
|
||||||
|
)) {
|
||||||
response.sectors.push({
|
response.sectors.push({
|
||||||
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
|
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
|
||||||
weight: value.weight
|
weight: value.weight
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||||
|
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
|
||||||
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
|
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
|
||||||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||||
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
|
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
|
||||||
@ -22,9 +22,9 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
|
CoinGeckoService,
|
||||||
DataProviderService,
|
DataProviderService,
|
||||||
EodHistoricalDataService,
|
EodHistoricalDataService,
|
||||||
GhostfolioScraperApiService,
|
|
||||||
GoogleSheetsService,
|
GoogleSheetsService,
|
||||||
ManualService,
|
ManualService,
|
||||||
RapidApiService,
|
RapidApiService,
|
||||||
@ -32,8 +32,8 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
{
|
{
|
||||||
inject: [
|
inject: [
|
||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
|
CoinGeckoService,
|
||||||
EodHistoricalDataService,
|
EodHistoricalDataService,
|
||||||
GhostfolioScraperApiService,
|
|
||||||
GoogleSheetsService,
|
GoogleSheetsService,
|
||||||
ManualService,
|
ManualService,
|
||||||
RapidApiService,
|
RapidApiService,
|
||||||
@ -42,16 +42,16 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
provide: 'DataProviderInterfaces',
|
provide: 'DataProviderInterfaces',
|
||||||
useFactory: (
|
useFactory: (
|
||||||
alphaVantageService,
|
alphaVantageService,
|
||||||
|
coinGeckoService,
|
||||||
eodHistoricalDataService,
|
eodHistoricalDataService,
|
||||||
ghostfolioScraperApiService,
|
|
||||||
googleSheetsService,
|
googleSheetsService,
|
||||||
manualService,
|
manualService,
|
||||||
rapidApiService,
|
rapidApiService,
|
||||||
yahooFinanceService
|
yahooFinanceService
|
||||||
) => [
|
) => [
|
||||||
alphaVantageService,
|
alphaVantageService,
|
||||||
|
coinGeckoService,
|
||||||
eodHistoricalDataService,
|
eodHistoricalDataService,
|
||||||
ghostfolioScraperApiService,
|
|
||||||
googleSheetsService,
|
googleSheetsService,
|
||||||
manualService,
|
manualService,
|
||||||
rapidApiService,
|
rapidApiService,
|
||||||
@ -59,10 +59,6 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [DataProviderService, YahooFinanceService]
|
||||||
DataProviderService,
|
|
||||||
GhostfolioScraperApiService,
|
|
||||||
YahooFinanceService
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class DataProviderModule {}
|
export class DataProviderModule {}
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import { UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
||||||
@ -260,26 +261,51 @@ export class DataProviderService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search({
|
||||||
|
query,
|
||||||
|
user
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
user: UserWithSettings;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||||
let lookupItems: LookupItem[] = [];
|
let lookupItems: LookupItem[] = [];
|
||||||
|
|
||||||
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
|
if (query?.length < 2) {
|
||||||
promises.push(
|
return { items: lookupItems };
|
||||||
this.getDataProvider(DataSource[dataSource]).search(aQuery)
|
}
|
||||||
);
|
|
||||||
|
let dataSources = this.configurationService.get('DATA_SOURCES');
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
dataSources = dataSources.filter((dataSource) => {
|
||||||
|
return !this.isPremiumDataSource(DataSource[dataSource]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dataSource of dataSources) {
|
||||||
|
promises.push(this.getDataProvider(DataSource[dataSource]).search(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchResults = await Promise.all(promises);
|
const searchResults = await Promise.all(promises);
|
||||||
|
|
||||||
searchResults.forEach((searchResult) => {
|
searchResults.forEach(({ items }) => {
|
||||||
lookupItems = lookupItems.concat(searchResult.items);
|
if (items?.length > 0) {
|
||||||
|
lookupItems = lookupItems.concat(items);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredItems = lookupItems.filter((lookupItem) => {
|
const filteredItems = lookupItems
|
||||||
// Only allow symbols with supported currency
|
.filter((lookupItem) => {
|
||||||
return lookupItem.currency ? true : false;
|
// Only allow symbols with supported currency
|
||||||
});
|
return lookupItem.currency ? true : false;
|
||||||
|
})
|
||||||
|
.sort(({ name: name1 }, { name: name2 }) => {
|
||||||
|
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: filteredItems
|
items: filteredItems
|
||||||
@ -295,4 +321,9 @@ export class DataProviderService {
|
|||||||
|
|
||||||
throw new Error('No data provider has been found.');
|
throw new Error('No data provider has been found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isPremiumDataSource(aDataSource: DataSource) {
|
||||||
|
const premiumDataSources: DataSource[] = [DataSource.EOD_HISTORICAL_DATA];
|
||||||
|
return premiumDataSources.includes(aDataSource);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,13 +5,17 @@ import {
|
|||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
SymbolProfile
|
||||||
|
} from '@prisma/client';
|
||||||
import bent from 'bent';
|
import bent from 'bent';
|
||||||
import { format } from 'date-fns';
|
import { format, isToday } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EodHistoricalDataService implements DataProviderInterface {
|
export class EodHistoricalDataService implements DataProviderInterface {
|
||||||
@ -19,8 +23,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
private readonly URL = 'https://eodhistoricaldata.com/api';
|
private readonly URL = 'https://eodhistoricaldata.com/api';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
|
||||||
) {
|
) {
|
||||||
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
|
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
|
||||||
}
|
}
|
||||||
@ -32,8 +35,15 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
public async getAssetProfile(
|
public async getAssetProfile(
|
||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<Partial<SymbolProfile>> {
|
): Promise<Partial<SymbolProfile>> {
|
||||||
|
const [searchResult] = await this.getSearchResult(aSymbol);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dataSource: this.getName()
|
assetClass: searchResult?.assetClass,
|
||||||
|
assetSubClass: searchResult?.assetSubClass,
|
||||||
|
currency: searchResult?.currency,
|
||||||
|
dataSource: this.getName(),
|
||||||
|
isin: searchResult?.isin,
|
||||||
|
name: searchResult?.name
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,32 +132,30 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
200
|
200
|
||||||
);
|
);
|
||||||
|
|
||||||
const [response, symbolProfiles] = await Promise.all([
|
const [realTimeResponse, searchResponse] = await Promise.all([
|
||||||
get(),
|
get(),
|
||||||
this.symbolProfileService.getSymbolProfiles(
|
this.search(aSymbols[0])
|
||||||
aSymbols.map((symbol) => {
|
|
||||||
return {
|
|
||||||
symbol,
|
|
||||||
dataSource: DataSource.EOD_HISTORICAL_DATA
|
|
||||||
};
|
|
||||||
})
|
|
||||||
)
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const quotes = aSymbols.length === 1 ? [response] : response;
|
const quotes =
|
||||||
|
aSymbols.length === 1 ? [realTimeResponse] : realTimeResponse;
|
||||||
|
|
||||||
return quotes.reduce((result, item, index, array) => {
|
return quotes.reduce(
|
||||||
result[item.code] = {
|
(
|
||||||
currency: symbolProfiles.find((symbolProfile) => {
|
result: { [symbol: string]: IDataProviderResponse },
|
||||||
return symbolProfile.symbol === item.code;
|
{ close, code, timestamp }
|
||||||
})?.currency,
|
) => {
|
||||||
dataSource: DataSource.EOD_HISTORICAL_DATA,
|
result[code] = {
|
||||||
marketPrice: item.close,
|
currency: searchResponse?.items[0]?.currency,
|
||||||
marketState: 'delayed'
|
dataSource: DataSource.EOD_HISTORICAL_DATA,
|
||||||
};
|
marketPrice: close,
|
||||||
|
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
||||||
|
};
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, {});
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'EodHistoricalDataService');
|
Logger.error(error, 'EodHistoricalDataService');
|
||||||
}
|
}
|
||||||
@ -156,6 +164,101 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
return { items: [] };
|
const searchResult = await this.getSearchResult(aQuery);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: searchResult
|
||||||
|
.filter(({ symbol }) => {
|
||||||
|
return !symbol.toLowerCase().endsWith('forex');
|
||||||
|
})
|
||||||
|
.map(({ currency, dataSource, name, symbol }) => {
|
||||||
|
return { currency, dataSource, name, symbol };
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSearchResult(aQuery: string): Promise<
|
||||||
|
(LookupItem & {
|
||||||
|
assetClass: AssetClass;
|
||||||
|
assetSubClass: AssetSubClass;
|
||||||
|
isin: string;
|
||||||
|
})[]
|
||||||
|
> {
|
||||||
|
let searchResult = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const get = bent(
|
||||||
|
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200
|
||||||
|
);
|
||||||
|
const response = await get();
|
||||||
|
|
||||||
|
searchResult = response.map(
|
||||||
|
({
|
||||||
|
Code,
|
||||||
|
Currency: currency,
|
||||||
|
Exchange,
|
||||||
|
ISIN: isin,
|
||||||
|
Name: name,
|
||||||
|
Type
|
||||||
|
}) => {
|
||||||
|
const { assetClass, assetSubClass } = this.parseAssetClass({
|
||||||
|
Exchange,
|
||||||
|
Type
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
currency,
|
||||||
|
isin,
|
||||||
|
name,
|
||||||
|
dataSource: this.getName(),
|
||||||
|
symbol: `${Code}.${Exchange}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'EodHistoricalDataService');
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAssetClass({
|
||||||
|
Exchange,
|
||||||
|
Type
|
||||||
|
}: {
|
||||||
|
Exchange: string;
|
||||||
|
Type: string;
|
||||||
|
}): {
|
||||||
|
assetClass: AssetClass;
|
||||||
|
assetSubClass: AssetSubClass;
|
||||||
|
} {
|
||||||
|
let assetClass: AssetClass;
|
||||||
|
let assetSubClass: AssetSubClass;
|
||||||
|
|
||||||
|
switch (Type?.toLowerCase()) {
|
||||||
|
case 'common stock':
|
||||||
|
assetClass = AssetClass.EQUITY;
|
||||||
|
assetSubClass = AssetSubClass.STOCK;
|
||||||
|
break;
|
||||||
|
case 'currency':
|
||||||
|
assetClass = AssetClass.CASH;
|
||||||
|
|
||||||
|
if (Exchange?.toLowerCase() === 'cc') {
|
||||||
|
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case 'etf':
|
||||||
|
assetClass = AssetClass.EQUITY;
|
||||||
|
assetSubClass = AssetSubClass.ETF;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { assetClass, assetSubClass };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,194 +0,0 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
|
||||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
|
||||||
import {
|
|
||||||
IDataProviderHistoricalResponse,
|
|
||||||
IDataProviderResponse
|
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
|
||||||
import {
|
|
||||||
DATE_FORMAT,
|
|
||||||
extractNumberFromString,
|
|
||||||
getYesterday
|
|
||||||
} from '@ghostfolio/common/helper';
|
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
|
||||||
import bent from 'bent';
|
|
||||||
import * as cheerio from 'cheerio';
|
|
||||||
import { addDays, format, isBefore } from 'date-fns';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
|
||||||
public constructor(
|
|
||||||
private readonly prismaService: PrismaService,
|
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getAssetProfile(
|
|
||||||
aSymbol: string
|
|
||||||
): Promise<Partial<SymbolProfile>> {
|
|
||||||
return {
|
|
||||||
dataSource: this.getName()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getDividends({
|
|
||||||
from,
|
|
||||||
granularity = 'day',
|
|
||||||
symbol,
|
|
||||||
to
|
|
||||||
}: {
|
|
||||||
from: Date;
|
|
||||||
granularity: Granularity;
|
|
||||||
symbol: string;
|
|
||||||
to: Date;
|
|
||||||
}) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getHistorical(
|
|
||||||
aSymbol: string,
|
|
||||||
aGranularity: Granularity = 'day',
|
|
||||||
from: Date,
|
|
||||||
to: Date
|
|
||||||
): Promise<{
|
|
||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const symbol = aSymbol;
|
|
||||||
|
|
||||||
const [symbolProfile] =
|
|
||||||
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
|
|
||||||
const { defaultMarketPrice, selector, url } =
|
|
||||||
symbolProfile.scraperConfiguration ?? {};
|
|
||||||
|
|
||||||
if (defaultMarketPrice) {
|
|
||||||
const historical: {
|
|
||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
|
||||||
} = {
|
|
||||||
[symbol]: {}
|
|
||||||
};
|
|
||||||
let date = from;
|
|
||||||
|
|
||||||
while (isBefore(date, to)) {
|
|
||||||
historical[symbol][format(date, DATE_FORMAT)] = {
|
|
||||||
marketPrice: defaultMarketPrice
|
|
||||||
};
|
|
||||||
|
|
||||||
date = addDays(date, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return historical;
|
|
||||||
} else if (selector === undefined || url === undefined) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const get = bent(url, 'GET', 'string', 200, {});
|
|
||||||
|
|
||||||
const html = await get();
|
|
||||||
const $ = cheerio.load(html);
|
|
||||||
|
|
||||||
const value = extractNumberFromString($(selector).text());
|
|
||||||
|
|
||||||
return {
|
|
||||||
[symbol]: {
|
|
||||||
[format(getYesterday(), DATE_FORMAT)]: {
|
|
||||||
marketPrice: value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(
|
|
||||||
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
|
|
||||||
from,
|
|
||||||
DATE_FORMAT
|
|
||||||
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getName(): DataSource {
|
|
||||||
return DataSource.GHOSTFOLIO;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getQuotes(
|
|
||||||
aSymbols: string[]
|
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
|
||||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
|
||||||
|
|
||||||
if (aSymbols.length <= 0) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const symbolProfiles =
|
|
||||||
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
|
|
||||||
|
|
||||||
const marketData = await this.prismaService.marketData.findMany({
|
|
||||||
distinct: ['symbol'],
|
|
||||||
orderBy: {
|
|
||||||
date: 'desc'
|
|
||||||
},
|
|
||||||
take: aSymbols.length,
|
|
||||||
where: {
|
|
||||||
symbol: {
|
|
||||||
in: aSymbols
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const symbolProfile of symbolProfiles) {
|
|
||||||
response[symbolProfile.symbol] = {
|
|
||||||
currency: symbolProfile.currency,
|
|
||||||
dataSource: this.getName(),
|
|
||||||
marketPrice: marketData.find((marketDataItem) => {
|
|
||||||
return marketDataItem.symbol === symbolProfile.symbol;
|
|
||||||
})?.marketPrice,
|
|
||||||
marketState: 'delayed'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error, 'GhostfolioScraperApiService');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
|
||||||
const items = await this.prismaService.symbolProfile.findMany({
|
|
||||||
select: {
|
|
||||||
currency: true,
|
|
||||||
dataSource: true,
|
|
||||||
name: true,
|
|
||||||
symbol: true
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{
|
|
||||||
dataSource: this.getName(),
|
|
||||||
name: {
|
|
||||||
mode: 'insensitive',
|
|
||||||
startsWith: aQuery
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dataSource: this.getName(),
|
|
||||||
symbol: {
|
|
||||||
mode: 'insensitive',
|
|
||||||
startsWith: aQuery
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return { items };
|
|
||||||
}
|
|
||||||
}
|
|
@ -16,6 +16,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||||
import bent from 'bent';
|
import bent from 'bent';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
|
import { isUUID } from 'class-validator';
|
||||||
import { addDays, format, isBefore } from 'date-fns';
|
import { addDays, format, isBefore } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -162,7 +163,7 @@ export class ManualService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
const items = await this.prismaService.symbolProfile.findMany({
|
let items = await this.prismaService.symbolProfile.findMany({
|
||||||
select: {
|
select: {
|
||||||
currency: true,
|
currency: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
@ -189,6 +190,11 @@ export class ManualService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
items = items.filter(({ symbol }) => {
|
||||||
|
// Remove UUID symbols (activities of type ITEM)
|
||||||
|
return !isUUID(symbol);
|
||||||
|
});
|
||||||
|
|
||||||
return { items };
|
return { items };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -441,8 +441,10 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
let name = longName;
|
let name = longName;
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
|
name = name.replace('Amundi Index Solutions - ', '');
|
||||||
name = name.replace('iShares ETF (CH) - ', '');
|
name = name.replace('iShares ETF (CH) - ', '');
|
||||||
name = name.replace('iShares III Public Limited Company - ', '');
|
name = name.replace('iShares III Public Limited Company - ', '');
|
||||||
|
name = name.replace('iShares V PLC - ', '');
|
||||||
name = name.replace('iShares VI Public Limited Company - ', '');
|
name = name.replace('iShares VI Public Limited Company - ', '');
|
||||||
name = name.replace('iShares VII PLC - ', '');
|
name = name.replace('iShares VII PLC - ', '');
|
||||||
name = name.replace('Multi Units Luxembourg - ', '');
|
name = name.replace('Multi Units Luxembourg - ', '');
|
||||||
|
@ -168,7 +168,7 @@ export class ExchangeRateDataService {
|
|||||||
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
|
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
let factor = 1;
|
let factor: number;
|
||||||
|
|
||||||
if (aFromCurrency !== aToCurrency) {
|
if (aFromCurrency !== aToCurrency) {
|
||||||
const dataSource = this.dataProviderService.getPrimaryDataSource();
|
const dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||||
@ -183,9 +183,29 @@ export class ExchangeRateDataService {
|
|||||||
if (marketData?.marketPrice) {
|
if (marketData?.marketPrice) {
|
||||||
factor = marketData?.marketPrice;
|
factor = marketData?.marketPrice;
|
||||||
} else {
|
} else {
|
||||||
// TODO: Get from data provider service or calculate indirectly via base currency
|
// Calculate indirectly via base currency
|
||||||
// and market data
|
try {
|
||||||
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
|
const [
|
||||||
|
{ marketPrice: marketPriceBaseCurrencyFromCurrency },
|
||||||
|
{ marketPrice: marketPriceBaseCurrencyToCurrency }
|
||||||
|
] = await Promise.all([
|
||||||
|
this.marketDataService.get({
|
||||||
|
dataSource,
|
||||||
|
date: aDate,
|
||||||
|
symbol: `${this.baseCurrency}${aFromCurrency}`
|
||||||
|
}),
|
||||||
|
this.marketDataService.get({
|
||||||
|
dataSource,
|
||||||
|
date: aDate,
|
||||||
|
symbol: `${this.baseCurrency}${aToCurrency}`
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calculate the opposite direction
|
||||||
|
factor =
|
||||||
|
(1 / marketPriceBaseCurrencyFromCurrency) *
|
||||||
|
marketPriceBaseCurrencyToCurrency;
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,12 +213,15 @@ export class ExchangeRateDataService {
|
|||||||
return factor * aValue;
|
return factor * aValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback with error, if currencies are not available
|
|
||||||
Logger.error(
|
Logger.error(
|
||||||
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`,
|
`No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format(
|
||||||
|
aDate,
|
||||||
|
DATE_FORMAT
|
||||||
|
)}`,
|
||||||
'ExchangeRateDataService'
|
'ExchangeRateDataService'
|
||||||
);
|
);
|
||||||
return aValue;
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async prepareCurrencies(): Promise<string[]> {
|
private async prepareCurrencies(): Promise<string[]> {
|
||||||
|
@ -8,7 +8,6 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
DATA_SOURCE_PRIMARY: string;
|
DATA_SOURCE_PRIMARY: string;
|
||||||
DATA_SOURCES: string[];
|
DATA_SOURCES: string[];
|
||||||
ENABLE_FEATURE_BLOG: boolean;
|
ENABLE_FEATURE_BLOG: boolean;
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||||
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
|
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
|
||||||
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { DataProviderInfo, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { MarketState } from '@ghostfolio/common/types';
|
import { MarketState } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Account,
|
Account,
|
||||||
@ -28,6 +28,7 @@ export interface IDataProviderHistoricalResponse {
|
|||||||
|
|
||||||
export interface IDataProviderResponse {
|
export interface IDataProviderResponse {
|
||||||
currency: string;
|
currency: string;
|
||||||
|
dataProviderInfo?: DataProviderInfo;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
marketState: MarketState;
|
marketState: MarketState;
|
||||||
|
@ -123,6 +123,13 @@ const routes: Routes = [
|
|||||||
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
|
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
|
||||||
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
|
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'blog/2023/02/ghostfolio-meets-umbrel',
|
||||||
|
loadChildren: () =>
|
||||||
|
import(
|
||||||
|
'./pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.module'
|
||||||
|
).then((m) => m.GhostfolioMeetsUmbrelPageModule)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'demo',
|
path: 'demo',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
|
@ -104,7 +104,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
this.tokenStorageService.signOut();
|
this.tokenStorageService.signOut();
|
||||||
this.userService.remove();
|
this.userService.remove();
|
||||||
|
|
||||||
document.location.href = '/';
|
document.location.href = `/${document.documentElement.lang}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<gf-line-chart
|
<gf-line-chart
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
|
[colorScheme]="user?.settings?.colorScheme"
|
||||||
[historicalDataItems]="historicalDataItems"
|
[historicalDataItems]="historicalDataItems"
|
||||||
[isAnimated]="true"
|
[isAnimated]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
@ -28,7 +29,7 @@
|
|||||||
}"
|
}"
|
||||||
[title]="
|
[title]="
|
||||||
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
||||||
| date: defaultDateFormat) ?? ''
|
| date : defaultDateFormat) ?? ''
|
||||||
"
|
"
|
||||||
(click)="
|
(click)="
|
||||||
onOpenMarketDataDetail({
|
onOpenMarketDataDetail({
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
|
||||||
|
|
||||||
import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
|
import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
|
||||||
|
|
||||||
|
@ -152,7 +152,7 @@
|
|||||||
mat-menu-item
|
mat-menu-item
|
||||||
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
||||||
>
|
>
|
||||||
<ng-container i18n>Gather Data</ng-container>
|
<ng-container i18n>Gather Historical Data</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
|
@ -27,7 +27,7 @@
|
|||||||
[disabled]="assetProfileForm.dirty"
|
[disabled]="assetProfileForm.dirty"
|
||||||
(click)="onGatherSymbol({dataSource: data.dataSource, symbol: data.symbol})"
|
(click)="onGatherSymbol({dataSource: data.dataSource, symbol: data.symbol})"
|
||||||
>
|
>
|
||||||
<ng-container i18n>Gather Data</ng-container>
|
<ng-container i18n>Gather Historical Data</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
|
@ -28,7 +28,13 @@
|
|||||||
>
|
>
|
||||||
<ng-container i18n>Engagement per Day</ng-container>
|
<ng-container i18n>Engagement per Day</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Last Request</th>
|
<th
|
||||||
|
*ngIf="hasPermissionForSubscription"
|
||||||
|
class="mat-header-cell px-1 py-2"
|
||||||
|
i18n
|
||||||
|
>
|
||||||
|
Last Request
|
||||||
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2"></th>
|
<th class="mat-header-cell px-1 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -86,7 +92,10 @@
|
|||||||
[value]="userItem.engagement"
|
[value]="userItem.engagement"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2">
|
<td
|
||||||
|
*ngIf="hasPermissionForSubscription"
|
||||||
|
class="mat-cell px-1 py-2"
|
||||||
|
>
|
||||||
{{ formatDistanceToNow(userItem.lastActivity) }}
|
{{ formatDistanceToNow(userItem.lastActivity) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2">
|
<td class="mat-cell px-1 py-2">
|
||||||
|
@ -166,7 +166,6 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
|||||||
},
|
},
|
||||||
display: true,
|
display: true,
|
||||||
grid: {
|
grid: {
|
||||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
|
||||||
display: false
|
display: false
|
||||||
},
|
},
|
||||||
type: 'time',
|
type: 'time',
|
||||||
@ -177,13 +176,21 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
|||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
border: {
|
border: {
|
||||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
width: 0
|
||||||
display: false
|
|
||||||
},
|
},
|
||||||
display: true,
|
display: true,
|
||||||
grid: {
|
grid: {
|
||||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
color: ({ scale, tick }) => {
|
||||||
display: false
|
if (
|
||||||
|
tick.value === 0 ||
|
||||||
|
tick.value === scale.max ||
|
||||||
|
tick.value === scale.min
|
||||||
|
) {
|
||||||
|
return `rgba(${getTextColor(this.colorScheme)}, 0.1)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'transparent';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
position: 'right',
|
position: 'right',
|
||||||
ticks: {
|
ticks: {
|
||||||
|
@ -110,11 +110,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
<ng-container *ngIf="user?.access?.length > 0">
|
<ng-container *ngIf="user?.access?.length > 0">
|
||||||
<button
|
<button mat-menu-item (click)="impersonateAccount(null)">
|
||||||
class="align-items-center d-flex"
|
|
||||||
mat-menu-item
|
|
||||||
(click)="impersonateAccount(null)"
|
|
||||||
>
|
|
||||||
<ion-icon
|
<ion-icon
|
||||||
*ngIf="user?.access?.length > 0"
|
*ngIf="user?.access?.length > 0"
|
||||||
class="mr-2"
|
class="mr-2"
|
||||||
@ -128,7 +124,6 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
*ngFor="let accessItem of user?.access"
|
*ngFor="let accessItem of user?.access"
|
||||||
class="align-items-center d-flex"
|
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
(click)="impersonateAccount(accessItem.id)"
|
(click)="impersonateAccount(accessItem.id)"
|
||||||
>
|
>
|
||||||
@ -147,7 +142,7 @@
|
|||||||
<hr class="m-0" />
|
<hr class="m-0" />
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<a
|
<a
|
||||||
class="d-block d-sm-none"
|
class="d-flex d-sm-none"
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
@ -157,7 +152,7 @@
|
|||||||
>Overview</a
|
>Overview</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="d-block d-sm-none"
|
class="d-flex d-sm-none"
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
@ -167,7 +162,7 @@
|
|||||||
>Portfolio</a
|
>Portfolio</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="d-block d-sm-none"
|
class="d-flex d-sm-none"
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
|
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
|
||||||
@ -175,7 +170,6 @@
|
|||||||
>Accounts</a
|
>Accounts</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="align-items-center d-flex"
|
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
|
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
|
||||||
@ -184,7 +178,7 @@
|
|||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
*ngIf="hasPermissionToAccessAdminControl"
|
*ngIf="hasPermissionToAccessAdminControl"
|
||||||
class="d-block d-sm-none"
|
class="d-flex d-sm-none"
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
|
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
|
||||||
@ -193,7 +187,7 @@
|
|||||||
>
|
>
|
||||||
<hr class="m-0" />
|
<hr class="m-0" />
|
||||||
<a
|
<a
|
||||||
class="d-block d-sm-none"
|
class="d-flex d-sm-none"
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
@ -206,7 +200,7 @@
|
|||||||
*ngIf="
|
*ngIf="
|
||||||
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
||||||
"
|
"
|
||||||
class="d-block d-sm-none"
|
class="d-flex d-sm-none"
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
|
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
|
||||||
@ -214,14 +208,14 @@
|
|||||||
>Pricing</a
|
>Pricing</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="d-block d-sm-none"
|
class="d-flex d-sm-none"
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
|
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
|
||||||
[routerLink]="['/about']"
|
[routerLink]="['/about']"
|
||||||
>About Ghostfolio</a
|
>About Ghostfolio</a
|
||||||
>
|
>
|
||||||
<hr class="d-block d-sm-none m-0" />
|
<hr class="d-flex d-sm-none m-0" />
|
||||||
<button mat-menu-item (click)="onSignOut()">Logout</button>
|
<button mat-menu-item (click)="onSignOut()">Logout</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
@ -283,9 +277,9 @@
|
|||||||
>Markets</a
|
>Markets</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1 no-min-width px-1"
|
class="d-none d-sm-block no-min-width"
|
||||||
href="https://github.com/ghostfolio/ghostfolio"
|
href="https://github.com/ghostfolio/ghostfolio"
|
||||||
mat-flat-button
|
mat-icon-button
|
||||||
><ion-icon name="logo-github"></ion-icon
|
><ion-icon name="logo-github"></ion-icon
|
||||||
></a>
|
></a>
|
||||||
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
|
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
|
||||||
|
@ -11,7 +11,9 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mat-flat-button {
|
.mdc-button {
|
||||||
|
height: unset;
|
||||||
|
|
||||||
&:not(.mat-primary) {
|
&:not(.mat-primary) {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
text-decoration-color: rgba(var(--palette-primary-500), 1) !important;
|
text-decoration-color: rgba(var(--palette-primary-500), 1) !important;
|
||||||
|
@ -56,8 +56,8 @@ export class HeaderComponent implements OnChanges {
|
|||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
.onChangeHasImpersonation()
|
.onChangeHasImpersonation()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((id) => {
|
.subscribe((impersonationId) => {
|
||||||
this.impersonationId = id;
|
this.impersonationId = impersonationId;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/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';
|
||||||
|
@ -78,8 +78,8 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
.onChangeHasImpersonation()
|
.onChangeHasImpersonation()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((aId) => {
|
.subscribe((impersonationId) => {
|
||||||
this.hasImpersonationId = !!aId;
|
this.hasImpersonationId = !!impersonationId;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.update();
|
this.update();
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
symbol="Fear & Greed Index"
|
symbol="Fear & Greed Index"
|
||||||
yMax="100"
|
yMax="100"
|
||||||
yMin="0"
|
yMin="0"
|
||||||
|
[colorScheme]="user?.settings?.colorScheme"
|
||||||
[historicalDataItems]="historicalDataItems"
|
[historicalDataItems]="historicalDataItems"
|
||||||
[isAnimated]="true"
|
[isAnimated]="true"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
|
@ -66,8 +66,8 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
|||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
.onChangeHasImpersonation()
|
.onChangeHasImpersonation()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((aId) => {
|
.subscribe((impersonationId) => {
|
||||||
this.hasImpersonationId = !!aId;
|
this.hasImpersonationId = !!impersonationId;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
@ -69,8 +69,8 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
|
|||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
.onChangeHasImpersonation()
|
.onChangeHasImpersonation()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((aId) => {
|
.subscribe((impersonationId) => {
|
||||||
this.hasImpersonationId = !!aId;
|
this.hasImpersonationId = !!impersonationId;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -283,7 +283,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
},
|
},
|
||||||
display: true,
|
display: true,
|
||||||
grid: {
|
grid: {
|
||||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
|
||||||
display: false
|
display: false
|
||||||
},
|
},
|
||||||
min: this.daysInMarket
|
min: this.daysInMarket
|
||||||
@ -298,13 +297,21 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
|||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
border: {
|
border: {
|
||||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
|
||||||
display: false
|
display: false
|
||||||
},
|
},
|
||||||
display: !this.isInPercent,
|
display: !this.isInPercent,
|
||||||
grid: {
|
grid: {
|
||||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
color: ({ scale, tick }) => {
|
||||||
display: false
|
if (
|
||||||
|
tick.value === 0 ||
|
||||||
|
tick.value === scale.max ||
|
||||||
|
tick.value === scale.min
|
||||||
|
) {
|
||||||
|
return `rgba(${getTextColor(this.colorScheme)}, 0.1)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'transparent';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
position: 'right',
|
position: 'right',
|
||||||
ticks: {
|
ticks: {
|
||||||
|
@ -50,7 +50,7 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
|
|||||||
public onEditEmergencyFund() {
|
public onEditEmergencyFund() {
|
||||||
const emergencyFundInput = prompt(
|
const emergencyFundInput = prompt(
|
||||||
$localize`Please enter the amount of your emergency fund:`,
|
$localize`Please enter the amount of your emergency fund:`,
|
||||||
this.summary.emergencyFund.toString()
|
this.summary.emergencyFund?.toString() ?? '0'
|
||||||
);
|
);
|
||||||
const emergencyFund = parseFloat(emergencyFundInput?.trim());
|
const emergencyFund = parseFloat(emergencyFundInput?.trim());
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile,
|
||||||
LineChartItem
|
LineChartItem
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
@ -40,6 +41,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
public countries: {
|
public countries: {
|
||||||
[code: string]: { name: string; value: number };
|
[code: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
|
public dataProviderInfo: DataProviderInfo;
|
||||||
public dividendInBaseCurrency: number;
|
public dividendInBaseCurrency: number;
|
||||||
public feeInBaseCurrency: number;
|
public feeInBaseCurrency: number;
|
||||||
public firstBuyDate: string;
|
public firstBuyDate: string;
|
||||||
@ -83,6 +85,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
.subscribe(
|
.subscribe(
|
||||||
({
|
({
|
||||||
averagePrice,
|
averagePrice,
|
||||||
|
dataProviderInfo,
|
||||||
dividendInBaseCurrency,
|
dividendInBaseCurrency,
|
||||||
feeInBaseCurrency,
|
feeInBaseCurrency,
|
||||||
firstBuyDate,
|
firstBuyDate,
|
||||||
@ -105,6 +108,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
|||||||
this.averagePrice = averagePrice;
|
this.averagePrice = averagePrice;
|
||||||
this.benchmarkDataItems = [];
|
this.benchmarkDataItems = [];
|
||||||
this.countries = {};
|
this.countries = {};
|
||||||
|
this.dataProviderInfo = dataProviderInfo;
|
||||||
this.dividendInBaseCurrency = dividendInBaseCurrency;
|
this.dividendInBaseCurrency = dividendInBaseCurrency;
|
||||||
this.feeInBaseCurrency = feeInBaseCurrency;
|
this.feeInBaseCurrency = feeInBaseCurrency;
|
||||||
this.firstBuyDate = firstBuyDate;
|
this.firstBuyDate = firstBuyDate;
|
||||||
|
@ -227,6 +227,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
<div *ngIf="dataProviderInfo" class="col-md-12 mb-3 text-center">
|
||||||
|
<hr />
|
||||||
|
<gf-data-provider-credits [dataProviderInfos]="[dataProviderInfo]">
|
||||||
|
</gf-data-provider-credits>
|
||||||
|
<hr />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" [ngClass]="{ 'd-none': !orders?.length }">
|
<div class="row" [ngClass]="{ 'd-none': !orders?.length }">
|
||||||
@ -249,7 +255,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="tags?.length > 0" class="row">
|
<div *ngIf="tags?.length > 0" class="row">
|
||||||
<div class="col mb-3">
|
<div class="col">
|
||||||
<div class="h5" i18n>Tags</div>
|
<div class="h5" i18n>Tags</div>
|
||||||
<mat-chip-list>
|
<mat-chip-list>
|
||||||
<mat-chip *ngFor="let tag of tags">{{ tag.name }}</mat-chip>
|
<mat-chip *ngFor="let tag of tags">{{ tag.name }}</mat-chip>
|
||||||
@ -261,7 +267,7 @@
|
|||||||
*ngIf="data.hasPermissionToReportDataGlitch === true && orders?.length > 0"
|
*ngIf="data.hasPermissionToReportDataGlitch === true && orders?.length > 0"
|
||||||
class="row"
|
class="row"
|
||||||
>
|
>
|
||||||
<div class="col mb-3">
|
<div class="col">
|
||||||
<hr />
|
<hr />
|
||||||
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
|
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
|
||||||
><ion-icon class="mr-1" name="flag-outline"></ion-icon
|
><ion-icon class="mr-1" name="flag-outline"></ion-icon
|
||||||
|
@ -6,6 +6,7 @@ import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/lega
|
|||||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||||
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||||
|
import { GfDataProviderCreditsModule } from '@ghostfolio/ui/data-provider-credits/data-provider-credits.module';
|
||||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
@ -18,6 +19,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
GfActivitiesTableModule,
|
GfActivitiesTableModule,
|
||||||
|
GfDataProviderCreditsModule,
|
||||||
GfDialogFooterModule,
|
GfDialogFooterModule,
|
||||||
GfDialogHeaderModule,
|
GfDialogHeaderModule,
|
||||||
GfLineChartModule,
|
GfLineChartModule,
|
||||||
|
@ -17,17 +17,21 @@
|
|||||||
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
||||||
<span i18n>Portfolio Summary</span>
|
<span i18n>Portfolio Summary</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="align-items-center d-flex mb-1">
|
||||||
|
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
||||||
|
<span i18n>Portfolio Allocations</span>
|
||||||
|
</li>
|
||||||
<li class="align-items-center d-flex mb-1">
|
<li class="align-items-center d-flex mb-1">
|
||||||
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
||||||
<span i18n>Performance Benchmarks</span>
|
<span i18n>Performance Benchmarks</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="align-items-center d-flex mb-1">
|
<li class="align-items-center d-flex mb-1">
|
||||||
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
||||||
<span i18n>Allocations</span>
|
<span i18n>FIRE Calculator</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="align-items-center d-flex mb-1">
|
<li class="align-items-center d-flex mb-1">
|
||||||
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
||||||
<span i18n>FIRE Calculator</span>
|
<span i18n>Professional Data Provider</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="align-items-center d-flex mb-1">
|
<li class="align-items-center d-flex mb-1">
|
||||||
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
|
||||||
|
@ -5,14 +5,16 @@ import {
|
|||||||
HttpRequest
|
HttpRequest
|
||||||
} from '@angular/common/http';
|
} from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
HEADER_KEY_IMPERSONATION,
|
||||||
|
HEADER_KEY_TIMEZONE,
|
||||||
|
HEADER_KEY_TOKEN
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { ImpersonationStorageService } from '../services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '../services/impersonation-storage.service';
|
||||||
import { TokenStorageService } from '../services/token-storage.service';
|
import { TokenStorageService } from '../services/token-storage.service';
|
||||||
|
|
||||||
const IMPERSONATION_KEY = 'Impersonation-Id';
|
|
||||||
const TOKEN_HEADER_KEY = 'Authorization';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthInterceptor implements HttpInterceptor {
|
export class AuthInterceptor implements HttpInterceptor {
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -24,21 +26,27 @@ export class AuthInterceptor implements HttpInterceptor {
|
|||||||
req: HttpRequest<any>,
|
req: HttpRequest<any>,
|
||||||
next: HttpHandler
|
next: HttpHandler
|
||||||
): Observable<HttpEvent<any>> {
|
): Observable<HttpEvent<any>> {
|
||||||
let authReq = req;
|
let request = req;
|
||||||
|
let headers = request.headers.set(
|
||||||
|
HEADER_KEY_TIMEZONE,
|
||||||
|
Intl?.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
);
|
||||||
|
|
||||||
const token = this.tokenStorageService.getToken();
|
const token = this.tokenStorageService.getToken();
|
||||||
const impersonationId = this.impersonationStorageService.getId();
|
|
||||||
|
|
||||||
if (token !== null) {
|
if (token !== null) {
|
||||||
let headers = req.headers.set(TOKEN_HEADER_KEY, `Bearer ${token}`);
|
headers = headers.set(HEADER_KEY_TOKEN, `Bearer ${token}`);
|
||||||
|
|
||||||
|
const impersonationId = this.impersonationStorageService.getId();
|
||||||
|
|
||||||
if (impersonationId !== null) {
|
if (impersonationId !== null) {
|
||||||
headers = headers.set(IMPERSONATION_KEY, impersonationId);
|
headers = headers.set(HEADER_KEY_IMPERSONATION, impersonationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
authReq = req.clone({ headers });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return next.handle(authReq);
|
request = request.clone({ headers });
|
||||||
|
|
||||||
|
return next.handle(request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@
|
|||||||
>hi@ghostfol.io</a
|
>hi@ghostfol.io</a
|
||||||
></ng-container
|
></ng-container
|
||||||
>
|
>
|
||||||
or open an issue at
|
or start a discussion at
|
||||||
<a
|
<a
|
||||||
href="https://github.com/ghostfolio/ghostfolio"
|
href="https://github.com/ghostfolio/ghostfolio"
|
||||||
title="Find Ghostfolio on GitHub"
|
title="Find Ghostfolio on GitHub"
|
||||||
@ -62,7 +62,7 @@
|
|||||||
mat-icon-button
|
mat-icon-button
|
||||||
title="Follow Ghostfolio on Twitter"
|
title="Follow Ghostfolio on Twitter"
|
||||||
>
|
>
|
||||||
<ion-icon name="logo-twitter" size="large"></ion-icon>
|
<ion-icon name="logo-twitter"></ion-icon>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="mx-2"
|
class="mx-2"
|
||||||
@ -70,7 +70,7 @@
|
|||||||
mat-icon-button
|
mat-icon-button
|
||||||
title="Send an e-mail"
|
title="Send an e-mail"
|
||||||
>
|
>
|
||||||
<ion-icon name="mail" size="large"></ion-icon>
|
<ion-icon name="mail"></ion-icon>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="mx-2"
|
class="mx-2"
|
||||||
@ -78,7 +78,7 @@
|
|||||||
mat-icon-button
|
mat-icon-button
|
||||||
title="Join the Ghostfolio Slack channel"
|
title="Join the Ghostfolio Slack channel"
|
||||||
>
|
>
|
||||||
<ion-icon name="logo-slack" size="large"></ion-icon>
|
<ion-icon name="logo-slack"></ion-icon>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="mx-2"
|
class="mx-2"
|
||||||
@ -86,7 +86,7 @@
|
|||||||
mat-icon-button
|
mat-icon-button
|
||||||
title="Find Ghostfolio on GitHub"
|
title="Find Ghostfolio on GitHub"
|
||||||
>
|
>
|
||||||
<ion-icon name="logo-github" size="large"></ion-icon>
|
<ion-icon name="logo-github"></ion-icon>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
@ -191,7 +191,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
|
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
|
||||||
<a
|
<a
|
||||||
class="py-2 w-100"
|
class="py-4 w-100"
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[routerLink]="['/faq']"
|
[routerLink]="['/faq']"
|
||||||
@ -203,7 +203,7 @@
|
|||||||
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
|
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="py-2 w-100"
|
class="py-4 w-100"
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[routerLink]="['/about', 'changelog']"
|
[routerLink]="['/about', 'changelog']"
|
||||||
@ -212,7 +212,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
|
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
|
||||||
<a
|
<a
|
||||||
class="py-2 w-100"
|
class="py-4 w-100"
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[routerLink]="['/about', 'privacy-policy']"
|
[routerLink]="['/about', 'privacy-policy']"
|
||||||
@ -221,7 +221,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div *ngIf="hasPermissionForBlog" class="col-md-3 col-xs-12 my-2">
|
<div *ngIf="hasPermissionForBlog" class="col-md-3 col-xs-12 my-2">
|
||||||
<a
|
<a
|
||||||
class="py-2 w-100"
|
class="py-4 w-100"
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[routerLink]="['/blog']"
|
[routerLink]="['/blog']"
|
||||||
|
@ -77,8 +77,8 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
|||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
.onChangeHasImpersonation()
|
.onChangeHasImpersonation()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((aId) => {
|
.subscribe((impersonationId) => {
|
||||||
this.hasImpersonationId = !!aId;
|
this.hasImpersonationId = !!impersonationId;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||||
|
|
||||||
|
import { GhostfolioMeetsUmbrelPageComponent } from './ghostfolio-meets-umbrel-page.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
component: GhostfolioMeetsUmbrelPageComponent,
|
||||||
|
path: '',
|
||||||
|
title: 'Ghostfolio meets Umbrel'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class GhostfolioMeetsUmbrelPageRoutingModule {}
|
@ -0,0 +1,9 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'page' },
|
||||||
|
selector: 'gf-ghostfolio-meets-umbrel-page',
|
||||||
|
styleUrls: ['./ghostfolio-meets-umbrel-page.scss'],
|
||||||
|
templateUrl: './ghostfolio-meets-umbrel-page.html'
|
||||||
|
})
|
||||||
|
export class GhostfolioMeetsUmbrelPageComponent {}
|
@ -0,0 +1,200 @@
|
|||||||
|
<div class="blog container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<article>
|
||||||
|
<div class="mb-4 text-center">
|
||||||
|
<h1 class="mb-1">Ghostfolio meets Umbrel</h1>
|
||||||
|
<div class="mb-3 text-muted"><small>2023-02-25</small></div>
|
||||||
|
<img
|
||||||
|
alt="Ghostfolio meets Umbrel Teaser"
|
||||||
|
class="border rounded w-100"
|
||||||
|
src="../assets/images/blog/ghostfolio-x-umbrel.png"
|
||||||
|
title="Ghostfolio meets Umbrel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<section class="mb-4">
|
||||||
|
<p>
|
||||||
|
We are happy to announce that
|
||||||
|
<a href="https://ghostfol.io">Ghostfolio</a>, the web-based personal
|
||||||
|
finance management software, is now available in the
|
||||||
|
<a href="https://umbrel.com" target="_blank">Umbrel</a> App Store, a
|
||||||
|
home server OS for self-hosting.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
In recent years, we have seen an increasing number of individuals
|
||||||
|
and organizations moving their data to the cloud. While cloud
|
||||||
|
computing has its benefits, such as accessibility and scalability,
|
||||||
|
it also comes with some concerns regarding data privacy and
|
||||||
|
security. However, there is an alternative to cloud computing that
|
||||||
|
provides the convenience of the cloud while giving you ownership and
|
||||||
|
control of your data: personal servers.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="mb-4">
|
||||||
|
<h2 class="h4">Umbrel – A personal server OS for self-hosting</h2>
|
||||||
|
<p>
|
||||||
|
<a href="https://github.com/getumbrel/umbrel" target="_blank"
|
||||||
|
>Umbrel</a
|
||||||
|
>
|
||||||
|
is an operating system based on
|
||||||
|
<a href="https://www.docker.com" target="_blank">Docker</a> that
|
||||||
|
allows you to run a personal server in your home. With it, you can
|
||||||
|
self-host open source apps directly from an integrated app store.
|
||||||
|
This means that you can discover self-hosted apps directly in the
|
||||||
|
<a href="https://github.com/getumbrel/umbrel-apps" target="_blank"
|
||||||
|
>Umbrel App Store</a
|
||||||
|
>
|
||||||
|
and install them in one click. You can get up and running Umbrel on
|
||||||
|
a Raspberry Pi 4, any Ubuntu / Debian system, or a VPS in only 5
|
||||||
|
minutes.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Umbrel offers numerous advantages for running a personal server in
|
||||||
|
your home, such as enhanced data privacy and security, ownership and
|
||||||
|
control of your data, and access to a diverse selection of
|
||||||
|
self-hosted apps.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="mb-4">
|
||||||
|
<h2 class="h4">
|
||||||
|
Ghostfolio – Track your portfolio without being tracked
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Keeping track of multiple assets can make managing your personal
|
||||||
|
finance a challenging task. However, there are tools available
|
||||||
|
beyond spreadsheets that can help you streamline the process and
|
||||||
|
make well-informed investment decisions based on data.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://github.com/ghostfolio/ghostfolio" target="_blank"
|
||||||
|
>Ghostfolio</a
|
||||||
|
>
|
||||||
|
is a modern open source web application designed to manage your
|
||||||
|
personal finance with ease and confidence. It presents your current
|
||||||
|
assets in real-time, including stocks, ETFs, cryptocurrencies,
|
||||||
|
commodities, and more. It allows you to track and analyze your
|
||||||
|
investments in one place.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The application has a range of features such as real-time asset
|
||||||
|
tracking, data import and export and advanced portfolio analytics
|
||||||
|
tools.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="mb-4">
|
||||||
|
<p>
|
||||||
|
To participate in the ongoing development of Ghostfolio, please feel
|
||||||
|
free to reach out to us on our
|
||||||
|
<a href="https://ghostfolio.slack.com" target="_blank"
|
||||||
|
>Slack channel</a
|
||||||
|
>
|
||||||
|
or via Twitter
|
||||||
|
<a href="https://twitter.com/ghostfolio_" target="_blank"
|
||||||
|
>@ghostfolio_</a
|
||||||
|
>. We look forward to hearing from you!
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section class="mb-4">
|
||||||
|
<ul class="list-inline">
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Announcement</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">App Store</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Assets</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Cloud</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Commodity</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Cryptocurrency</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Debian</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Development</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Docker</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">ETF</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Fintech</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Ghostfolio</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Home Server</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Investing</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Linux</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Open Source</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Operating System</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">OS</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">OSS</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Personal Finance</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Personal Server</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Portfolio</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Privacy</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Raspberry Pi</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Security</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Software</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Spreadsheet</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Stocks</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Ubuntu</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Umbrel</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">VPS</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Wealth Management</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,13 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { GhostfolioMeetsUmbrelPageRoutingModule } from './ghostfolio-meets-umbrel-page-routing.module';
|
||||||
|
import { GhostfolioMeetsUmbrelPageComponent } from './ghostfolio-meets-umbrel-page.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [GhostfolioMeetsUmbrelPageComponent],
|
||||||
|
imports: [CommonModule, GhostfolioMeetsUmbrelPageRoutingModule, RouterModule],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GhostfolioMeetsUmbrelPageModule {}
|
@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
@ -2,6 +2,32 @@
|
|||||||
<div class="mb-5 row">
|
<div class="mb-5 row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Blog</h3>
|
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Blog</h3>
|
||||||
|
<mat-card appearance="outlined" class="mb-3">
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="container p-0">
|
||||||
|
<div class="flex-nowrap no-gutters row">
|
||||||
|
<a
|
||||||
|
class="d-flex overflow-hidden w-100"
|
||||||
|
href="../en/blog/2023/02/ghostfolio-meets-umbrel"
|
||||||
|
>
|
||||||
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
|
<div class="h6 m-0 text-truncate">
|
||||||
|
Ghostfolio meets Umbrel
|
||||||
|
</div>
|
||||||
|
<div class="d-flex text-muted">2023-02-25</div>
|
||||||
|
</div>
|
||||||
|
<div class="align-items-center d-flex">
|
||||||
|
<ion-icon
|
||||||
|
class="chevron text-muted"
|
||||||
|
name="chevron-forward-outline"
|
||||||
|
size="small"
|
||||||
|
></ion-icon>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
<mat-card appearance="outlined" class="mb-3">
|
<mat-card appearance="outlined" class="mb-3">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="container p-0">
|
<div class="container p-0">
|
||||||
|
@ -17,8 +17,8 @@ export class LandingPageComponent implements OnDestroy, OnInit {
|
|||||||
[code: string]: { value: number };
|
[code: string]: { value: number };
|
||||||
} = {};
|
} = {};
|
||||||
public currentYear = format(new Date(), 'yyyy');
|
public currentYear = format(new Date(), 'yyyy');
|
||||||
public demoAuthToken: string;
|
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
|
public hasPermissionForDemo: boolean;
|
||||||
public hasPermissionForStatistics: boolean;
|
public hasPermissionForStatistics: boolean;
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionToCreateUser: boolean;
|
public hasPermissionToCreateUser: boolean;
|
||||||
@ -54,6 +54,7 @@ export class LandingPageComponent implements OnDestroy, OnInit {
|
|||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
countriesOfSubscribers = [],
|
countriesOfSubscribers = [],
|
||||||
|
demoAuthToken,
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
statistics
|
statistics
|
||||||
} = this.dataService.fetchInfo();
|
} = this.dataService.fetchInfo();
|
||||||
@ -64,6 +65,7 @@ export class LandingPageComponent implements OnDestroy, OnInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.hasPermissionForDemo = !!demoAuthToken;
|
||||||
this.hasPermissionForStatistics = hasPermission(
|
this.hasPermissionForStatistics = hasPermission(
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
permissions.enableStatistics
|
permissions.enableStatistics
|
||||||
|
@ -40,12 +40,18 @@
|
|||||||
>
|
>
|
||||||
Get Started
|
Get Started
|
||||||
</a>
|
</a>
|
||||||
<div class="d-inline-block mx-3 text-muted">or</div></ng-container
|
</ng-container>
|
||||||
>
|
<ng-container *ngIf="hasPermissionForDemo">
|
||||||
|
<div
|
||||||
<a class="d-inline-block" mat-stroked-button [routerLink]="['/demo']">
|
*ngIf="hasPermissionToCreateUser"
|
||||||
Live Demo
|
class="d-inline-block mx-3 text-muted"
|
||||||
</a>
|
>
|
||||||
|
or
|
||||||
|
</div>
|
||||||
|
<a class="d-inline-block" mat-stroked-button [routerLink]="['/demo']">
|
||||||
|
Live Demo
|
||||||
|
</a>
|
||||||
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -165,6 +171,14 @@
|
|||||||
title="Product Hunt – The best new products in tech."
|
title="Product Hunt – The best new products in tech."
|
||||||
></a>
|
></a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
|
||||||
|
<a
|
||||||
|
class="d-block logo logo-reddit mask"
|
||||||
|
href="https://www.reddit.com"
|
||||||
|
target="_blank"
|
||||||
|
title="Reddit - Dive into anything"
|
||||||
|
></a>
|
||||||
|
</div>
|
||||||
<div class="col-md-3 d-flex justify-content-center my-1">
|
<div class="col-md-3 d-flex justify-content-center my-1">
|
||||||
<a
|
<a
|
||||||
class="d-block logo logo-sackgeld mask"
|
class="d-block logo logo-sackgeld mask"
|
||||||
@ -181,6 +195,14 @@
|
|||||||
title="SourceForge: The Complete Open-Source and Business Software Platform"
|
title="SourceForge: The Complete Open-Source and Business Software Platform"
|
||||||
></a>
|
></a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
|
||||||
|
<a
|
||||||
|
class="d-block logo logo-umbrel mask"
|
||||||
|
href="https://umbrel.com"
|
||||||
|
target="_blank"
|
||||||
|
title="Umbrel — A personal server OS for self-hosting"
|
||||||
|
></a>
|
||||||
|
</div>
|
||||||
<div class="col-md-3 d-flex justify-content-center my-1">
|
<div class="col-md-3 d-flex justify-content-center my-1">
|
||||||
<a
|
<a
|
||||||
class="d-block logo logo-unraid mask"
|
class="d-block logo logo-unraid mask"
|
||||||
@ -363,16 +385,20 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<h2 class="h4 mb-1 text-center">Are <strong>you</strong> ready?</h2>
|
<h2 class="h4 mb-1 text-center">Are <strong>you</strong> ready?</h2>
|
||||||
<p class="lead mb-3 text-center">
|
<p class="lead mb-3 text-center">
|
||||||
Join now or check out the example account
|
Join now<ng-container *ngIf="hasPermissionForDemo">
|
||||||
|
or check out the example account</ng-container
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
<div class="py-2 text-center">
|
<div class="py-2 text-center">
|
||||||
<a color="primary" mat-flat-button [routerLink]="['/register']">
|
<a color="primary" mat-flat-button [routerLink]="['/register']">
|
||||||
Get Started
|
Get Started
|
||||||
</a>
|
</a>
|
||||||
<div class="d-inline-block mx-3 text-muted">or</div>
|
<ng-container *ngIf="hasPermissionForDemo">
|
||||||
<a class="d-inline-block" mat-stroked-button [routerLink]="['/demo']">
|
<div class="d-inline-block mx-3 text-muted">or</div>
|
||||||
Live Demo
|
<a class="d-inline-block" mat-stroked-button [routerLink]="['/demo']">
|
||||||
</a>
|
Live Demo
|
||||||
|
</a>
|
||||||
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -69,6 +69,11 @@
|
|||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.logo-reddit {
|
||||||
|
mask-image: url('/assets/images/logo-reddit.svg');
|
||||||
|
max-height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
&.logo-sackgeld {
|
&.logo-sackgeld {
|
||||||
mask-image: url('/assets/images/logo-sackgeld.png');
|
mask-image: url('/assets/images/logo-sackgeld.png');
|
||||||
}
|
}
|
||||||
@ -77,6 +82,11 @@
|
|||||||
mask-image: url('/assets/images/logo-sourceforge.svg');
|
mask-image: url('/assets/images/logo-sourceforge.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.logo-umbrel {
|
||||||
|
mask-image: url('/assets/images/logo-umbrel.svg');
|
||||||
|
max-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
&.logo-unraid {
|
&.logo-unraid {
|
||||||
mask-image: url('/assets/images/logo-unraid.svg');
|
mask-image: url('/assets/images/logo-unraid.svg');
|
||||||
}
|
}
|
||||||
@ -114,8 +124,10 @@
|
|||||||
&.logo-agplv3,
|
&.logo-agplv3,
|
||||||
&.logo-alternative-to,
|
&.logo-alternative-to,
|
||||||
&.logo-privacy-tools,
|
&.logo-privacy-tools,
|
||||||
|
&.logo-reddit,
|
||||||
&.logo-sackgeld,
|
&.logo-sackgeld,
|
||||||
&.logo-sourceforge,
|
&.logo-sourceforge,
|
||||||
|
&.logo-umbrel,
|
||||||
&.logo-unraid {
|
&.logo-unraid {
|
||||||
background-color: rgba(var(--light-primary-text));
|
background-color: rgba(var(--light-primary-text));
|
||||||
}
|
}
|
||||||
|
@ -88,8 +88,8 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
|||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
.onChangeHasImpersonation()
|
.onChangeHasImpersonation()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((aId) => {
|
.subscribe((impersonationId) => {
|
||||||
this.hasImpersonationId = !!aId;
|
this.hasImpersonationId = !!impersonationId;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
|
@ -9,12 +9,13 @@ import {
|
|||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
|
|
||||||
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
|
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
|
||||||
|
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
|
||||||
import {
|
import {
|
||||||
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
|
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
|
||||||
MatLegacyDialogRef as MatDialogRef
|
MatLegacyDialogRef as MatDialogRef
|
||||||
} from '@angular/material/legacy-dialog';
|
} from '@angular/material/legacy-dialog';
|
||||||
|
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
@ -56,6 +57,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
});
|
});
|
||||||
public currencies: string[] = [];
|
public currencies: string[] = [];
|
||||||
public currentMarketPrice = null;
|
public currentMarketPrice = null;
|
||||||
|
public defaultDateFormat: string;
|
||||||
public filteredLookupItems: LookupItem[];
|
public filteredLookupItems: LookupItem[];
|
||||||
public filteredLookupItemsObservable: Observable<LookupItem[]>;
|
public filteredLookupItemsObservable: Observable<LookupItem[]>;
|
||||||
public filteredTagsObservable: Observable<Tag[]>;
|
public filteredTagsObservable: Observable<Tag[]>;
|
||||||
@ -85,6 +87,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
const { currencies, platforms, tags } = this.dataService.fetchInfo();
|
const { currencies, platforms, tags } = this.dataService.fetchInfo();
|
||||||
|
|
||||||
this.currencies = currencies;
|
this.currencies = currencies;
|
||||||
|
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||||
this.platforms = platforms;
|
this.platforms = platforms;
|
||||||
this.tags = tags.map(({ id, name }) => {
|
this.tags = tags.map(({ id, name }) => {
|
||||||
return {
|
return {
|
||||||
@ -106,6 +109,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
this.data.activity?.SymbolProfile?.currency,
|
this.data.activity?.SymbolProfile?.currency,
|
||||||
Validators.required
|
Validators.required
|
||||||
],
|
],
|
||||||
|
currencyOfUnitPrice: [
|
||||||
|
this.data.activity?.SymbolProfile?.currency,
|
||||||
|
Validators.required
|
||||||
|
],
|
||||||
dataSource: [
|
dataSource: [
|
||||||
this.data.activity?.SymbolProfile?.dataSource,
|
this.data.activity?.SymbolProfile?.dataSource,
|
||||||
Validators.required
|
Validators.required
|
||||||
@ -131,16 +138,26 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
})
|
})
|
||||||
],
|
],
|
||||||
type: [undefined, Validators.required], // Set after value changes subscription
|
type: [undefined, Validators.required], // Set after value changes subscription
|
||||||
unitPrice: [this.data.activity?.unitPrice, Validators.required]
|
unitPrice: [this.data.activity?.unitPrice, Validators.required],
|
||||||
|
unitPriceInCustomCurrency: [
|
||||||
|
this.data.activity?.unitPrice,
|
||||||
|
Validators.required
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
this.activityForm.valueChanges
|
this.activityForm.valueChanges
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(async () => {
|
.subscribe(async () => {
|
||||||
let exchangeRate = 1;
|
let exchangeRateOfFee = 1;
|
||||||
|
let exchangeRateOfUnitPrice = 1;
|
||||||
|
|
||||||
|
this.activityForm.controls['feeInCustomCurrency'].setErrors(null);
|
||||||
|
this.activityForm.controls['unitPriceInCustomCurrency'].setErrors(null);
|
||||||
|
|
||||||
const currency = this.activityForm.controls['currency'].value;
|
const currency = this.activityForm.controls['currency'].value;
|
||||||
const currencyOfFee = this.activityForm.controls['currencyOfFee'].value;
|
const currencyOfFee = this.activityForm.controls['currencyOfFee'].value;
|
||||||
|
const currencyOfUnitPrice =
|
||||||
|
this.activityForm.controls['currencyOfUnitPrice'].value;
|
||||||
const date = this.activityForm.controls['date'].value;
|
const date = this.activityForm.controls['date'].value;
|
||||||
|
|
||||||
if (currency && currencyOfFee && currency !== currencyOfFee && date) {
|
if (currency && currencyOfFee && currency !== currencyOfFee && date) {
|
||||||
@ -154,18 +171,57 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
);
|
);
|
||||||
|
|
||||||
exchangeRate = marketPrice;
|
exchangeRateOfFee = marketPrice;
|
||||||
} catch {}
|
} catch {
|
||||||
|
this.activityForm.controls['feeInCustomCurrency'].setErrors({
|
||||||
|
invalid: true
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const feeInCustomCurrency =
|
const feeInCustomCurrency =
|
||||||
this.activityForm.controls['feeInCustomCurrency'].value *
|
this.activityForm.controls['feeInCustomCurrency'].value *
|
||||||
exchangeRate;
|
exchangeRateOfFee;
|
||||||
|
|
||||||
this.activityForm.controls['fee'].setValue(feeInCustomCurrency, {
|
this.activityForm.controls['fee'].setValue(feeInCustomCurrency, {
|
||||||
emitEvent: false
|
emitEvent: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
currency &&
|
||||||
|
currencyOfUnitPrice &&
|
||||||
|
currency !== currencyOfUnitPrice &&
|
||||||
|
date
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { marketPrice } = await lastValueFrom(
|
||||||
|
this.dataService
|
||||||
|
.fetchExchangeRateForDate({
|
||||||
|
date,
|
||||||
|
symbol: `${currencyOfUnitPrice}-${currency}`
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
);
|
||||||
|
|
||||||
|
exchangeRateOfUnitPrice = marketPrice;
|
||||||
|
} catch {
|
||||||
|
this.activityForm.controls['unitPriceInCustomCurrency'].setErrors({
|
||||||
|
invalid: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitPriceInCustomCurrency =
|
||||||
|
this.activityForm.controls['unitPriceInCustomCurrency'].value *
|
||||||
|
exchangeRateOfUnitPrice;
|
||||||
|
|
||||||
|
this.activityForm.controls['unitPrice'].setValue(
|
||||||
|
unitPriceInCustomCurrency,
|
||||||
|
{
|
||||||
|
emitEvent: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.activityForm.controls['type'].value === 'BUY' ||
|
this.activityForm.controls['type'].value === 'BUY' ||
|
||||||
this.activityForm.controls['type'].value === 'ITEM'
|
this.activityForm.controls['type'].value === 'ITEM'
|
||||||
@ -187,11 +243,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
this.filteredLookupItemsObservable = this.activityForm.controls[
|
this.filteredLookupItemsObservable = this.activityForm.controls[
|
||||||
'searchSymbol'
|
'searchSymbol'
|
||||||
].valueChanges.pipe(
|
].valueChanges.pipe(
|
||||||
startWith(''),
|
|
||||||
debounceTime(400),
|
debounceTime(400),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
switchMap((query: string) => {
|
switchMap((query: string) => {
|
||||||
if (isString(query)) {
|
if (isString(query) && query.length > 1) {
|
||||||
const filteredLookupItemsObservable =
|
const filteredLookupItemsObservable =
|
||||||
this.dataService.fetchSymbols(query);
|
this.dataService.fetchSymbols(query);
|
||||||
|
|
||||||
@ -231,6 +286,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
this.activityForm.controls['currencyOfFee'].setValue(
|
this.activityForm.controls['currencyOfFee'].setValue(
|
||||||
this.data.user.settings.baseCurrency
|
this.data.user.settings.baseCurrency
|
||||||
);
|
);
|
||||||
|
this.activityForm.controls['currencyOfUnitPrice'].setValue(
|
||||||
|
this.data.user.settings.baseCurrency
|
||||||
|
);
|
||||||
this.activityForm.controls['dataSource'].removeValidators(
|
this.activityForm.controls['dataSource'].removeValidators(
|
||||||
Validators.required
|
Validators.required
|
||||||
);
|
);
|
||||||
@ -288,7 +346,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
|
|
||||||
public applyCurrentMarketPrice() {
|
public applyCurrentMarketPrice() {
|
||||||
this.activityForm.patchValue({
|
this.activityForm.patchValue({
|
||||||
unitPrice: this.currentMarketPrice
|
currencyOfUnitPrice: this.activityForm.controls['currency'].value,
|
||||||
|
unitPriceInCustomCurrency: this.currentMarketPrice
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -415,6 +474,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
|||||||
.subscribe(({ currency, dataSource, marketPrice }) => {
|
.subscribe(({ currency, dataSource, marketPrice }) => {
|
||||||
this.activityForm.controls['currency'].setValue(currency);
|
this.activityForm.controls['currency'].setValue(currency);
|
||||||
this.activityForm.controls['currencyOfFee'].setValue(currency);
|
this.activityForm.controls['currencyOfFee'].setValue(currency);
|
||||||
|
this.activityForm.controls['currencyOfUnitPrice'].setValue(currency);
|
||||||
this.activityForm.controls['dataSource'].setValue(dataSource);
|
this.activityForm.controls['dataSource'].setValue(dataSource);
|
||||||
|
|
||||||
this.currentMarketPrice = marketPrice;
|
this.currentMarketPrice = marketPrice;
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
<h1 *ngIf="data.activity.id" i18n mat-dialog-title>Update activity</h1>
|
<h1 *ngIf="data.activity.id" i18n mat-dialog-title>Update activity</h1>
|
||||||
<h1 *ngIf="!data.activity.id" i18n mat-dialog-title>Add activity</h1>
|
<h1 *ngIf="!data.activity.id" i18n mat-dialog-title>Add activity</h1>
|
||||||
<div class="flex-grow-1 pt-3" mat-dialog-content>
|
<div class="flex-grow-1 pt-3" mat-dialog-content>
|
||||||
<div>
|
<div class="mb-3">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Type</mat-label>
|
<mat-label i18n>Type</mat-label>
|
||||||
<mat-select formControlName="type">
|
<mat-select formControlName="type">
|
||||||
@ -18,7 +18,7 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="mb-3">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Account</mat-label>
|
<mat-label i18n>Account</mat-label>
|
||||||
<mat-select formControlName="accountId">
|
<mat-select formControlName="accountId">
|
||||||
@ -33,6 +33,7 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
class="mb-3"
|
||||||
[ngClass]="{ 'd-none': !activityForm.controls['searchSymbol'].hasValidator(Validators.required) }"
|
[ngClass]="{ 'd-none': !activityForm.controls['searchSymbol'].hasValidator(Validators.required) }"
|
||||||
>
|
>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
@ -54,11 +55,15 @@
|
|||||||
<ng-container>
|
<ng-container>
|
||||||
<mat-option
|
<mat-option
|
||||||
*ngFor="let lookupItem of filteredLookupItemsObservable | async"
|
*ngFor="let lookupItem of filteredLookupItemsObservable | async"
|
||||||
class="autocomplete"
|
class="line-height-1"
|
||||||
[value]="lookupItem"
|
[value]="lookupItem"
|
||||||
>
|
>
|
||||||
<span class="mr-2 symbol">{{ lookupItem.symbol | gfSymbol }}</span
|
<span><b>{{ lookupItem.name }}</b></span>
|
||||||
><span><b>{{ lookupItem.name }}</b></span>
|
<br />
|
||||||
|
<small class="text-muted"
|
||||||
|
>{{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency
|
||||||
|
}}</small
|
||||||
|
>
|
||||||
</mat-option>
|
</mat-option>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</mat-autocomplete>
|
</mat-autocomplete>
|
||||||
@ -66,6 +71,7 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
class="mb-3"
|
||||||
[ngClass]="{ 'd-none': !activityForm.controls['name'].hasValidator(Validators.required) }"
|
[ngClass]="{ 'd-none': !activityForm.controls['name'].hasValidator(Validators.required) }"
|
||||||
>
|
>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
@ -89,7 +95,7 @@
|
|||||||
<input formControlName="dataSource" matInput />
|
<input formControlName="dataSource" matInput />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="mb-3">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Date</mat-label>
|
<mat-label i18n>Date</mat-label>
|
||||||
<input formControlName="date" matInput [matDatepicker]="date" />
|
<input formControlName="date" matInput [matDatepicker]="date" />
|
||||||
@ -103,13 +109,60 @@
|
|||||||
<mat-datepicker #date disabled="false"></mat-datepicker>
|
<mat-datepicker #date disabled="false"></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="mb-3">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Quantity</mat-label>
|
<mat-label i18n>Quantity</mat-label>
|
||||||
<input formControlName="quantity" matInput type="number" />
|
<input formControlName="quantity" matInput type="number" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="align-items-start d-flex">
|
<div class="align-items-start d-flex mb-3">
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label
|
||||||
|
><ng-container [ngSwitch]="activityForm.controls['type']?.value">
|
||||||
|
<ng-container *ngSwitchCase="'DIVIDEND'" i18n
|
||||||
|
>Dividend</ng-container
|
||||||
|
>
|
||||||
|
<ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container>
|
||||||
|
<ng-container *ngSwitchDefault i18n>Unit Price</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</mat-label>
|
||||||
|
<input
|
||||||
|
formControlName="unitPriceInCustomCurrency"
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="ml-2"
|
||||||
|
matTextSuffix
|
||||||
|
[ngClass]="{ 'd-none': !activityForm.controls['currency']?.value }"
|
||||||
|
>
|
||||||
|
<mat-select formControlName="currencyOfUnitPrice">
|
||||||
|
<mat-option *ngFor="let currency of currencies" [value]="currency">
|
||||||
|
{{ currency }}
|
||||||
|
</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</div>
|
||||||
|
<mat-error
|
||||||
|
*ngIf="activityForm.controls['unitPriceInCustomCurrency'].hasError('invalid')"
|
||||||
|
><ng-container i18n
|
||||||
|
>Oops! Could not get the historical exchange rate from</ng-container
|
||||||
|
>
|
||||||
|
{{ activityForm.controls['date']?.value | date: defaultDateFormat
|
||||||
|
}}</mat-error
|
||||||
|
>
|
||||||
|
</mat-form-field>
|
||||||
|
<button
|
||||||
|
*ngIf="currentMarketPrice && (data.activity.type === 'BUY' || data.activity.type === 'SELL')"
|
||||||
|
class="apply-current-market-price ml-2 no-min-width"
|
||||||
|
mat-button
|
||||||
|
title="Apply current market price"
|
||||||
|
type="button"
|
||||||
|
(click)="applyCurrentMarketPrice()"
|
||||||
|
>
|
||||||
|
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-none">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label
|
<mat-label
|
||||||
><ng-container [ngSwitch]="activityForm.controls['type']?.value">
|
><ng-container [ngSwitch]="activityForm.controls['type']?.value">
|
||||||
@ -125,18 +178,8 @@
|
|||||||
>{{ activityForm.controls['currency'].value }}</span
|
>{{ activityForm.controls['currency'].value }}</span
|
||||||
>
|
>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<button
|
|
||||||
*ngIf="currentMarketPrice && (data.activity.type === 'BUY' || data.activity.type === 'SELL')"
|
|
||||||
class="apply-current-market-price ml-2 no-min-width"
|
|
||||||
mat-button
|
|
||||||
title="Apply current market price"
|
|
||||||
type="button"
|
|
||||||
(click)="applyCurrentMarketPrice()"
|
|
||||||
>
|
|
||||||
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="mb-3">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Fee</mat-label>
|
<mat-label i18n>Fee</mat-label>
|
||||||
<input formControlName="feeInCustomCurrency" matInput type="number" />
|
<input formControlName="feeInCustomCurrency" matInput type="number" />
|
||||||
@ -151,6 +194,14 @@
|
|||||||
</mat-option>
|
</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</div>
|
</div>
|
||||||
|
<mat-error
|
||||||
|
*ngIf="activityForm.controls['feeInCustomCurrency'].hasError('invalid')"
|
||||||
|
><ng-container i18n
|
||||||
|
>Oops! Could not get the historical exchange rate from</ng-container
|
||||||
|
>
|
||||||
|
{{ activityForm.controls['date']?.value | date: defaultDateFormat
|
||||||
|
}}</mat-error
|
||||||
|
>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-none">
|
<div class="d-none">
|
||||||
@ -162,7 +213,7 @@
|
|||||||
>
|
>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="mb-3">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Note</mat-label>
|
<mat-label i18n>Note</mat-label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -175,6 +226,7 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
class="mb-3"
|
||||||
[ngClass]="{ 'd-none': activityForm.controls['type']?.value !== 'ITEM' }"
|
[ngClass]="{ 'd-none': activityForm.controls['type']?.value !== 'ITEM' }"
|
||||||
>
|
>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
@ -190,6 +242,7 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
class="mb-3"
|
||||||
[ngClass]="{ 'd-none': activityForm.controls['type']?.value !== 'ITEM' }"
|
[ngClass]="{ 'd-none': activityForm.controls['type']?.value !== 'ITEM' }"
|
||||||
>
|
>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
@ -204,7 +257,7 @@
|
|||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div [ngClass]="{ 'd-none': tags?.length <= 0 }">
|
<div class="mb-3" [ngClass]="{ 'd-none': tags?.length <= 0 }">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Tags</mat-label>
|
<mat-label i18n>Tags</mat-label>
|
||||||
<mat-chip-grid #tagsChipList>
|
<mat-chip-grid #tagsChipList>
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
|
||||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatChipsModule } from '@angular/material/chips';
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||||
|
@ -10,16 +10,6 @@
|
|||||||
.mat-dialog-content {
|
.mat-dialog-content {
|
||||||
max-height: unset;
|
max-height: unset;
|
||||||
|
|
||||||
.autocomplete {
|
|
||||||
font-size: 90%;
|
|
||||||
height: 2.5rem;
|
|
||||||
|
|
||||||
.symbol {
|
|
||||||
display: inline-block;
|
|
||||||
min-width: 4rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-datepicker-input {
|
.mat-datepicker-input {
|
||||||
&.mat-mdc-input-element:disabled {
|
&.mat-mdc-input-element:disabled {
|
||||||
color: var(--dark-primary-text);
|
color: var(--dark-primary-text);
|
||||||
|
@ -118,8 +118,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
.onChangeHasImpersonation()
|
.onChangeHasImpersonation()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((aId) => {
|
.subscribe((impersonationId) => {
|
||||||
this.hasImpersonationId = !!aId;
|
this.hasImpersonationId = !!impersonationId;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.filters$
|
this.filters$
|
||||||
|
@ -109,8 +109,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
|||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
.onChangeHasImpersonation()
|
.onChangeHasImpersonation()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((aId) => {
|
.subscribe((impersonationId) => {
|
||||||
this.hasImpersonationId = !!aId;
|
this.hasImpersonationId = !!impersonationId;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.filters$
|
this.filters$
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { PortfolioReportRule, User } from '@ghostfolio/common/interfaces';
|
import { PortfolioReportRule, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -20,6 +21,7 @@ export class FirePageComponent implements OnDestroy, OnInit {
|
|||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public feeRules: PortfolioReportRule[];
|
public feeRules: PortfolioReportRule[];
|
||||||
public fireWealth: Big;
|
public fireWealth: Big;
|
||||||
|
public hasImpersonationId: boolean;
|
||||||
public hasPermissionToCreateOrder: boolean;
|
public hasPermissionToCreateOrder: boolean;
|
||||||
public hasPermissionToUpdateUserSettings: boolean;
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
public isLoading = false;
|
public isLoading = false;
|
||||||
@ -33,6 +35,7 @@ export class FirePageComponent implements OnDestroy, OnInit {
|
|||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
|
private impersonationStorageService: ImpersonationStorageService,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -70,6 +73,13 @@ export class FirePageComponent implements OnDestroy, OnInit {
|
|||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.impersonationStorageService
|
||||||
|
.onChangeHasImpersonation()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((impersonationId) => {
|
||||||
|
this.hasImpersonationId = !!impersonationId;
|
||||||
|
});
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
@ -91,6 +101,45 @@ export class FirePageComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onAnnualInterestRateChange(annualInterestRate: number) {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({ annualInterestRate })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onRetirementDateChange(retirementDate: Date) {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({
|
||||||
|
retirementDate: retirementDate.toISOString(),
|
||||||
|
projectedTotalAmount: null
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onSavingsRateChange(savingsRate: number) {
|
public onSavingsRateChange(savingsRate: number) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.putUserSetting({ savingsRate })
|
.putUserSetting({ savingsRate })
|
||||||
@ -109,6 +158,27 @@ export class FirePageComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onProjectedTotalAmountChange(projectedTotalAmount: number) {
|
||||||
|
this.dataService
|
||||||
|
.putUserSetting({
|
||||||
|
projectedTotalAmount,
|
||||||
|
retirementDate: null
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
|
@ -11,13 +11,19 @@
|
|||||||
></gf-premium-indicator>
|
></gf-premium-indicator>
|
||||||
</h4>
|
</h4>
|
||||||
<gf-fire-calculator
|
<gf-fire-calculator
|
||||||
|
[annualInterestRate]="user?.settings?.annualInterestRate"
|
||||||
[colorScheme]="user?.settings?.colorScheme"
|
[colorScheme]="user?.settings?.colorScheme"
|
||||||
[currency]="user?.settings?.baseCurrency"
|
[currency]="user?.settings?.baseCurrency"
|
||||||
[deviceType]="deviceType"
|
[deviceType]="deviceType"
|
||||||
[fireWealth]="fireWealth?.toNumber()"
|
[fireWealth]="fireWealth?.toNumber()"
|
||||||
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings"
|
[hasPermissionToUpdateUserSettings]="!hasImpersonationId && hasPermissionToUpdateUserSettings"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
|
[projectedTotalAmount]="user?.settings?.projectedTotalAmount"
|
||||||
|
[retirementDate]="user?.settings?.retirementDate"
|
||||||
[savingsRate]="user?.settings?.savingsRate"
|
[savingsRate]="user?.settings?.savingsRate"
|
||||||
|
(annualInterestRateChanged)="onAnnualInterestRateChange($event)"
|
||||||
|
(projectedTotalAmountChanged)="onProjectedTotalAmountChange($event)"
|
||||||
|
(retirementDateChanged)="onRetirementDateChange($event)"
|
||||||
(savingsRateChanged)="onSavingsRateChange($event)"
|
(savingsRateChanged)="onSavingsRateChange($event)"
|
||||||
></gf-fire-calculator>
|
></gf-fire-calculator>
|
||||||
</div>
|
</div>
|
||||||
@ -69,12 +75,14 @@
|
|||||||
></gf-value>
|
></gf-value>
|
||||||
per month</span
|
per month</span
|
||||||
>, based on your total assets of
|
>, based on your total assets of
|
||||||
<gf-value
|
<span class="font-weight-bold"
|
||||||
class="d-inline-block"
|
><gf-value
|
||||||
[currency]="user?.settings?.baseCurrency"
|
class="d-inline-block"
|
||||||
[locale]="user?.settings?.locale"
|
[currency]="user?.settings?.baseCurrency"
|
||||||
[value]="fireWealth?.toNumber()"
|
[locale]="user?.settings?.locale"
|
||||||
></gf-value>
|
[value]="fireWealth?.toNumber()"
|
||||||
|
></gf-value
|
||||||
|
></span>
|
||||||
and a withdrawal rate of 4%.
|
and a withdrawal rate of 4%.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -72,8 +72,8 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
|
|||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
.onChangeHasImpersonation()
|
.onChangeHasImpersonation()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((aId) => {
|
.subscribe((impersonationId) => {
|
||||||
this.hasImpersonationId = !!aId;
|
this.hasImpersonationId = !!impersonationId;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.filters$
|
this.filters$
|
||||||
|
@ -15,6 +15,9 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
export class PricingPageComponent implements OnDestroy, OnInit {
|
export class PricingPageComponent implements OnDestroy, OnInit {
|
||||||
public baseCurrency: string;
|
public baseCurrency: string;
|
||||||
public coupon: number;
|
public coupon: number;
|
||||||
|
public importAndExportTooltipBasic = translate(
|
||||||
|
'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC'
|
||||||
|
);
|
||||||
public importAndExportTooltipOSS = translate(
|
public importAndExportTooltipOSS = translate(
|
||||||
'DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS'
|
'DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS'
|
||||||
);
|
);
|
||||||
|
@ -106,6 +106,13 @@
|
|||||||
></ion-icon>
|
></ion-icon>
|
||||||
<a i18n [routerLink]="['/features']">and more Features...</a>
|
<a i18n [routerLink]="['/features']">and more Features...</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="align-items-center d-flex mb-1">
|
||||||
|
<ion-icon
|
||||||
|
class="mr-1"
|
||||||
|
name="checkmark-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<span i18n>Community Support</span>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p i18n>Self-hosted, update manually.</p>
|
<p i18n>Self-hosted, update manually.</p>
|
||||||
@ -158,35 +165,19 @@
|
|||||||
></ion-icon>
|
></ion-icon>
|
||||||
<span i18n>Portfolio Performance</span>
|
<span i18n>Portfolio Performance</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li class="align-items-center d-flex mb-1">
|
||||||
<ion-icon
|
<ion-icon
|
||||||
class="invisible"
|
class="mr-1"
|
||||||
name="checkmark-circle-outline"
|
|
||||||
></ion-icon>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<ion-icon
|
|
||||||
class="invisible"
|
|
||||||
name="checkmark-circle-outline"
|
|
||||||
></ion-icon>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<ion-icon
|
|
||||||
class="invisible"
|
|
||||||
name="checkmark-circle-outline"
|
|
||||||
></ion-icon>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<ion-icon
|
|
||||||
class="invisible"
|
|
||||||
name="checkmark-circle-outline"
|
|
||||||
></ion-icon>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<ion-icon
|
|
||||||
class="invisible"
|
|
||||||
name="checkmark-circle-outline"
|
name="checkmark-circle-outline"
|
||||||
></ion-icon>
|
></ion-icon>
|
||||||
|
<span i18n>Data Import and Export</span>
|
||||||
|
<span
|
||||||
|
class="align-items-center d-flex ml-1"
|
||||||
|
matTooltipPosition="above"
|
||||||
|
[matTooltip]="importAndExportTooltipBasic"
|
||||||
|
>
|
||||||
|
<ion-icon name="information-circle-outline"></ion-icon>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -289,6 +280,13 @@
|
|||||||
<ion-icon name="information-circle-outline"></ion-icon>
|
<ion-icon name="information-circle-outline"></ion-icon>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="align-items-center d-flex mb-1">
|
||||||
|
<ion-icon
|
||||||
|
class="mr-1"
|
||||||
|
name="checkmark-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<span i18n>Professional Data Provider</span>
|
||||||
|
</li>
|
||||||
<li class="align-items-center d-flex mb-1">
|
<li class="align-items-center d-flex mb-1">
|
||||||
<ion-icon
|
<ion-icon
|
||||||
class="mr-1"
|
class="mr-1"
|
||||||
@ -296,6 +294,13 @@
|
|||||||
></ion-icon>
|
></ion-icon>
|
||||||
<a i18n [routerLink]="['/features']">and more Features...</a>
|
<a i18n [routerLink]="['/features']">and more Features...</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="align-items-center d-flex mb-1">
|
||||||
|
<ion-icon
|
||||||
|
class="mr-1"
|
||||||
|
name="checkmark-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<span i18n>Email and Chat Support</span>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p i18n>Fully managed Ghostfolio cloud offering.</p>
|
<p i18n>Fully managed Ghostfolio cloud offering.</p>
|
||||||
|
@ -63,7 +63,7 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
public async createAccount() {
|
public async createAccount() {
|
||||||
this.dataService
|
this.dataService
|
||||||
.postUser({ country: this.userService.getCountry() })
|
.postUser()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ accessToken, authToken, role }) => {
|
.subscribe(({ accessToken, authToken, role }) => {
|
||||||
this.openShowAccessTokenDialog(accessToken, authToken, role);
|
this.openShowAccessTokenDialog(accessToken, authToken, role);
|
||||||
|
@ -21,7 +21,6 @@
|
|||||||
class="d-inline-block"
|
class="d-inline-block"
|
||||||
color="primary"
|
color="primary"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="!demoAuthToken"
|
|
||||||
(click)="createAccount()"
|
(click)="createAccount()"
|
||||||
>
|
>
|
||||||
<ng-container i18n>Create Account</ng-container>
|
<ng-container i18n>Create Account</ng-container>
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user