Compare commits

..

68 Commits

Author SHA1 Message Date
b404858904 Release 1.246.0 (#1794) 2023-03-18 10:36:40 +01:00
7ec033577f Feature/extend trackinsight data enhancer by isin (#1793)
* Extend data enhancer by isin

* Update changelog
2023-03-18 10:34:50 +01:00
c8ca82b803 Feature/extend data source eod historical data by asset class and isin (#1791)
* Extend EodHistoricalDataService

* asset and asset sub class
* isin

* Update changelog
2023-03-18 10:09:11 +01:00
5db2faa17d Bugfix/fix border color in fire calculator (#1792)
* Fix border color

* Update changelog
2023-03-17 19:37:36 +01:00
1605fb8d48 Feature/improve language localization for data gathering (#1790)
* Improve locales

* Update changelog
2023-03-16 20:54:34 +01:00
b6a7804a26 Refactoring (#1784) 2023-03-14 10:46:11 +01:00
de31381fd9 Release 1.245.0 (#1787) 2023-03-12 14:33:00 +01:00
0d92b8d8bb Reduce search requests (#1786) 2023-03-12 14:29:22 +01:00
7c6ff776d9 Feature/add search functionality for eod historical data (#1783)
* Add search functionality for EOD_HISTORICAL_DATA

* Update changelog
2023-03-12 13:13:34 +01:00
e37a34ed6c Feature/improve exchange rate service for specific date (#1785)
* Calculate exchange rate indirectly via base currency

* Update changelog
2023-03-12 12:19:13 +01:00
c4d9c00f92 Feature/upgrade ngx device detector to version 5.0.1 (#1782)
* Upgrade ngx-device-detector to version 5.0.1

* Update changelog
2023-03-12 10:14:35 +01:00
3af8be89e3 Feature/improve usability of fire calculator (#1779)
* Improve usability

* Add debounce
* Persist annualInterestRate
* Partially disable date picker

* Update changelog
2023-03-12 09:55:55 +01:00
0f1db71604 Release 1.244.0 (#1778) 2023-03-09 22:14:28 +01:00
fce9e7fb0c Feature/extend fire calculator by retirement date (#1748)
* Extend fire calculator by retirement date

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-03-09 22:12:01 +01:00
6301c0c21c Clean up (#1767) 2023-03-09 13:33:41 +01:00
30bb484d5a Release 1.243.0 (#1777) 2023-03-08 20:25:27 +01:00
f88ee5e5a0 Feature/improve validation for manual currency (#1769)
* Improve validation

* Update changelog
2023-03-08 20:23:03 +01:00
73b5030972 Remove documentation of BASE_CURRENCY (#1776) 2023-03-08 20:22:39 +01:00
a69a3442ab Feature/add coingecko as default data source (#1775)
* Add CoinGecko as a default data source

* Update changelog
2023-03-08 20:13:53 +01:00
d4dff744b5 Sort imports (#1770) 2023-03-07 22:05:02 +01:00
62c93ad99d Make NODE_ENV optional in production
* Make NODE_ENV optional

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-03-06 21:02:20 +01:00
1e42d6bffa Feature/harmonize axis style of charts (#1768)
* Harmonize axis style

* Update changelog
2023-03-06 19:58:43 +01:00
002ac29f2f Feature/remove environment variable for custom symbols (#1766)
* Remove environment variable

* Update changelog
2023-03-05 09:46:38 +01:00
20ccf389e9 Release 1.242.0 (#1765) 2023-03-04 10:32:51 +01:00
a2f99ed4d2 Feature/upgrade ngx skeleton loader to version 7.0.0 (#1758)
* Upgrade ngx-skeleton-loader to version 7.0.0

* Update changelog
2023-03-04 10:30:15 +01:00
cc6320acfd Feature/simplify database seeding (#1763)
* Simplify database seeding

* Update documentation

* Update changelog
2023-03-04 10:16:16 +01:00
261a0fb0b9 Refactor AuthInterceptor (#1764)
* Refactor AuthInterceptor

* Refactor JwtStrategy
2023-03-04 10:13:04 +01:00
cfc05cce41 Resolve segmentation fault during prisma db migrations (#1761)
* Resolves segmentation fault during prisma db migrations by downgrading Node.js from version 18 to 16

* Update changelog
2023-03-02 19:28:58 +01:00
1f15b70134 Release 1.241.0 (#1760) 2023-03-01 20:53:59 +01:00
a5b49b286d Feature/filter item activities from search results (#1759)
* Filter ITEM activities from search results

* Update changelog
2023-03-01 20:52:12 +01:00
f3333f24da Feature/upgrade stripe dependencies 20230226 (#1755)
* Upgrade Stripe dependencies

* Consider language of user in Stripe checkout

* Update changelog
2023-03-01 20:25:48 +01:00
cad8f0d0e2 Feature/upgrade twitter api v2 to version 1.14.2 (#1754)
* Update twitter-api-v2 to version 1.14.2

* Update changelog
2023-02-27 08:17:13 +01:00
edd3e75730 Release 1.240.0 (#1753) 2023-02-26 17:13:50 +01:00
ab68c2c69a Bugfix/fix feature graphic of umbrel blog post (#1752)
* Fix feature graphic

* Update changelog
2023-02-26 17:12:38 +01:00
cbb95f21a3 Feature/support manual currency for unit price (#1751)
* Support manual currency for unit price

* Update changelog
2023-02-26 17:10:13 +01:00
74d3954335 Release 1.239.0 (#1750) 2023-02-25 20:28:30 +01:00
92449b0369 Feature/remove rimraf (#1739)
* Remove rimraf

* Update changelog
2023-02-25 20:26:56 +01:00
65276483e0 Feature/add umbrel blog post (#1749)
* Add blog post: Ghostfolio meets Umbrel

* Update changelog
2023-02-25 20:14:33 +01:00
dde0d1e465 Add linux/arm/v7 (#1741) 2023-02-25 11:34:22 +01:00
3ad802c6f5 Release 1.238.0 (#1747) 2023-02-25 11:23:05 +01:00
b81377a682 Feature/rename example env file (#1734)
* Rename .env to .env.example

* Ignore .env file

* Update changelog
2023-02-25 11:20:38 +01:00
545180b88f Feature/add reddit and umbrel logos to landing page (#1745)
* Add Reddit and Umbrel logos

* Update changelog
2023-02-25 11:20:04 +01:00
a9819b9e25 Feature/upgrade zone.js to version 0.12.0 (#1740)
* Upgrade zone.js

* Update changelog
2023-02-25 10:33:59 +01:00
897e941e7a Feature/add data provider info to position (#1730)
* Add data provider info

* Update changelog
2023-02-25 10:33:45 +01:00
aef840c2cc Bugfix/fix maximum call stack size exceeded error in value redaction (#1743)
* Bugfix for RangeError: Maximum call stack size exceeded

* Update changelog
2023-02-25 10:15:25 +01:00
80d0638922 Adding Coingecko Data Provider (#1736)
* Adding Coingecko Data Provider

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-02-25 09:45:01 +01:00
494ba36d44 Feature/reset letter spacing in buttons (#1742)
* Reset letter spacing in buttons

* Update changelog
2023-02-24 20:44:20 +01:00
dab9154092 Add support for armv7 processors (#1738)
* Add support for armv7 processors

* Update changelog
2023-02-23 18:09:35 +01:00
cd4a85abbf Improve wording (#1729) 2023-02-21 14:43:21 +01:00
e7977a9fbb Release 1.237.0 (#1733) 2023-02-19 19:26:16 +01:00
684c1e55b0 Bugfix/do not skip manual data source part 2 (#1732)
* Do not skip MANUAL data source

* Update changelog
2023-02-19 19:24:52 +01:00
1ffa831c5c Feature/improve style of symbol search results (#1728)
* Improve style

* Update changelog
2023-02-19 11:20:56 +01:00
40eed0016c Feature/migrate header module to angular material 15 (#1725)
* Migrate GfHeaderModule to Angular Material 15

* Update changelog
2023-02-19 10:36:52 +01:00
b58631083b Increase file size limit for imports (#1726)
* Increase file size limit for imports

* Update changelog
2023-02-19 10:09:13 +01:00
e0c0425d21 Add development guide (#1722) 2023-02-19 10:06:52 +01:00
bf2de5d572 Feature/add support to pricing page (#1723)
* Add support

* Update changelog
2023-02-19 10:02:46 +01:00
2b4a1dc480 Bugfix/fix issue with exact matches in activities filter (#1724)
* Fix issue with exact match

* Update changelog
2023-02-19 10:01:51 +01:00
ce022c024f Feature/upgrade nx to version 15.7.2 (#1721)
* Upgrade Angular and Nx

* Update changelog
2023-02-18 19:34:06 +01:00
0f4bf529d8 Handle impersonation mode with guard (#1714) 2023-02-18 14:59:38 +01:00
dad6bf7095 Release 1.236.0 (#1720) 2023-02-17 19:23:04 +01:00
86ca9eaae6 Bugfix/fix logout url in development (#1715)
* Add language

* Update changelog
2023-02-17 19:20:58 +01:00
9d9b805b0e Bugfix/do not skip manual data source (#1718)
* Do not skip MANUAL data source

* Update changelog
2023-02-17 19:20:14 +01:00
851401be1e Feature/remove ghostfolio as data source type (#1717)
* Remove GHOSTFOLIO

* Update changelog
2023-02-17 19:19:16 +01:00
85052bc9bc Bugfix/fix buying power calculation with emergency fund tag (#1713)
* Fix buying power calculation

* Update changelog
2023-02-17 17:29:48 +01:00
bff09f529d Feature/beautify etf names in asset profiles (#1709)
* Beautify ETF names

* Update changelog
2023-02-17 11:20:46 +01:00
f438458687 Release 1.235.0 (#1708) 2023-02-16 17:20:22 +01:00
7125b12631 Feature/improve styles on about page (#1707)
* Improve styles

* Update changelog
2023-02-16 17:17:30 +01:00
0cbf275a2e Feature/eliminate ghostfolio scraper api service (#1706)
* Eliminate GhostfolioScraperApiService

* Update changelog
2023-02-16 16:25:23 +01:00
151 changed files with 10102 additions and 2969 deletions

View File

View File

@ -10,7 +10,7 @@ jobs:
strategy:
matrix:
node_version:
- 18
- 16
steps:
- name: Checkout code
uses: actions/checkout@v3

View File

@ -41,7 +41,7 @@ jobs:
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.output.labels }}

1
.gitignore vendored
View File

@ -25,6 +25,7 @@
# misc
/.angular/cache
.env
.env.prod
/.sass-cache
/connect.lock

View File

@ -1,10 +1,7 @@
module.exports = {
stories: [],
addons: ['@storybook/addon-essentials']
// uncomment the property below if you want to apply some webpack config globally
// webpackFinal: async (config, { configType }) => {
// // Make whatever fine-grained changes you need that should apply to all storybook configs
// // Return the altered config
// return config;
// },

View File

@ -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/),
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
### 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 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
@ -1331,7 +1481,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Beautified the ETF names in the symbol profile
- Beautified the ETF names in the asset profile
### Fixed
@ -1756,7 +1906,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- 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`
### Fixed

25
DEVELOPMENT.md Normal file
View 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

View File

@ -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
WORKDIR /ghostfolio
@ -50,7 +50,7 @@ COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings
# Image to run, copy everything needed from builder
FROM node:18-slim
FROM node:16-slim
RUN apt update && apt install -y \
openssl \
&& rm -rf /var/lib/apt/lists/*

View File

@ -75,7 +75,7 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
## 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">
@ -86,9 +86,8 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables
| Name | Default Value | Description |
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `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` |
| `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) |
@ -106,7 +105,8 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
- Basic knowledge of Docker
- 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
@ -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
```
#### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps:
#### Setup
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. 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
@ -150,18 +147,18 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 16)
- [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
1. Run `yarn install`
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 `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. 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. 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

View File

@ -1,6 +1,7 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { Accounts } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type {
@ -83,7 +84,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAllAccounts(
@Headers('impersonation-id') impersonationId
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
): Promise<Accounts> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
@ -101,7 +102,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById(
@Headers('impersonation-id') impersonationId,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Param('id') id: string
): Promise<AccountWithValue> {
const impersonationUserId =

View File

@ -231,12 +231,27 @@ export class AdminService {
}
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy: {
let orderBy: any = {
createdAt: 'desc'
};
let where;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = {
Analytics: {
updatedAt: 'desc'
}
},
};
where = {
NOT: {
Analytics: null
}
};
}
const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy,
where,
select: {
_count: {
select: { Account: true, Order: true }
@ -252,19 +267,16 @@ export class AdminService {
id: true,
Subscription: true
},
take: 30,
where: {
NOT: {
Analytics: null
}
}
take: 30
});
return usersWithAnalytics.map(
({ _count, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics.activityCount / daysSinceRegistration;
const engagement = Analytics
? Analytics.activityCount / daysSinceRegistration
: undefined;
const subscription = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
@ -278,8 +290,8 @@ export class AdminService {
id,
subscription,
accountCount: _count.Account || 0,
country: Analytics.country,
lastActivity: Analytics.updatedAt,
country: Analytics?.country,
lastActivity: Analytics?.updatedAt,
transactionCount: _count.Order || 0
};
}

View File

@ -1,33 +1,46 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import * as countriesAndTimezones from 'countries-and-timezones';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
public constructor(
readonly configurationService: ConfigurationService,
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly userService: UserService
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
passReqToCallback: true,
secretOrKey: configurationService.get('JWT_SECRET_KEY')
});
}
public async validate({ id }: { id: string }) {
public async validate(request: Request, { id }: { id: string }) {
try {
const timezone = request.headers[HEADER_KEY_TIMEZONE.toLowerCase()];
const user = await this.userService.user({ id });
if (user) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } },
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
create: { country, User: { connect: { id: user.id } } },
update: {
country,
activityCount: { increment: 1 },
updatedAt: new Date()
},
where: { userId: user.id }
});
}
return user;
} else {

View File

@ -1,6 +1,13 @@
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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExchangeRateService } from './exchange-rate.service';
@ -18,9 +25,18 @@ export class ExchangeRateController {
): Promise<IDataProviderHistoricalResponse> {
const date = new Date(dateString);
return this.exchangeRateService.getExchangeRate({
const exchangeRate = await this.exchangeRateService.getExchangeRate({
date,
symbol
});
if (exchangeRate) {
return { marketPrice: exchangeRate };
}
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
}

View File

@ -1,5 +1,4 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
@ -14,16 +13,14 @@ export class ExchangeRateService {
}: {
date: Date;
symbol: string;
}): Promise<IDataProviderHistoricalResponse> {
}): Promise<number> {
const [currency1, currency2] = symbol.split('-');
const marketPrice = await this.exchangeRateDataService.toCurrencyAtDate(
return this.exchangeRateDataService.toCurrencyAtDate(
1,
currency1,
currency2,
date
);
return { marketPrice };
}
}

View File

@ -1,11 +1,11 @@
import * as fs from 'fs';
import * as path from 'path';
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express';
@ -18,18 +18,10 @@ export class FrontendMiddleware implements NestMiddleware {
public indexHtmlIt = '';
public indexHtmlNl = '';
public indexHtmlPt = '';
public isProduction: boolean;
public constructor(
private readonly configService: ConfigService,
private readonly configurationService: ConfigurationService
) {
const NODE_ENV =
this.configService.get<'development' | 'production'>('NODE_ENV') ??
'development';
this.isProduction = NODE_ENV === 'production';
try {
this.indexHtmlDe = fs.readFileSync(
this.getPathOfIndexHtmlFile('de'),
@ -90,12 +82,17 @@ export class FrontendMiddleware implements NestMiddleware {
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
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 (
request.path.startsWith('/api/') ||
this.isFileRequest(request.url) ||
!this.isProduction
!environment.production
) {
// Skip
next();

View File

@ -254,6 +254,7 @@ export class ImportService {
countries: null,
createdAt: undefined,
id: undefined,
isin: null,
name: null,
scraperConfiguration: null,
sectors: null,

View File

@ -6,8 +6,8 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
DEMO_USER_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
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_SUBSCRIPTION') === true
) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSource
);
@ -120,7 +118,7 @@ export class InfoService {
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(),
demoAuthToken: await this.getDemoAuthToken(),
statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions(),
tags: await this.tagService.get()
@ -248,12 +246,20 @@ export class InfoService {
)) as string;
}
private getDemoAuthToken() {
private async getDemoAuthToken() {
const demoUserId = (await this.propertyService.getByKey(
PROPERTY_DEMO_USER_ID
)) as string;
if (demoUserId) {
return this.jwtService.sign({
id: DEMO_USER_ID
id: demoUserId
});
}
return undefined;
}
private async getStatistics() {
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
return undefined;

View File

@ -3,6 +3,7 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.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 type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -66,7 +67,7 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers('impersonation-id') impersonationId,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string

View File

@ -110,9 +110,6 @@ export class OrderService {
dataSource,
symbol: id
};
} else {
data.SymbolProfile.connectOrCreate.create.symbol =
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
}
await this.dataGatheringService.addJobToQueue(

View File

@ -1,4 +1,5 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
import { GetValueObject } from './interfaces/get-value-object.interface';
@ -48,8 +49,11 @@ export const CurrentRateServiceMock = {
getValues: ({
dataGatheringItems,
dateQuery
}: GetValuesParams): Promise<GetValueObject[]> => {
const result: GetValueObject[] = [];
}: GetValuesParams): Promise<{
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}> => {
const values: GetValueObject[] = [];
if (dateQuery.lt) {
for (
let date = resetHours(dateQuery.gte);
@ -57,7 +61,7 @@ export const CurrentRateServiceMock = {
date = addDays(date, 1)
) {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
values.push({
date,
marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol,
@ -70,7 +74,7 @@ export const CurrentRateServiceMock = {
} else {
for (const date of dateQuery.in) {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
values.push({
date,
marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol,
@ -81,6 +85,6 @@ export const CurrentRateServiceMock = {
}
}
}
return Promise.resolve(result);
return Promise.resolve({ values, dataProviderInfos: [] });
}
};

View File

@ -1,6 +1,7 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service';
@ -103,7 +104,12 @@ describe('CurrentRateService', () => {
},
userCurrency: 'CHF'
})
).toMatchObject<GetValueObject[]>([
).toMatchObject<{
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}>({
dataProviderInfos: [],
values: [
{
date: undefined,
marketPriceInBaseCurrency: 1841.823902,
@ -114,6 +120,7 @@ describe('CurrentRateService', () => {
marketPriceInBaseCurrency: 1847.839966,
symbol: 'AMZN'
}
]);
]
});
});
});

View File

@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { resetHours } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash';
@ -22,7 +23,11 @@ export class CurrentRateService {
dataGatheringItems,
dateQuery,
userCurrency
}: GetValuesParams): Promise<GetValueObject[]> {
}: GetValuesParams): Promise<{
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}> {
const dataProviderInfos: DataProviderInfo[] = [];
const includeToday =
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
@ -38,6 +43,14 @@ export class CurrentRateService {
.then((dataResultProvider) => {
const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) {
if (
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
) {
dataProviderInfos.push(
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
);
}
result.push({
date: today,
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 {

View File

@ -1,4 +1,5 @@
import {
DataProviderInfo,
EnhancedSymbolProfile,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
@ -7,6 +8,7 @@ import { Tag } from '@prisma/client';
export interface PortfolioPositionDetail {
averagePrice: number;
dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number;
feeInBaseCurrency: number;
firstBuyDate: string;

View File

@ -1,7 +1,11 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import {
DataProviderInfo,
ResponseError,
TimelinePosition
} from '@ghostfolio/common/interfaces';
import { GroupBy } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client';
@ -45,6 +49,7 @@ export class PortfolioCalculator {
private currency: string;
private currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[];
private orders: PortfolioOrder[];
private transactionPoints: TransactionPoint[];
@ -202,7 +207,8 @@ export class PortfolioCalculator {
symbols[item.symbol] = true;
}
const marketSymbols = await this.currentRateService.getValues({
const { dataProviderInfos, values: marketSymbols } =
await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
@ -211,6 +217,8 @@ export class PortfolioCalculator {
userCurrency: this.currency
});
this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
@ -368,7 +376,8 @@ export class PortfolioCalculator {
dates.push(resetHours(end));
const marketSymbols = await this.currentRateService.getValues({
const { dataProviderInfos, values: marketSymbols } =
await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
@ -377,6 +386,8 @@ export class PortfolioCalculator {
userCurrency: this.currency
});
this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
@ -463,6 +474,10 @@ export class PortfolioCalculator {
};
}
public getDataProviderInfos() {
return this.dataProviderInfos;
}
public getInvestments(): { date: string; investment: Big }[] {
if (this.transactionPoints.length === 0) {
return [];
@ -748,7 +763,7 @@ export class PortfolioCalculator {
let marketSymbols: GetValueObject[] = [];
if (dataGatheringItems.length > 0) {
try {
marketSymbols = await this.currentRateService.getValues({
const { values } = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
@ -757,6 +772,7 @@ export class PortfolioCalculator {
},
userCurrency: this.currency
});
marketSymbols = values;
} catch (error) {
Logger.error(
`Failed to fetch info for date ${startDate} with exception`,

View File

@ -10,6 +10,7 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
PortfolioDetails,
PortfolioDividends,
@ -65,7 +66,7 @@ export class PortfolioController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@ -189,7 +190,7 @@ export class PortfolioController {
@Get('dividends')
@UseGuards(AuthGuard('jwt'))
public async getDividends(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('groupBy') groupBy?: GroupBy,
@ -239,7 +240,7 @@ export class PortfolioController {
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('groupBy') groupBy?: GroupBy,
@ -291,7 +292,7 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2')
public async getPerformanceV2(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@ -360,7 +361,7 @@ export class PortfolioController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@ -451,7 +452,7 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'))
public async getPosition(
@Headers('impersonation-id') impersonationId: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource,
@Param('symbol') symbol
): Promise<PortfolioPositionDetail> {
@ -474,7 +475,7 @@ export class PortfolioController {
@Get('report')
@UseGuards(AuthGuard('jwt'))
public async getReport(
@Headers('impersonation-id') impersonationId: string
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<PortfolioReport> {
const report = await this.portfolioService.getReport(impersonationId);

View File

@ -678,6 +678,7 @@ export class PortfolioService {
return {
tags,
averagePrice: undefined,
dataProviderInfo: undefined,
dividendInBaseCurrency: undefined,
feeInBaseCurrency: undefined,
firstBuyDate: undefined,
@ -849,6 +850,7 @@ export class PortfolioService {
tags,
transactionCount,
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee.toNumber(),
@ -911,6 +913,7 @@ export class PortfolioService {
SymbolProfile,
tags,
averagePrice: 0,
dataProviderInfo: undefined,
dividendInBaseCurrency: 0,
feeInBaseCurrency: 0,
firstBuyDate: undefined,
@ -1550,7 +1553,10 @@ export class PortfolioService {
userCurrency
}).toNumber();
const emergencyFund = new Big(
Math.max(
emergencyFundPositionsValueInBaseCurrency,
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
)
);
const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date;

View File

@ -117,7 +117,7 @@ export class SubscriptionController {
return await this.subscriptionService.createCheckoutSession({
couponId,
priceId,
userId: this.request.user.id
user: this.request.user
});
} catch (error) {
Logger.error(error, 'SubscriptionController');

View File

@ -4,6 +4,7 @@ import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config';
import { UserWithSettings } from '@ghostfolio/common/interfaces';
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable, Logger } from '@nestjs/common';
@ -23,7 +24,7 @@ export class SubscriptionService {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2020-08-27'
apiVersion: '2022-11-15'
}
);
}
@ -31,17 +32,17 @@ export class SubscriptionService {
public async createCheckoutSession({
couponId,
priceId,
userId
user
}: {
couponId?: string;
priceId: string;
userId: string;
user: UserWithSettings;
}) {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account`,
client_reference_id: userId,
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE
}/account`,
client_reference_id: user.id,
line_items: [
{
price: priceId,
@ -116,10 +117,6 @@ export class SubscriptionService {
userId: session.client_reference_id
});
await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id
});
return session.client_reference_id;
} catch (error) {
Logger.error(error, 'SubscriptionService');

View File

@ -1,15 +1,18 @@
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 { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
HttpException,
Inject,
Param,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -21,7 +24,10 @@ import { SymbolService } from './symbol.service';
@Controller('symbol')
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
@ -33,7 +39,10 @@ export class SymbolController {
@Query() { query = '' }
): Promise<{ items: LookupItem[] }> {
try {
return this.symbolService.lookup(query.toLowerCase());
return this.symbolService.lookup({
query: query.toLowerCase(),
user: this.request.user
});
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),

View File

@ -5,7 +5,10 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
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 { 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: [] };
if (!aQuery) {
if (!query) {
return results;
}
try {
const { items } = await this.dataProviderService.search(aQuery);
const { items } = await this.dataProviderService.search({
query,
user
});
results.items = items;
return results;
} catch (error) {

View File

@ -1,7 +0,0 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsOptional()
country?: string;
}

View File

@ -6,12 +6,17 @@ import type {
import {
IsBoolean,
IsIn,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
export class UpdateUserSettingDto {
@IsNumber()
@IsOptional()
annualInterestRate?: number;
@IsOptional()
@IsString()
baseCurrency?: string;
@ -48,6 +53,14 @@ export class UpdateUserSettingDto {
@IsOptional()
locale?: string;
@IsNumber()
@IsOptional()
projectedTotalAmount?: number;
@IsISO8601()
@IsOptional()
retirementDate?: string;
@IsNumber()
@IsOptional()
savingsRate?: number;

View File

@ -22,7 +22,6 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { size } from 'lodash';
import { CreateUserDto } from './create-user.dto';
import { UserItem } from './interfaces/user-item.interface';
import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UserService } from './user.service';
@ -66,7 +65,7 @@ export class UserController {
}
@Post()
public async signupUser(@Body() data: CreateUserDto): Promise<UserItem> {
public async signupUser(): Promise<UserItem> {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
@ -80,7 +79,6 @@ export class UserController {
const hasAdmin = await this.userService.hasAdmin();
const { accessToken, id, role } = await this.userService.createUser({
country: data.country,
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
});

View File

@ -18,8 +18,6 @@ import { Injectable } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client';
import { sortBy } from 'lodash';
import { CreateUserDto } from './create-user.dto';
const crypto = require('crypto');
@Injectable()
@ -234,9 +232,10 @@ export class UserService {
}
public async createUser({
country,
data
}: CreateUserDto & { data: Prisma.UserCreateInput }): Promise<User> {
}: {
data: Prisma.UserCreateInput;
}): Promise<User> {
if (!data?.provider) {
data.provider = 'ANONYMOUS';
}
@ -264,7 +263,6 @@ export class UserService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
await this.prismaService.analytics.create({
data: {
country,
User: { connect: { id: user.id } }
}
});

View File

@ -1,3 +1,4 @@
import Big from 'big.js';
import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
@ -59,7 +60,10 @@ export function redactAttributes({
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
redactedObject[property] = redactAttributes({
options,

View File

@ -1,5 +1,6 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
CallHandler,
ExecutionContext,
@ -22,7 +23,8 @@ export class RedactValuesInResponseInterceptor<T>
return next.handle().pipe(
map((data: any) => {
const request = context.switchToHttp().getRequest();
const hasImpersonationId = !!request.headers?.['impersonation-id'];
const hasImpersonationId =
!!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
if (
hasImpersonationId ||

View File

@ -24,7 +24,7 @@ export class TransformDataSourceInRequestInterceptor<T>
const http = context.switchToHttp();
const request = http.getRequest();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (request.body.dataSource) {
request.body.dataSource = decodeDataSource(request.body.dataSource);
}

View File

@ -26,9 +26,7 @@ export class TransformDataSourceInResponseInterceptor<T>
): Observable<any> {
return next.handle().pipe(
map((data: any) => {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
data = redactAttributes({
options: [
{

View File

@ -1,6 +1,7 @@
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import * as bodyParser from 'body-parser';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
@ -9,13 +10,8 @@ async function bootstrap() {
const configApp = await NestFactory.create(AppModule);
const configService = configApp.get<ConfigService>(ConfigService);
const NODE_ENV =
configService.get<'development' | 'production'>('NODE_ENV') ??
'development';
const app = await NestFactory.create(AppModule, {
logger:
NODE_ENV === 'production'
logger: environment.production
? ['error', 'log', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn']
});
@ -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 PORT = configService.get<number>('PORT') || 3333;
await app.listen(PORT, HOST, () => {

View File

@ -19,10 +19,9 @@ export class ConfigurationService {
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
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_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),

View File

@ -152,10 +152,11 @@ export class DataGatheringService {
countries,
currency,
dataSource,
isin,
name,
sectors,
url
} = assetProfiles[symbol];
} = assetProfile;
try {
await this.prismaService.symbolProfile.upsert({
@ -165,6 +166,7 @@ export class DataGatheringService {
countries,
currency,
dataSource,
isin,
name,
sectors,
symbol,
@ -175,6 +177,7 @@ export class DataGatheringService {
assetSubClass,
countries,
currency,
isin,
name,
sectors,
url
@ -207,10 +210,6 @@ export class DataGatheringService {
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
if (dataSource === 'MANUAL') {
continue;
}
await this.addJobToQueue(
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
{
@ -253,11 +252,6 @@ export class DataGatheringService {
},
scraperConfiguration: true,
symbol: true
},
where: {
dataSource: {
not: 'MANUAL'
}
}
})
).map((symbolProfile) => {
@ -278,7 +272,6 @@ export class DataGatheringService {
return symbolProfiles
.filter(({ dataSource }) => {
return (
dataSource !== DataSource.GHOSTFOLIO &&
dataSource !== DataSource.MANUAL &&
dataSource !== DataSource.RAPID_API
);
@ -300,11 +293,6 @@ export class DataGatheringService {
dataSource: true,
scraperConfiguration: true,
symbol: true
},
where: {
dataSource: {
not: 'MANUAL'
}
}
});

View File

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

View File

@ -7,7 +7,7 @@ import bent from 'bent';
const getJSON = bent('json');
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 countriesMapping = {
'Russian Federation': 'Russia'
@ -32,17 +32,29 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response;
}
const result = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
const profile = await getJSON(
`${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(() => {
return getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${
symbol.split('.')[0]
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
symbol.split('.')?.[0]
}.json`
);
});
if (result.weight < 0.95) {
if (holdings?.weight < 0.95) {
// Skip if data is inaccurate
return response;
}
@ -52,7 +64,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.countries as unknown as Country[]).length === 0
) {
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;
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 = [];
for (const [name, value] of Object.entries<any>(result.sectors)) {
for (const [name, value] of Object.entries<any>(
holdings?.sectors ?? {}
)) {
response.sectors.push({
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
weight: value.weight

View File

@ -1,8 +1,8 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
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 { 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 { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.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: [
AlphaVantageService,
CoinGeckoService,
DataProviderService,
EodHistoricalDataService,
GhostfolioScraperApiService,
GoogleSheetsService,
ManualService,
RapidApiService,
@ -32,8 +32,8 @@ import { DataProviderService } from './data-provider.service';
{
inject: [
AlphaVantageService,
CoinGeckoService,
EodHistoricalDataService,
GhostfolioScraperApiService,
GoogleSheetsService,
ManualService,
RapidApiService,
@ -42,16 +42,16 @@ import { DataProviderService } from './data-provider.service';
provide: 'DataProviderInterfaces',
useFactory: (
alphaVantageService,
coinGeckoService,
eodHistoricalDataService,
ghostfolioScraperApiService,
googleSheetsService,
manualService,
rapidApiService,
yahooFinanceService
) => [
alphaVantageService,
coinGeckoService,
eodHistoricalDataService,
ghostfolioScraperApiService,
googleSheetsService,
manualService,
rapidApiService,
@ -59,10 +59,6 @@ import { DataProviderService } from './data-provider.service';
]
}
],
exports: [
DataProviderService,
GhostfolioScraperApiService,
YahooFinanceService
]
exports: [DataProviderService, YahooFinanceService]
})
export class DataProviderModule {}

View File

@ -8,6 +8,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { UserWithSettings } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
@ -260,25 +261,50 @@ export class DataProviderService {
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[] }>[] = [];
let lookupItems: LookupItem[] = [];
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
promises.push(
this.getDataProvider(DataSource[dataSource]).search(aQuery)
);
if (query?.length < 2) {
return { items: lookupItems };
}
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);
searchResults.forEach((searchResult) => {
lookupItems = lookupItems.concat(searchResult.items);
searchResults.forEach(({ items }) => {
if (items?.length > 0) {
lookupItems = lookupItems.concat(items);
}
});
const filteredItems = lookupItems.filter((lookupItem) => {
const filteredItems = lookupItems
.filter((lookupItem) => {
// Only allow symbols with supported currency
return lookupItem.currency ? true : false;
})
.sort(({ name: name1 }, { name: name2 }) => {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
});
return {
@ -295,4 +321,9 @@ export class DataProviderService {
throw new Error('No data provider has been found.');
}
private isPremiumDataSource(aDataSource: DataSource) {
const premiumDataSources: DataSource[] = [DataSource.EOD_HISTORICAL_DATA];
return premiumDataSources.includes(aDataSource);
}
}

View File

@ -5,13 +5,17 @@ import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
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 { format } from 'date-fns';
import { format, isToday } from 'date-fns';
@Injectable()
export class EodHistoricalDataService implements DataProviderInterface {
@ -19,8 +23,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService
private readonly configurationService: ConfigurationService
) {
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
}
@ -32,8 +35,15 @@ export class EodHistoricalDataService implements DataProviderInterface {
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
const [searchResult] = await this.getSearchResult(aSymbol);
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
);
const [response, symbolProfiles] = await Promise.all([
const [realTimeResponse, searchResponse] = await Promise.all([
get(),
this.symbolProfileService.getSymbolProfiles(
aSymbols.map((symbol) => {
return {
symbol,
dataSource: DataSource.EOD_HISTORICAL_DATA
};
})
)
this.search(aSymbols[0])
]);
const quotes = aSymbols.length === 1 ? [response] : response;
const quotes =
aSymbols.length === 1 ? [realTimeResponse] : realTimeResponse;
return quotes.reduce((result, item, index, array) => {
result[item.code] = {
currency: symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === item.code;
})?.currency,
return quotes.reduce(
(
result: { [symbol: string]: IDataProviderResponse },
{ close, code, timestamp }
) => {
result[code] = {
currency: searchResponse?.items[0]?.currency,
dataSource: DataSource.EOD_HISTORICAL_DATA,
marketPrice: item.close,
marketState: 'delayed'
marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
};
return result;
}, {});
},
{}
);
} catch (error) {
Logger.error(error, 'EodHistoricalDataService');
}
@ -156,6 +164,101 @@ export class EodHistoricalDataService implements DataProviderInterface {
}
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 };
}
}

View File

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

View File

@ -16,6 +16,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import * as cheerio from 'cheerio';
import { isUUID } from 'class-validator';
import { addDays, format, isBefore } from 'date-fns';
@Injectable()
@ -162,7 +163,7 @@ export class ManualService implements DataProviderInterface {
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
let items = await this.prismaService.symbolProfile.findMany({
select: {
currency: 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 };
}
}

View File

@ -441,8 +441,10 @@ export class YahooFinanceService implements DataProviderInterface {
let name = longName;
if (name) {
name = name.replace('Amundi Index Solutions - ', '');
name = name.replace('iShares ETF (CH) - ', '');
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 VII PLC - ', '');
name = name.replace('Multi Units Luxembourg - ', '');

View File

@ -168,7 +168,7 @@ export class ExchangeRateDataService {
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
}
let factor = 1;
let factor: number;
if (aFromCurrency !== aToCurrency) {
const dataSource = this.dataProviderService.getPrimaryDataSource();
@ -183,9 +183,29 @@ export class ExchangeRateDataService {
if (marketData?.marketPrice) {
factor = marketData?.marketPrice;
} else {
// TODO: Get from data provider service or calculate indirectly via base currency
// and market data
return this.toCurrency(aValue, aFromCurrency, aToCurrency);
// Calculate indirectly via base currency
try {
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;
}
// Fallback with error, if currencies are not available
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'
);
return aValue;
return undefined;
}
private async prepareCurrencies(): Promise<string[]> {

View File

@ -8,7 +8,6 @@ export interface Environment extends CleanedEnvAccessors {
DATA_SOURCE_PRIMARY: string;
DATA_SOURCES: string[];
ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;

View File

@ -1,4 +1,4 @@
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { DataProviderInfo, UniqueAsset } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types';
import {
Account,
@ -28,6 +28,7 @@ export interface IDataProviderHistoricalResponse {
export interface IDataProviderResponse {
currency: string;
dataProviderInfo?: DataProviderInfo;
dataSource: DataSource;
marketPrice: number;
marketState: MarketState;

View File

@ -123,6 +123,13 @@ const routes: Routes = [
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
).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',
loadChildren: () =>

View File

@ -104,7 +104,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.tokenStorageService.signOut();
this.userService.remove();
document.location.href = '/';
document.location.href = `/${document.documentElement.lang}`;
}
public ngOnDestroy() {

View File

@ -1,6 +1,7 @@
<div>
<gf-line-chart
class="mb-4"
[colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems"
[isAnimated]="true"
[locale]="locale"
@ -28,7 +29,7 @@
}"
[title]="
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
| date: defaultDateFormat) ?? ''
| date : defaultDateFormat) ?? ''
"
(click)="
onOpenMarketDataDetail({

View File

@ -1,11 +1,11 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatDatepickerModule } from '@angular/material/datepicker';
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 { MatInputModule } from '@angular/material/input';
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
import { MarketDataDetailDialog } from './market-data-detail-dialog.component';

View File

@ -152,7 +152,7 @@
mat-menu-item
(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
mat-menu-item

View File

@ -27,7 +27,7 @@
[disabled]="assetProfileForm.dirty"
(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
mat-menu-item

View File

@ -28,7 +28,13 @@
>
<ng-container i18n>Engagement per Day</ng-container>
</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>
</tr>
</thead>
@ -86,7 +92,10 @@
[value]="userItem.engagement"
></gf-value>
</td>
<td class="mat-cell px-1 py-2">
<td
*ngIf="hasPermissionForSubscription"
class="mat-cell px-1 py-2"
>
{{ formatDistanceToNow(userItem.lastActivity) }}
</td>
<td class="mat-cell px-1 py-2">

View File

@ -166,7 +166,6 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
},
display: true,
grid: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
display: false
},
type: 'time',
@ -177,13 +176,21 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
},
y: {
border: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
display: false
width: 0
},
display: true,
grid: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
display: false
color: ({ scale, tick }) => {
if (
tick.value === 0 ||
tick.value === scale.max ||
tick.value === scale.min
) {
return `rgba(${getTextColor(this.colorScheme)}, 0.1)`;
}
return 'transparent';
}
},
position: 'right',
ticks: {

View File

@ -110,11 +110,7 @@
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container *ngIf="user?.access?.length > 0">
<button
class="align-items-center d-flex"
mat-menu-item
(click)="impersonateAccount(null)"
>
<button mat-menu-item (click)="impersonateAccount(null)">
<ion-icon
*ngIf="user?.access?.length > 0"
class="mr-2"
@ -128,7 +124,6 @@
</button>
<button
*ngFor="let accessItem of user?.access"
class="align-items-center d-flex"
mat-menu-item
(click)="impersonateAccount(accessItem.id)"
>
@ -147,7 +142,7 @@
<hr class="m-0" />
</ng-container>
<a
class="d-block d-sm-none"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
@ -157,7 +152,7 @@
>Overview</a
>
<a
class="d-block d-sm-none"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
@ -167,7 +162,7 @@
>Portfolio</a
>
<a
class="d-block d-sm-none"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
@ -175,7 +170,6 @@
>Accounts</a
>
<a
class="align-items-center d-flex"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
@ -184,7 +178,7 @@
>
<a
*ngIf="hasPermissionToAccessAdminControl"
class="d-block d-sm-none"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
@ -193,7 +187,7 @@
>
<hr class="m-0" />
<a
class="d-block d-sm-none"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
@ -206,7 +200,7 @@
*ngIf="
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
"
class="d-block d-sm-none"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
@ -214,14 +208,14 @@
>Pricing</a
>
<a
class="d-block d-sm-none"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
[routerLink]="['/about']"
>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>
</mat-menu>
</ng-container>
@ -283,9 +277,9 @@
>Markets</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"
mat-flat-button
mat-icon-button
><ion-icon name="logo-github"></ion-icon
></a>
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">

View File

@ -11,7 +11,9 @@
flex: 1 1 auto;
}
.mat-flat-button {
.mdc-button {
height: unset;
&:not(.mat-primary) {
background-color: transparent;
text-decoration-color: rgba(var(--palette-primary-500), 1) !important;

View File

@ -56,8 +56,8 @@ export class HeaderComponent implements OnChanges {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((id) => {
this.impersonationId = id;
.subscribe((impersonationId) => {
this.impersonationId = impersonationId;
});
}

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';

View File

@ -78,8 +78,8 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.update();

View File

@ -10,6 +10,7 @@
symbol="Fear & Greed Index"
yMax="100"
yMin="0"
[colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems"
[isAnimated]="true"
[locale]="user?.settings?.locale"

View File

@ -66,8 +66,8 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.changeDetectorRef.markForCheck();
});

View File

@ -69,8 +69,8 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
}

View File

@ -283,7 +283,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
},
display: true,
grid: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
display: false
},
min: this.daysInMarket
@ -298,13 +297,21 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
},
y: {
border: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
display: false
},
display: !this.isInPercent,
grid: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
display: false
color: ({ scale, tick }) => {
if (
tick.value === 0 ||
tick.value === scale.max ||
tick.value === scale.min
) {
return `rgba(${getTextColor(this.colorScheme)}, 0.1)`;
}
return 'transparent';
}
},
position: 'right',
ticks: {

View File

@ -50,7 +50,7 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
public onEditEmergencyFund() {
const emergencyFundInput = prompt(
$localize`Please enter the amount of your emergency fund:`,
this.summary.emergencyFund.toString()
this.summary.emergencyFund?.toString() ?? '0'
);
const emergencyFund = parseFloat(emergencyFundInput?.trim());

View File

@ -13,6 +13,7 @@ import {
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import {
DataProviderInfo,
EnhancedSymbolProfile,
LineChartItem
} from '@ghostfolio/common/interfaces';
@ -40,6 +41,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public countries: {
[code: string]: { name: string; value: number };
};
public dataProviderInfo: DataProviderInfo;
public dividendInBaseCurrency: number;
public feeInBaseCurrency: number;
public firstBuyDate: string;
@ -83,6 +85,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
.subscribe(
({
averagePrice,
dataProviderInfo,
dividendInBaseCurrency,
feeInBaseCurrency,
firstBuyDate,
@ -105,6 +108,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.averagePrice = averagePrice;
this.benchmarkDataItems = [];
this.countries = {};
this.dataProviderInfo = dataProviderInfo;
this.dividendInBaseCurrency = dividendInBaseCurrency;
this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate;

View File

@ -227,6 +227,12 @@
</div>
</ng-template>
</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 class="row" [ngClass]="{ 'd-none': !orders?.length }">
@ -249,7 +255,7 @@
</div>
<div *ngIf="tags?.length > 0" class="row">
<div class="col mb-3">
<div class="col">
<div class="h5" i18n>Tags</div>
<mat-chip-list>
<mat-chip *ngFor="let tag of tags">{{ tag.name }}</mat-chip>
@ -261,7 +267,7 @@
*ngIf="data.hasPermissionToReportDataGlitch === true && orders?.length > 0"
class="row"
>
<div class="col mb-3">
<div class="col">
<hr />
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
><ion-icon class="mr-1" name="flag-outline"></ion-icon

View File

@ -6,6 +6,7 @@ import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/lega
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.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 { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -18,6 +19,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
imports: [
CommonModule,
GfActivitiesTableModule,
GfDataProviderCreditsModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfLineChartModule,

View File

@ -17,17 +17,21 @@
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>Portfolio Summary</span>
</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">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>Performance Benchmarks</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>Allocations</span>
<span i18n>FIRE Calculator</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>FIRE Calculator</span>
<span i18n>Professional Data Provider</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>

View File

@ -5,14 +5,16 @@ import {
HttpRequest
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import {
HEADER_KEY_IMPERSONATION,
HEADER_KEY_TIMEZONE,
HEADER_KEY_TOKEN
} from '@ghostfolio/common/config';
import { Observable } from 'rxjs';
import { ImpersonationStorageService } from '../services/impersonation-storage.service';
import { TokenStorageService } from '../services/token-storage.service';
const IMPERSONATION_KEY = 'Impersonation-Id';
const TOKEN_HEADER_KEY = 'Authorization';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
public constructor(
@ -24,21 +26,27 @@ export class AuthInterceptor implements HttpInterceptor {
req: HttpRequest<any>,
next: HttpHandler
): 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 impersonationId = this.impersonationStorageService.getId();
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) {
headers = headers.set(IMPERSONATION_KEY, impersonationId);
headers = headers.set(HEADER_KEY_IMPERSONATION, impersonationId);
}
}
authReq = req.clone({ headers });
}
request = request.clone({ headers });
return next.handle(authReq);
return next.handle(request);
}
}

View File

@ -48,7 +48,7 @@
>hi@ghostfol.io</a
></ng-container
>
or open an issue at
or start a discussion at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
@ -62,7 +62,7 @@
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter" size="large"></ion-icon>
<ion-icon name="logo-twitter"></ion-icon>
</a>
<a
class="mx-2"
@ -70,7 +70,7 @@
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail" size="large"></ion-icon>
<ion-icon name="mail"></ion-icon>
</a>
<a
class="mx-2"
@ -78,7 +78,7 @@
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
<ion-icon name="logo-slack"></ion-icon>
</a>
<a
class="mx-2"
@ -86,7 +86,7 @@
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
<ion-icon name="logo-github"></ion-icon>
</a>
</p>
<div
@ -191,7 +191,7 @@
<div class="row">
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/faq']"
@ -203,7 +203,7 @@
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
>
<a
class="py-2 w-100"
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/about', 'changelog']"
@ -212,7 +212,7 @@
</div>
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/about', 'privacy-policy']"
@ -221,7 +221,7 @@
</div>
<div *ngIf="hasPermissionForBlog" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/blog']"

View File

@ -77,8 +77,8 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.userService.stateChanged

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { 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 {}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@ -2,6 +2,32 @@
<div class="mb-5 row">
<div class="col">
<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-content>
<div class="container p-0">

View File

@ -17,8 +17,8 @@ export class LandingPageComponent implements OnDestroy, OnInit {
[code: string]: { value: number };
} = {};
public currentYear = format(new Date(), 'yyyy');
public demoAuthToken: string;
public deviceType: string;
public hasPermissionForDemo: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToCreateUser: boolean;
@ -54,6 +54,7 @@ export class LandingPageComponent implements OnDestroy, OnInit {
) {
const {
countriesOfSubscribers = [],
demoAuthToken,
globalPermissions,
statistics
} = this.dataService.fetchInfo();
@ -64,6 +65,7 @@ export class LandingPageComponent implements OnDestroy, OnInit {
};
}
this.hasPermissionForDemo = !!demoAuthToken;
this.hasPermissionForStatistics = hasPermission(
globalPermissions,
permissions.enableStatistics

View File

@ -40,12 +40,18 @@
>
Get Started
</a>
<div class="d-inline-block mx-3 text-muted">or</div></ng-container
</ng-container>
<ng-container *ngIf="hasPermissionForDemo">
<div
*ngIf="hasPermissionToCreateUser"
class="d-inline-block mx-3 text-muted"
>
or
</div>
<a class="d-inline-block" mat-stroked-button [routerLink]="['/demo']">
Live Demo
</a>
</ng-container>
</div>
</div>
</div>
@ -165,6 +171,14 @@
title="Product Hunt The best new products in tech."
></a>
</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">
<a
class="d-block logo logo-sackgeld mask"
@ -181,6 +195,14 @@
title="SourceForge: The Complete Open-Source and Business Software Platform"
></a>
</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">
<a
class="d-block logo logo-unraid mask"
@ -363,16 +385,20 @@
<div class="col">
<h2 class="h4 mb-1 text-center">Are <strong>you</strong> ready?</h2>
<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>
<div class="py-2 text-center">
<a color="primary" mat-flat-button [routerLink]="['/register']">
Get Started
</a>
<ng-container *ngIf="hasPermissionForDemo">
<div class="d-inline-block mx-3 text-muted">or</div>
<a class="d-inline-block" mat-stroked-button [routerLink]="['/demo']">
Live Demo
</a>
</ng-container>
</div>
</div>
</div>

View File

@ -69,6 +69,11 @@
filter: grayscale(1);
}
&.logo-reddit {
mask-image: url('/assets/images/logo-reddit.svg');
max-height: 1rem;
}
&.logo-sackgeld {
mask-image: url('/assets/images/logo-sackgeld.png');
}
@ -77,6 +82,11 @@
mask-image: url('/assets/images/logo-sourceforge.svg');
}
&.logo-umbrel {
mask-image: url('/assets/images/logo-umbrel.svg');
max-height: 1.5rem;
}
&.logo-unraid {
mask-image: url('/assets/images/logo-unraid.svg');
}
@ -114,8 +124,10 @@
&.logo-agplv3,
&.logo-alternative-to,
&.logo-privacy-tools,
&.logo-reddit,
&.logo-sackgeld,
&.logo-sourceforge,
&.logo-umbrel,
&.logo-unraid {
background-color: rgba(var(--light-primary-text));
}

View File

@ -88,8 +88,8 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.userService.stateChanged

View File

@ -9,12 +9,13 @@ import {
ViewChild
} from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import {
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
MatLegacyDialogRef as MatDialogRef
} from '@angular/material/legacy-dialog';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
@ -56,6 +57,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
});
public currencies: string[] = [];
public currentMarketPrice = null;
public defaultDateFormat: string;
public filteredLookupItems: LookupItem[];
public filteredLookupItemsObservable: Observable<LookupItem[]>;
public filteredTagsObservable: Observable<Tag[]>;
@ -85,6 +87,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
const { currencies, platforms, tags } = this.dataService.fetchInfo();
this.currencies = currencies;
this.defaultDateFormat = getDateFormatString(this.locale);
this.platforms = platforms;
this.tags = tags.map(({ id, name }) => {
return {
@ -106,6 +109,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.data.activity?.SymbolProfile?.currency,
Validators.required
],
currencyOfUnitPrice: [
this.data.activity?.SymbolProfile?.currency,
Validators.required
],
dataSource: [
this.data.activity?.SymbolProfile?.dataSource,
Validators.required
@ -131,16 +138,26 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
})
],
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
.pipe(takeUntil(this.unsubscribeSubject))
.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 currencyOfFee = this.activityForm.controls['currencyOfFee'].value;
const currencyOfUnitPrice =
this.activityForm.controls['currencyOfUnitPrice'].value;
const date = this.activityForm.controls['date'].value;
if (currency && currencyOfFee && currency !== currencyOfFee && date) {
@ -154,18 +171,57 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject))
);
exchangeRate = marketPrice;
} catch {}
exchangeRateOfFee = marketPrice;
} catch {
this.activityForm.controls['feeInCustomCurrency'].setErrors({
invalid: true
});
}
}
const feeInCustomCurrency =
this.activityForm.controls['feeInCustomCurrency'].value *
exchangeRate;
exchangeRateOfFee;
this.activityForm.controls['fee'].setValue(feeInCustomCurrency, {
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 (
this.activityForm.controls['type'].value === 'BUY' ||
this.activityForm.controls['type'].value === 'ITEM'
@ -187,11 +243,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.filteredLookupItemsObservable = this.activityForm.controls[
'searchSymbol'
].valueChanges.pipe(
startWith(''),
debounceTime(400),
distinctUntilChanged(),
switchMap((query: string) => {
if (isString(query)) {
if (isString(query) && query.length > 1) {
const filteredLookupItemsObservable =
this.dataService.fetchSymbols(query);
@ -231,6 +286,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['currencyOfFee'].setValue(
this.data.user.settings.baseCurrency
);
this.activityForm.controls['currencyOfUnitPrice'].setValue(
this.data.user.settings.baseCurrency
);
this.activityForm.controls['dataSource'].removeValidators(
Validators.required
);
@ -288,7 +346,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public applyCurrentMarketPrice() {
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 }) => {
this.activityForm.controls['currency'].setValue(currency);
this.activityForm.controls['currencyOfFee'].setValue(currency);
this.activityForm.controls['currencyOfUnitPrice'].setValue(currency);
this.activityForm.controls['dataSource'].setValue(dataSource);
this.currentMarketPrice = marketPrice;

View File

@ -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>Add activity</h1>
<div class="flex-grow-1 pt-3" mat-dialog-content>
<div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select formControlName="type">
@ -18,7 +18,7 @@
</mat-select>
</mat-form-field>
</div>
<div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Account</mat-label>
<mat-select formControlName="accountId">
@ -33,6 +33,7 @@
</mat-form-field>
</div>
<div
class="mb-3"
[ngClass]="{ 'd-none': !activityForm.controls['searchSymbol'].hasValidator(Validators.required) }"
>
<mat-form-field appearance="outline" class="w-100">
@ -54,11 +55,15 @@
<ng-container>
<mat-option
*ngFor="let lookupItem of filteredLookupItemsObservable | async"
class="autocomplete"
class="line-height-1"
[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>
</ng-container>
</mat-autocomplete>
@ -66,6 +71,7 @@
</mat-form-field>
</div>
<div
class="mb-3"
[ngClass]="{ 'd-none': !activityForm.controls['name'].hasValidator(Validators.required) }"
>
<mat-form-field appearance="outline" class="w-100">
@ -89,7 +95,7 @@
<input formControlName="dataSource" matInput />
</mat-form-field>
</div>
<div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Date</mat-label>
<input formControlName="date" matInput [matDatepicker]="date" />
@ -103,13 +109,60 @@
<mat-datepicker #date disabled="false"></mat-datepicker>
</mat-form-field>
</div>
<div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Quantity</mat-label>
<input formControlName="quantity" matInput type="number" />
</mat-form-field>
</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-label
><ng-container [ngSwitch]="activityForm.controls['type']?.value">
@ -125,18 +178,8 @@
>{{ activityForm.controls['currency'].value }}</span
>
</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 class="mb-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label>
<input formControlName="feeInCustomCurrency" matInput type="number" />
@ -151,6 +194,14 @@
</mat-option>
</mat-select>
</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>
</div>
<div class="d-none">
@ -162,7 +213,7 @@
>
</mat-form-field>
</div>
<div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Note</mat-label>
<textarea
@ -175,6 +226,7 @@
</mat-form-field>
</div>
<div
class="mb-3"
[ngClass]="{ 'd-none': activityForm.controls['type']?.value !== 'ITEM' }"
>
<mat-form-field appearance="outline" class="w-100">
@ -190,6 +242,7 @@
</mat-form-field>
</div>
<div
class="mb-3"
[ngClass]="{ 'd-none': activityForm.controls['type']?.value !== 'ITEM' }"
>
<mat-form-field appearance="outline" class="w-100">
@ -204,7 +257,7 @@
</mat-select>
</mat-form-field>
</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-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList>

View File

@ -1,13 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
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 { MatInputModule } from '@angular/material/input';
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';

View File

@ -10,16 +10,6 @@
.mat-dialog-content {
max-height: unset;
.autocomplete {
font-size: 90%;
height: 2.5rem;
.symbol {
display: inline-block;
min-width: 4rem;
}
}
.mat-datepicker-input {
&.mat-mdc-input-element:disabled {
color: var(--dark-primary-text);

View File

@ -118,8 +118,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.filters$

View File

@ -109,8 +109,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.filters$

View File

@ -1,5 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
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 { PortfolioReportRule, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -20,6 +21,7 @@ export class FirePageComponent implements OnDestroy, OnInit {
public deviceType: string;
public feeRules: PortfolioReportRule[];
public fireWealth: Big;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public isLoading = false;
@ -33,6 +35,7 @@ export class FirePageComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService
) {}
@ -70,6 +73,13 @@ export class FirePageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
});
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.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) {
this.dataService
.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() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

View File

@ -11,13 +11,19 @@
></gf-premium-indicator>
</h4>
<gf-fire-calculator
[annualInterestRate]="user?.settings?.annualInterestRate"
[colorScheme]="user?.settings?.colorScheme"
[currency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[fireWealth]="fireWealth?.toNumber()"
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings"
[hasPermissionToUpdateUserSettings]="!hasImpersonationId && hasPermissionToUpdateUserSettings"
[locale]="user?.settings?.locale"
[projectedTotalAmount]="user?.settings?.projectedTotalAmount"
[retirementDate]="user?.settings?.retirementDate"
[savingsRate]="user?.settings?.savingsRate"
(annualInterestRateChanged)="onAnnualInterestRateChange($event)"
(projectedTotalAmountChanged)="onProjectedTotalAmountChange($event)"
(retirementDateChanged)="onRetirementDateChange($event)"
(savingsRateChanged)="onSavingsRateChange($event)"
></gf-fire-calculator>
</div>
@ -69,12 +75,14 @@
></gf-value>
per month</span
>, based on your total assets of
<gf-value
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[currency]="user?.settings?.baseCurrency"
[locale]="user?.settings?.locale"
[value]="fireWealth?.toNumber()"
></gf-value>
></gf-value
></span>
and a withdrawal rate of 4%.
</div>
</div>

View File

@ -72,8 +72,8 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.filters$

View File

@ -15,6 +15,9 @@ import { takeUntil } from 'rxjs/operators';
export class PricingPageComponent implements OnDestroy, OnInit {
public baseCurrency: string;
public coupon: number;
public importAndExportTooltipBasic = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC'
);
public importAndExportTooltipOSS = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS'
);

View File

@ -106,6 +106,13 @@
></ion-icon>
<a i18n [routerLink]="['/features']">and more Features...</a>
</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>
</div>
<p i18n>Self-hosted, update manually.</p>
@ -158,35 +165,19 @@
></ion-icon>
<span i18n>Portfolio Performance</span>
</li>
<li>
<li class="align-items-center d-flex mb-1">
<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"
></ion-icon>
</li>
<li>
<ion-icon
class="invisible"
class="mr-1"
name="checkmark-circle-outline"
></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>
</ul>
</div>
@ -289,6 +280,13 @@
<ion-icon name="information-circle-outline"></ion-icon>
</span>
</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">
<ion-icon
class="mr-1"
@ -296,6 +294,13 @@
></ion-icon>
<a i18n [routerLink]="['/features']">and more Features...</a>
</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>
</div>
<p i18n>Fully managed Ghostfolio cloud offering.</p>

View File

@ -63,7 +63,7 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
public async createAccount() {
this.dataService
.postUser({ country: this.userService.getCountry() })
.postUser()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accessToken, authToken, role }) => {
this.openShowAccessTokenDialog(accessToken, authToken, role);

View File

@ -21,7 +21,6 @@
class="d-inline-block"
color="primary"
mat-flat-button
[disabled]="!demoAuthToken"
(click)="createAccount()"
>
<ng-container i18n>Create Account</ng-container>

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