Compare commits

...

67 Commits

Author SHA1 Message Date
3f8a2b47f9 Release 2.5.0 (#2380) 2023-09-23 20:11:46 +02:00
e2e4c9be3c Feature/skip data gathering for manual data source (#2379)
* Skip data gathering

* Update changelog
2023-09-23 20:10:08 +02:00
0f7c6ff0fe Bugfix/fix asset class of cash position for empty account (#2378)
* Fix assetClass and assetSubClass

* Update changelog
2023-09-23 19:52:28 +02:00
703a96f4db Add guard (#2377) 2023-09-23 19:45:15 +02:00
42c0560422 Feature/translate activity type (#2376)
* Introduce ActivityTypeComponent with localized label

* Update changelog
2023-09-23 16:44:03 +02:00
eb63802d01 Feature/extend supported date formats in activities import (#2362)
* Extend supported date formats in activities import

* Update changelog
2023-09-23 16:14:54 +02:00
6d9191a46f Feature/setup turkish (#2300)
* Setup Turkish

* Add Turkish translations

* Update changelog

---------

Co-authored-by: sadmimye <134071831+sadmimye@users.noreply.github.com>
2023-09-22 20:26:45 +02:00
6744245d8b Feature/extend personal finance tools pages 20230922 (#2369)
* Extend pages

* Refactoring
2023-09-22 20:04:40 +02:00
8f64a77a9d Clean up (#2329) 2023-09-21 19:56:31 +02:00
0d5fc7655b Improve wording (#2358) 2023-09-21 19:55:36 +02:00
c511ec7e33 Release 2.4.0 (#2356) 2023-09-19 20:38:50 +02:00
b12349a148 Feature/add support for interest on account level (#2354)
* Add support for interest

* Update changelog
2023-09-19 20:37:04 +02:00
f7e3a4c727 Update OSS Friends (#2352) 2023-09-19 20:27:14 +02:00
5f276469b7 Feature/upgrade prisma to version 5.3.1 (#2355)
* Upgrade prisma to version 5.3.1

* Update changelog
2023-09-19 19:37:33 +02:00
69e1d92ed3 Feature/unlock experimental features setting for all users (#2351)
* Unlock experimental features setting for all users

* Update changelog
2023-09-19 18:41:12 +02:00
ef2849aa6c Remove this (#2341) 2023-09-19 10:28:07 +02:00
c668d7b456 Feature/improve preselected currency in create or update activity dialog (#2349)
* Preselect currency based on account's currency

* Update changelog
2023-09-18 19:45:02 +02:00
e23bf62859 Fix Memory Leak on Data Gathering when server TZ is behind UTC (#2332)
* Fix for timezones behind UTC (the previous code converted the date to one day before (in local time) then added a day, which resulted in the same day after converting back to UTC and thus generating an infinite loop)

* Update changelog

---------

Co-authored-by: Rafael Claudio <rafacla@github.com>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-09-17 22:19:06 +02:00
54c5746d21 Release 2.3.0 (#2348) 2023-09-17 18:23:28 +02:00
7130ac7565 Feature/add support for fees on account level (#1954)
* Add migration

* Add business logic for fees

* Fix export for liabilities

* Update changelog
2023-09-17 18:20:54 +02:00
1851ae137f Release 2.2.0 (#2346) 2023-09-17 07:17:20 +02:00
6f6ff94979 Improve sidebar (#2343)
* Improve sidebar

* Improve style of system message

* Update changelog
2023-09-17 07:08:26 +02:00
7f25066f0f Remove ALPHA_VANTAGE_API_KEY (#2345) 2023-09-17 06:54:38 +02:00
fc795aaa8c Update postgres to version 15 in docker-compose files (#1596)
* Update postgres to version 15 in docker-compose files

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-09-17 06:52:41 +02:00
d0112968e8 Feature/introduce sidebar navigation on desktop (#2340)
* Introduce sidebar navigation on desktop

* Update changelog
2023-09-16 14:40:05 +02:00
522025ffa0 Fix entry (#2339) 2023-09-16 14:37:08 +02:00
27bf662281 Release 2.1.0 (#2338) 2023-09-15 19:48:09 +02:00
93c27277c6 Extend sitemap with Italian pages (#2337) 2023-09-15 19:46:32 +02:00
5e6adfcef5 Feature/improve language localization for german 20230915 (#2336)
* Improve language localization

* Update changelog
2023-09-15 19:38:15 +02:00
ab691bb27a Feature/remove account type from user interface (#2335)
* Remove account type from user interface and set it optional

* Update changelog
2023-09-15 19:11:20 +02:00
8fc5676443 Feature/improve timeout of data source requests (#2330)
* Improve timeout

* Update changelog
2023-09-15 16:25:01 +02:00
1fe1e2fe0c Feature/improve read only mode (#2322)
* Improve read-only mode

* Update changelog
2023-09-15 16:22:39 +02:00
921d38a706 Feature/harmonize style of granted access user interface (#2326)
* Harmonize style

* Update changelog
2023-09-15 16:21:14 +02:00
6161d5e77c Feature/improve logger output of info service (#2331)
* Improve context of logger output

* Update changelog
2023-09-15 08:26:08 +02:00
369386f976 Add drop file functionality on import (#2323)
* Add drop file functionality on import

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-09-14 21:12:48 +02:00
41437636b1 Update messages.it.xlf (#2325)
* Update messages.it.xlf

* Update changelog
2023-09-14 19:48:47 +02:00
b21884eb66 Feature/harmonize logger output (#2321)
* Harmonize logger output

* Update changelog
2023-09-13 08:39:37 +02:00
1c5437e1fd Extend sitemap.xml with dutch pages (#2318) 2023-09-13 08:39:09 +02:00
58278ba5e6 Bugfix/fix dutch localization of portfolio summary (#2315)
* Revert reserved keyword (plural)

* Update changelog
2023-09-11 12:04:09 +02:00
921f3e9807 Update dutch translation (#2314)
* Update dutch translation

* Update changelog
2023-09-11 11:42:36 +02:00
75ca125a70 Add home server systems (#2311) 2023-09-10 08:01:01 +02:00
a1fd4e7a38 Improve wording (#2312) 2023-09-10 08:00:07 +02:00
0d5a8eb33e Add Ghostfolio 2.0 (#2309) 2023-09-09 09:10:36 +02:00
b088df2fa3 Release 2.0.0 (#2310) 2023-09-09 08:29:18 +02:00
f45d8f616a Bugfix/fix blog post ghostfolio 2 (#2307)
* Fix month

* Update sitemap.xml

* Update locales
2023-09-08 21:36:01 +02:00
d8300502ce Feature/upgrade yahoo finance2 to version 2.5.0 (#2306)
* Upgrade yahoo-finance2 to version 2.5.0

* Update changelog
2023-09-08 21:22:57 +02:00
502d51ad29 Feature/add blog post ghostfolio 2 (#2269)
* Add blog post: Ghostfolio 2.0

* Update changelog
2023-09-08 20:45:31 +02:00
bc33e5f147 Feature/remove deprecated environment variable base currency (#2255)
* Remove the deprecated environment variable BASE_CURRENCY

* Update changelog
2023-09-08 20:43:23 +02:00
48ba8f936b Feature/deactivate internet identity for account registration (#2293)
* Deactivate Internet Identity

* Update changelog
2023-09-08 20:23:22 +02:00
05ec4cce05 Add Portuguese landing page (#2301) 2023-09-08 17:06:58 +02:00
d74f283707 Eliminate prisma service (#2286)
* Eliminate prisma service
2023-09-08 17:05:42 +02:00
0f8bc7db32 Bugfix/do not remove countries and sectors in yahoo finance data enhancer (#2297)
* Do not remove countries and sectors

* Update changelog
2023-09-08 15:07:17 +02:00
431500f28a Update docker compose files to version 3.9 (#2299)
* Update docker compose files to version 3.9

* Format yml files

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-09-07 20:44:52 +02:00
9672de174e Feature/improve language localization for german 20230903 (#2294)
* Update locales

* Update changelog
2023-09-07 19:20:30 +02:00
c6aa06b933 Feature/improve import validation (#2305)
* Improve import validation

* Update changelog

Co-authored-by: httpiga <36515569+httpiga@users.noreply.github.com>
2023-09-07 18:28:47 +02:00
1f46a6b6f3 Clean up (#2291) 2023-09-05 19:44:45 +02:00
1bed940bc0 Feature/refresh cryptocurrencies list 20230903 (#2290)
* Refresh cryptocurrencies list

* Add CyberConnect

* Update changelog
2023-09-04 09:07:06 +02:00
f9eb3cc3c5 Release 1.305.0 (#2289) 2023-09-03 08:16:02 +02:00
2519c3ffb0 Bugfix/fix alignment in menu of impersonation mode (#2284)
* Fix alignment

* Update changelog
2023-09-02 17:57:50 +02:00
91013d1d10 Bugfix/fix alignment in header navigation (#2285)
* Fix alignment

* Update changelog
2023-09-02 17:13:13 +02:00
6deefb9c43 Update OSS Friends (#2282) 2023-09-02 11:52:29 +02:00
d0744e07df Feature/upgrade replace in file to version 7.0.1 (#2277)
* Upgrade replace-in-file to version 7.0.1

* Update changelog
2023-09-02 08:58:25 +02:00
93e1ee3ba7 Feature/improve localization of personal finance tools (#2274)
* Improve localization

* Update changelog
2023-09-02 08:58:10 +02:00
dceaa55a6c Feature/add hacker news logo to landing page (#2281)
* Add Hacker News

* Update changelog
2023-09-02 08:39:32 +02:00
8b4d55925d Feature/upgrade yahoo finance2 to version 2.4.4 (#2276)
* Upgrade yahoo-finance2 to version 2.4.4

* Update changelog
2023-09-02 08:36:46 +02:00
754b49e50f Feature/shorten page titles (#2273)
* Shorten page titles

* Update changelog
2023-08-31 18:22:22 +02:00
6ccbda8169 Feature/upgrade prisma to version 5.2.0 (#2217)
* Upgrade prisma to version 5.2.0

* Update changelog
2023-08-29 13:44:53 +02:00
153 changed files with 28261 additions and 9422 deletions

View File

@ -11,6 +11,5 @@ POSTGRES_USER=user
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD> POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING> ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING> JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

View File

@ -5,6 +5,122 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.5.0 - 2023-09-23
### Added
- Added support for translated activity types in the activities table
- Added support for dates in `DD.MM.YYYY` format in the activities import
- Set up the language localization for Türkçe (`tr`)
### Changed
- Skipped creating queue jobs for asset profiles with `MANUAL` data source on creating a new activity
### Fixed
- Fixed an issue with the cash position in the holdings table
## 2.4.0 - 2023-09-19
### Added
- Added support for interest on account level (experimental)
### Changed
- Improved the preselected currency based on the account's currency in the create or edit activity dialog
- Unlocked the experimental features setting for all users
- Upgraded `prisma` from version `5.2.0` to `5.3.1`
### Fixed
- Fixed a memory leak related to the server's timezone (behind UTC) in the data gathering
## 2.3.0 - 2023-09-17
### Added
- Added support for fees on account level (experimental)
### Fixed
- Fixed the export functionality for liabilities
## 2.2.0 - 2023-09-17
### Added
- Introduced a sidebar navigation on desktop
### Changed
- Improved the style of the system message
- Upgraded _Postgres_ from version `12` to `15` in the `docker-compose` files
## 2.1.0 - 2023-09-15
### Added
- Added support to drop a file in the import activities dialog
- Added a timeout to all data source requests
### Changed
- Harmonized the style of the user interface for granting and revoking public access to share the portfolio
- Removed the account type from the user interface as a preparation to remove it from the `Account` database schema
- Improved the logger output of the info service
- Harmonized the logger output: `<symbol> (<dataSource>)`
- Improved the language localization for German (`de`)
- Improved the language localization for Italian (`it`)
- Improved the language localization for Dutch (`nl`)
- Improved the read-only mode
### Fixed
- Fixed the timeout in _EOD Historical Data_ requests
- Fixed an issue with the portfolio summary caused by the language localization for Dutch (`nl`)
## 2.0.0 - 2023-09-09
### Added
- Added support for the cryptocurrency _CyberConnect_
- Added a blog post: _Announcing Ghostfolio 2.0_
### Changed
- **Breaking Change**: Removed the deprecated environment variable `BASE_CURRENCY`
- Improved the validation in the activities import
- Deactivated _Internet Identity_ as a social login provider for the account registration
- Improved the language localization for German (`de`)
- Refreshed the cryptocurrencies list
- Changed the version in the `docker-compose` files from `3.7` to `3.9`
- Upgraded `yahoo-finance2` from version `2.4.4` to `2.5.0`
### Fixed
- Fixed an issue in the _Yahoo Finance_ data enhancer where countries and sectors have been removed
## 1.305.0 - 2023-09-03
### Added
- Added _Hacker News_ to the _As seen in_ section on the landing page
### Changed
- Shortened the page titles
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `4.16.2` to `5.2.0`
- Upgraded `replace-in-file` from version `6.3.5` to `7.0.1`
- Upgraded `yahoo-finance2` from version `2.4.3` to `2.4.4`
### Fixed
- Fixed the alignment in the header navigation
- Fixed the alignment in the menu of the impersonation mode
## 1.304.0 - 2023-08-27 ## 1.304.0 - 2023-08-27
### Added ### Added
@ -1469,7 +1585,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Set up the language localization for Italiano (`it`) - Set up the language localization for Italian (`it`)
- Extended the landing page - Extended the landing page
## 1.195.0 - 20.09.2022 ## 1.195.0 - 20.09.2022
@ -2892,7 +3008,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Supported the management of additional currencies in the admin control panel - Supported the management of additional currencies in the admin control panel
- Introduced the system message - Introduced the system message
- Introduced the read only mode - Introduced the read-only mode
### Changed ### Changed

View File

@ -13,6 +13,8 @@
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing) [![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing)
[![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) [![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
</div> </div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation. **Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
@ -25,7 +27,7 @@
## Ghostfolio Premium ## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs. Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development.
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section. If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
@ -136,9 +138,9 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d` 1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
At each start, the container will automatically apply the database schema migrations if needed. At each start, the container will automatically apply the database schema migrations if needed.
### Run with _Unraid_ (Community) ### Home Server Systems (Community)
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio). Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
## Development ## Development

View File

@ -10,6 +10,7 @@ import {
import { isString } from 'lodash'; import { isString } from 'lodash';
export class CreateAccountDto { export class CreateAccountDto {
@IsOptional()
@IsString() @IsString()
accountType: AccountType; accountType: AccountType;

View File

@ -10,6 +10,7 @@ import {
import { isString } from 'lodash'; import { isString } from 'lodash';
export class UpdateAccountDto { export class UpdateAccountDto {
@IsOptional()
@IsString() @IsString()
accountType: AccountType; accountType: AccountType;

View File

@ -6,7 +6,12 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -23,8 +28,6 @@ import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -34,9 +37,7 @@ export class AdminService {
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async addAssetProfile({ public async addAssetProfile({
dataSource, dataSource,
@ -80,15 +81,15 @@ export class AdminService {
exchangeRates: this.exchangeRateDataService exchangeRates: this.exchangeRateDataService
.getCurrencies() .getCurrencies()
.filter((currency) => { .filter((currency) => {
return currency !== this.baseCurrency; return currency !== DEFAULT_CURRENCY;
}) })
.map((currency) => { .map((currency) => {
return { return {
label1: this.baseCurrency, label1: DEFAULT_CURRENCY,
label2: currency, label2: currency,
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
1, 1,
this.baseCurrency, DEFAULT_CURRENCY,
currency currency
) )
}; };
@ -306,7 +307,9 @@ export class AdminService {
response = await this.propertyService.delete({ key }); response = await this.propertyService.delete({ key });
} }
if (key === PROPERTY_CURRENCIES) { if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') {
await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false');
} else if (key === PROPERTY_CURRENCIES) {
await this.exchangeRateDataService.initialize(); await this.exchangeRateDataService.initialize();
} }

View File

@ -55,7 +55,7 @@ export class AuthService {
const isUserSignupEnabled = const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled(); await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled) { if (!isUserSignupEnabled || true) {
throw new Error('Sign up forbidden'); throw new Error('Sign up forbidden');
} }

View File

@ -26,18 +26,8 @@ export class ExportService {
where: { userId } where: { userId }
}) })
).map( ).map(
({ ({ balance, comment, currency, id, isExcluded, name, platformId }) => {
accountType,
balance,
comment,
currency,
id,
isExcluded,
name,
platformId
}) => {
return { return {
accountType,
balance, balance,
comment, comment,
currency, currency,
@ -87,7 +77,13 @@ export class ExportService {
currency: SymbolProfile.currency, currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toISOString(), date: date.toISOString(),
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol symbol:
type === 'FEE' ||
type === 'INTEREST' ||
type === 'ITEM' ||
type === 'LIABILITY'
? SymbolProfile.name
: SymbolProfile.symbol
}; };
} }
) )

View File

@ -410,7 +410,7 @@ export class ImportService {
currency, currency,
userCurrency userCurrency
), ),
//@ts-ignore // @ts-ignore
SymbolProfile: assetProfile, SymbolProfile: assetProfile,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value, value,
@ -566,7 +566,7 @@ export class ImportService {
]) ])
)?.[symbol]; )?.[symbol];
if (!assetProfile) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );

View File

@ -1,6 +1,7 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -28,11 +29,11 @@ import { InfoService } from './info.service';
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
PlatformModule, PlatformModule,
PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
TagModule TagModule,
UserModule
], ],
providers: [InfoService] providers: [InfoService]
}) })

View File

@ -1,12 +1,14 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT,
PROPERTY_BETTER_UPTIME_MONITOR_ID, PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
@ -44,10 +46,10 @@ export class InfoService {
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly platformService: PlatformService, private readonly platformService: PlatformService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly tagService: TagService private readonly tagService: TagService,
private readonly userService: UserService
) {} ) {}
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
@ -139,18 +141,13 @@ export class InfoService {
subscriptions, subscriptions,
systemMessage, systemMessage,
tags, tags,
baseCurrency: this.configurationService.get('BASE_CURRENCY'), baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies() currencies: this.exchangeRateDataService.getCurrencies()
}; };
} }
private async countActiveUsers(aDays: number) { private async countActiveUsers(aDays: number) {
return await this.prismaService.user.count({ return this.userService.count({
orderBy: {
Analytics: {
updatedAt: 'desc'
}
},
where: { where: {
AND: [ AND: [
{ {
@ -172,16 +169,24 @@ export class InfoService {
private async countDockerHubPulls(): Promise<number> { private async countDockerHubPulls(): Promise<number> {
try { try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { pull_count } = await got( const { pull_count } = await got(
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`, `https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
{ {
headers: { 'User-Agent': 'request' } headers: { 'User-Agent': 'request' },
// @ts-ignore
signal: abortController.signal
} }
).json<any>(); ).json<any>();
return pull_count; return pull_count;
} catch (error) { } catch (error) {
Logger.error(error, 'InfoService'); Logger.error(error, 'InfoService - DockerHub');
return undefined; return undefined;
} }
@ -189,7 +194,16 @@ export class InfoService {
private async countGitHubContributors(): Promise<number> { private async countGitHubContributors(): Promise<number> {
try { try {
const { body } = await got('https://github.com/ghostfolio/ghostfolio'); const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
// @ts-ignore
signal: abortController.signal
});
const $ = cheerio.load(body); const $ = cheerio.load(body);
@ -199,7 +213,7 @@ export class InfoService {
).text() ).text()
); );
} catch (error) { } catch (error) {
Logger.error(error, 'InfoService'); Logger.error(error, 'InfoService - GitHub');
return undefined; return undefined;
} }
@ -207,26 +221,31 @@ export class InfoService {
private async countGitHubStargazers(): Promise<number> { private async countGitHubStargazers(): Promise<number> {
try { try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { stargazers_count } = await got( const { stargazers_count } = await got(
`https://api.github.com/repos/ghostfolio/ghostfolio`, `https://api.github.com/repos/ghostfolio/ghostfolio`,
{ {
headers: { 'User-Agent': 'request' } headers: { 'User-Agent': 'request' },
// @ts-ignore
signal: abortController.signal
} }
).json<any>(); ).json<any>();
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
Logger.error(error, 'InfoService'); Logger.error(error, 'InfoService - GitHub');
return undefined; return undefined;
} }
} }
private async countNewUsers(aDays: number) { private async countNewUsers(aDays: number) {
return await this.prismaService.user.count({ return this.userService.count({
orderBy: {
createdAt: 'desc'
},
where: { where: {
AND: [ AND: [
{ {
@ -317,11 +336,10 @@ export class InfoService {
return undefined; return undefined;
} }
const stripeConfig = (await this.prismaService.property.findUnique({ return (
where: { key: PROPERTY_STRIPE_CONFIG } ((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
})) ?? { value: '{}' }; {}
);
return JSON.parse(stripeConfig.value);
} }
private async getUptime(): Promise<number> { private async getUptime(): Promise<number> {
@ -331,24 +349,31 @@ export class InfoService {
PROPERTY_BETTER_UPTIME_MONITOR_ID PROPERTY_BETTER_UPTIME_MONITOR_ID
)) as string; )) as string;
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { data } = await got( const { data } = await got(
`https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format( `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90), subDays(new Date(), 90),
DATE_FORMAT DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`, )}&to${format(new Date(), DATE_FORMAT)}`,
{ {
headers: { headers: {
Authorization: `Bearer ${this.configurationService.get( Authorization: `Bearer ${this.configurationService.get(
'BETTER_UPTIME_API_KEY' 'BETTER_UPTIME_API_KEY'
)}` )}`
} },
// @ts-ignore
signal: abortController.signal
} }
).json<any>(); ).json<any>();
return data.attributes.availability / 100; return data.attributes.availability / 100;
} catch (error) { } catch (error) {
Logger.error(error, 'InfoService'); Logger.error(error, 'InfoService - Better Stack');
return undefined; return undefined;
} }

View File

@ -1,4 +1,5 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -41,10 +42,18 @@ export class LogoService {
} }
private getBuffer(aUrl: string) { private getBuffer(aUrl: string) {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
return got( return got(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
{ {
headers: { 'User-Agent': 'request' } headers: { 'User-Agent': 'request' },
// @ts-ignore
signal: abortController.signal
} }
).buffer(); ).buffer();
} }

View File

@ -147,8 +147,9 @@ export class OrderController {
userId: this.request.user.id userId: this.request.user.id
}); });
if (!order.isDraft) { if (data.dataSource && !order.isDraft) {
// Gather symbol data in the background, if not draft // Gather symbol data in the background, if data source is set
// (not MANUAL) and not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
dataSource: data.dataSource, dataSource: data.dataSource,

View File

@ -97,7 +97,12 @@ export class OrderService {
const updateAccountBalance = data.updateAccountBalance ?? false; const updateAccountBalance = data.updateAccountBalance ?? false;
const userId = data.userId; const userId = data.userId;
if (data.type === 'ITEM' || data.type === 'LIABILITY') { if (
data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY'
) {
const assetClass = data.assetClass; const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
currency = data.SymbolProfile.connectOrCreate.create.currency; currency = data.SymbolProfile.connectOrCreate.create.currency;
@ -118,20 +123,22 @@ export class OrderService {
}; };
} }
this.dataGatheringService.addJobToQueue({ if (data.SymbolProfile.connectOrCreate.create.dataSource !== 'MANUAL') {
data: { this.dataGatheringService.addJobToQueue({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, data: {
symbol: data.SymbolProfile.connectOrCreate.create.symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
}) },
} name: GATHER_ASSET_PROFILE_PROCESS,
}); opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
})
}
});
}
delete data.accountId; delete data.accountId;
delete data.assetClass; delete data.assetClass;
@ -151,6 +158,9 @@ export class OrderService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = const isDraft =
data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY' data.type === 'LIABILITY'
? false ? false
: isAfter(data.date as Date, endOfToday()); : isAfter(data.date as Date, endOfToday());
@ -197,7 +207,12 @@ export class OrderService {
where where
}); });
if (order.type === 'ITEM' || order.type === 'LIABILITY') { if (
order.type === 'FEE' ||
order.type === 'INTEREST' ||
order.type === 'ITEM' ||
order.type === 'LIABILITY'
) {
await this.symbolProfileService.deleteById(order.symbolProfileId); await this.symbolProfileService.deleteById(order.symbolProfileId);
} }
@ -368,7 +383,12 @@ export class OrderService {
let isDraft = false; let isDraft = false;
if (data.type === 'ITEM' || data.type === 'LIABILITY') { if (
data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY'
) {
delete data.SymbolProfile.connect; delete data.SymbolProfile.connect;
} else { } else {
delete data.SymbolProfile.update; delete data.SymbolProfile.update;

View File

@ -105,7 +105,6 @@ describe('CurrentRateService', () => {
null, null,
null, null,
null, null,
null,
null null
); );
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);

View File

@ -784,7 +784,7 @@ export class PortfolioCalculator {
); );
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
Logger.warn( Logger.warn(
`Missing historical market data for symbol ${currentPosition.symbol}`, `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator' 'PortfolioCalculator'
); );
hasErrors = true; hasErrors = true;

View File

@ -10,7 +10,10 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
@ -47,8 +50,6 @@ import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
private baseCurrency: string;
public constructor( public constructor(
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
@ -57,9 +58,7 @@ export class PortfolioController {
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@ -174,8 +173,14 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = { holdings[symbol] = {
...portfolioPosition, ...portfolioPosition,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined, assetClass:
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined, hasDetails || portfolioPosition.assetClass === 'CASH'
? portfolioPosition.assetClass
: undefined,
assetSubClass:
hasDetails || portfolioPosition.assetSubClass === 'CASH'
? portfolioPosition.assetSubClass
: undefined,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined, currency: hasDetails ? portfolioPosition.currency : undefined,
markets: hasDetails ? portfolioPosition.markets : undefined, markets: hasDetails ? portfolioPosition.markets : undefined,
@ -442,8 +447,7 @@ export class PortfolioController {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice, portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency, portfolioPosition.currency,
this.request.user?.Settings?.settings.baseCurrency ?? this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
this.baseCurrency
); );
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);

View File

@ -11,12 +11,12 @@ import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/ac
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID, EMERGENCY_FUND_TAG_ID,
MAX_CHART_ITEMS, MAX_CHART_ITEMS,
UNKNOWN_KEY UNKNOWN_KEY
@ -56,12 +56,11 @@ import {
Platform, Platform,
Prisma, Prisma,
Tag, Tag,
Type as TypeOfOrder Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
differenceInDays, differenceInDays,
endOfToday,
format, format,
isAfter, isAfter,
isBefore, isBefore,
@ -90,11 +89,8 @@ const europeMarkets = require('../../assets/countries/europe-markets.json');
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
private baseCurrency: string;
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService, private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -104,9 +100,7 @@ export class PortfolioService {
private readonly rulesService: RulesService, private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
private readonly userService: UserService private readonly userService: UserService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getAccounts({ public async getAccounts({
filters, filters,
@ -1347,36 +1341,6 @@ export class PortfolioService {
return cashPositions; return cashPositions;
} }
private getDividend({
activities,
date = new Date(0),
userCurrency
}: {
activities: OrderWithAccount[];
date?: Date;
userCurrency: string;
}) {
return activities
.filter((activity) => {
// Filter out all activities before given date (drafts) and type dividend
return (
isBefore(date, new Date(activity.date)) &&
activity.type === TypeOfOrder.DIVIDEND
);
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getDividendsByGroup({ private getDividendsByGroup({
dividends, dividends,
groupBy groupBy
@ -1521,52 +1485,6 @@ export class PortfolioService {
}; };
} }
private getItems(activities: OrderWithAccount[], date = new Date(0)) {
return activities
.filter((activity) => {
// Filter out all activities before given date (drafts) and type item
return (
isBefore(date, new Date(activity.date)) &&
activity.type === TypeOfOrder.ITEM
);
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getLiabilities({
activities,
userCurrency
}: {
activities: OrderWithAccount[];
userCurrency: string;
}) {
return activities
.filter(({ type }) => {
return type === TypeOfOrder.LIABILITY;
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getStartDate(aDateRange: DateRange, portfolioStart: Date) { private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) { switch (aDateRange) {
case '1d': case '1d':
@ -1655,9 +1573,10 @@ export class PortfolioService {
return account?.isExcluded ?? false; return account?.isExcluded ?? false;
}); });
const dividend = this.getDividend({ const dividend = this.getSumOfActivityType({
activities, activities,
userCurrency userCurrency,
activityType: 'DIVIDEND'
}).toNumber(); }).toNumber();
const emergencyFund = new Big( const emergencyFund = new Big(
Math.max( Math.max(
@ -1667,23 +1586,49 @@ export class PortfolioService {
); );
const fees = this.getFees({ activities, userCurrency }).toNumber(); const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date; const firstOrderDate = activities[0]?.date;
const items = this.getItems(activities).toNumber(); const interest = this.getSumOfActivityType({
const liabilities = this.getLiabilities({
activities, activities,
userCurrency userCurrency,
activityType: 'INTEREST'
}).toNumber();
const items = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'ITEM'
}).toNumber();
const liabilities = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'LIABILITY'
}).toNumber(); }).toNumber();
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY'); const totalBuy = this.getSumOfActivityType({
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL'); activities,
userCurrency,
activityType: 'BUY'
}).toNumber();
const totalSell = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'SELL'
}).toNumber();
const cash = new Big(balanceInBaseCurrency) const cash = new Big(balanceInBaseCurrency)
.minus(emergencyFund) .minus(emergencyFund)
.plus(emergencyFundPositionsValueInBaseCurrency) .plus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(); .toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell); const committedFunds = new Big(totalBuy).minus(totalSell);
const totalOfExcludedActivities = new Big( const totalOfExcludedActivities = this.getSumOfActivityType({
this.getTotalByType(excludedActivities, userCurrency, 'BUY') userCurrency,
).minus(this.getTotalByType(excludedActivities, userCurrency, 'SELL')); activities: excludedActivities,
activityType: 'BUY'
}).minus(
this.getSumOfActivityType({
userCurrency,
activities: excludedActivities,
activityType: 'SELL'
})
);
const cashDetailsWithExcludedAccounts = const cashDetailsWithExcludedAccounts =
await this.accountService.getCashDetails({ await this.accountService.getCashDetails({
@ -1730,6 +1675,7 @@ export class PortfolioService {
excludedAccountsAndActivities, excludedAccountsAndActivities,
fees, fees,
firstOrderDate, firstOrderDate,
interest,
items, items,
liabilities, liabilities,
netWorth, netWorth,
@ -1752,6 +1698,39 @@ export class PortfolioService {
}; };
} }
private getSumOfActivityType({
activities,
activityType,
date = new Date(0),
userCurrency
}: {
activities: OrderWithAccount[];
activityType: ActivityType;
date?: Date;
userCurrency: string;
}) {
return activities
.filter((activity) => {
// Filter out all activities before given date (drafts) and
// activity type
return (
isBefore(date, new Date(activity.date)) &&
activity.type === activityType
);
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private async getTransactionPoints({ private async getTransactionPoints({
filters, filters,
includeDrafts = false, includeDrafts = false,
@ -1768,7 +1747,7 @@ export class PortfolioService {
portfolioOrders: PortfolioOrder[]; portfolioOrders: PortfolioOrder[];
}> { }> {
const userCurrency = const userCurrency =
this.request.user?.Settings?.settings.baseCurrency ?? this.baseCurrency; this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
const orders = await this.orderService.getOrders({ const orders = await this.orderService.getOrders({
filters, filters,
@ -1823,6 +1802,21 @@ export class PortfolioService {
}; };
} }
private getUserCurrency(aUser: UserWithSettings) {
return (
aUser.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
);
}
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
private async getValueOfAccountsAndPlatforms({ private async getValueOfAccountsAndPlatforms({
filters = [], filters = [],
orders, orders,
@ -1966,38 +1960,4 @@ export class PortfolioService {
return { accounts, platforms }; return { accounts, platforms };
} }
private getTotalByType(
orders: OrderWithAccount[],
currency: string,
type: TypeOfOrder
) {
return orders
.filter(
(order) => !isAfter(order.date, endOfToday()) && order.type === type
)
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.SymbolProfile.currency,
currency
);
})
.reduce((previous, current) => previous + current, 0);
}
private getUserCurrency(aUser: UserWithSettings) {
return (
aUser.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
this.baseCurrency
);
}
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
} }

View File

@ -19,22 +19,22 @@ import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash'; import { sortBy, without } from 'lodash';
const crypto = require('crypto'); const crypto = require('crypto');
@Injectable() @Injectable()
export class UserService { export class UserService {
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService private readonly tagService: TagService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
public async count(args?: Prisma.UserCountArgs) {
return this.prismaService.user.count(args);
} }
public async getUser( public async getUser(
@ -188,6 +188,11 @@ export class UserService {
currentPermissions.push(permissions.enableSubscriptionInterstitial); currentPermissions.push(permissions.enableSubscriptionInterstitial);
} }
currentPermissions = without(
currentPermissions,
permissions.createAccess
);
// Reset benchmark // Reset benchmark
user.Settings.settings.benchmark = undefined; user.Settings.settings.benchmark = undefined;
} }
@ -267,7 +272,7 @@ export class UserService {
...data, ...data,
Account: { Account: {
create: { create: {
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
isDefault: true, isDefault: true,
name: 'Default Account' name: 'Default Account'
} }
@ -275,7 +280,7 @@ export class UserService {
Settings: { Settings: {
create: { create: {
settings: { settings: {
currency: this.baseCurrency currency: DEFAULT_CURRENCY
} }
} }
} }

View File

@ -1293,6 +1293,7 @@
"BZKY": "Bizkey", "BZKY": "Bizkey",
"BZL": "BZLCoin", "BZL": "BZLCoin",
"BZNT": "Bezant", "BZNT": "Bezant",
"BZR": "Bazaars",
"BZRX": "bZx Protocol", "BZRX": "bZx Protocol",
"BZX": "Bitcoin Zero", "BZX": "Bitcoin Zero",
"BZZ": "Swarmv", "BZZ": "Swarmv",
@ -2564,7 +2565,7 @@
"ELONGT": "Elon GOAT", "ELONGT": "Elon GOAT",
"ELONONE": "AstroElon", "ELONONE": "AstroElon",
"ELP": "Ellerium", "ELP": "Ellerium",
"ELS": "Elysium", "ELS": "Ethlas",
"ELT": "Element Black", "ELT": "Element Black",
"ELTC2": "eLTC", "ELTC2": "eLTC",
"ELTCOIN": "ELTCOIN", "ELTCOIN": "ELTCOIN",
@ -2573,6 +2574,7 @@
"ELVN": "11Minutes", "ELVN": "11Minutes",
"ELX": "Energy Ledger", "ELX": "Energy Ledger",
"ELY": "Elysian", "ELY": "Elysian",
"ELYSIUM": "Elysium",
"EM": "Eminer", "EM": "Eminer",
"EMANATE": "EMANATE", "EMANATE": "EMANATE",
"EMAR": "EmaratCoin", "EMAR": "EmaratCoin",
@ -2890,6 +2892,7 @@
"FDO": "Firdaos", "FDO": "Firdaos",
"FDR": "French Digital Reserve", "FDR": "French Digital Reserve",
"FDT": "Frutti Dino", "FDT": "Frutti Dino",
"FDUSD": "First Digital USD",
"FDX": "fidentiaX", "FDX": "fidentiaX",
"FDZ": "Friendz", "FDZ": "Friendz",
"FEAR": "Fear", "FEAR": "Fear",

View File

@ -1,4 +1,5 @@
{ {
"CYBER24781": "CyberConnect",
"LUNA1": "Terra", "LUNA1": "Terra",
"LUNA2": "Terra", "LUNA2": "Terra",
"SGB1": "Songbird", "SGB1": "Songbird",

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<urlset <urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url> <url>
<loc>https://ghostfol.io/de</loc> <loc>https://ghostfol.io/de</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -50,6 +50,118 @@
<loc>https://ghostfol.io/de/ressourcen</loc> <loc>https://ghostfol.io/de/ressourcen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ueber-uns</loc> <loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -146,6 +258,10 @@
<loc>https://ghostfol.io/en/blog/2023/08/ghostfolio-joins-oss-friends</loc> <loc>https://ghostfol.io/en/blog/2023/08/ghostfolio-joins-oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/faq</loc> <loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -184,6 +300,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -268,6 +388,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -442,6 +566,118 @@
<loc>https://ghostfol.io/it/risorse</loc> <loc>https://ghostfol.io/it/risorse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-campmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl</loc> <loc>https://ghostfol.io/nl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -451,7 +687,119 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/nl/kenmerken</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/functionaliteiten</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
@ -493,7 +841,11 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/nl/vaak-gestelde-vragen</loc> <loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
@ -548,4 +900,8 @@
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc> <loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/tr</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
</urlset> </urlset>

View File

@ -55,7 +55,6 @@ async function bootstrap() {
app.use(HtmlTemplateMiddleware); app.use(HtmlTemplateMiddleware);
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY');
const HOST = configService.get<string>('HOST') || '0.0.0.0'; const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333; const PORT = configService.get<number>('PORT') || 3333;
@ -63,15 +62,6 @@ async function bootstrap() {
logLogo(); logLogo();
Logger.log(`Listening at http://${HOST}:${PORT}`); Logger.log(`Listening at http://${HOST}:${PORT}`);
Logger.log(''); Logger.log('');
if (BASE_CURRENCY) {
Logger.warn(
`The environment variable "BASE_CURRENCY" is deprecated and will be removed in Ghostfolio 2.0.`
);
Logger.warn(
'Please use the currency converter in the activity dialog instead.'
);
}
}); });
} }

View File

@ -18,7 +18,8 @@ const descriptions = {
fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.', fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.', it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETFs of cryptocurrencies over meerdere platforms bij te houden.', nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETFs of cryptocurrencies over meerdere platforms bij te houden.',
pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.' pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
tr: 'Ghostfolio, hisse senetleri, ETFler veya kripto para birimleri gibi varlıklarınızı birden fazla platformda takip etmenizi sağlayan bir kişisel finans panosudur.'
}; };
const title = 'Ghostfolio Open Source Wealth Management Software'; const title = 'Ghostfolio Open Source Wealth Management Software';
@ -75,6 +76,10 @@ const locales = {
'/en/blog/2023/08/ghostfolio-joins-oss-friends': { '/en/blog/2023/08/ghostfolio-joins-oss-friends': {
featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png', featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png',
title: `Ghostfolio joins OSS Friends - ${titleShort}` title: `Ghostfolio joins OSS Friends - ${titleShort}`
},
'/en/blog/2023/09/ghostfolio-2': {
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
title: `Announcing Ghostfolio 2.0 - ${titleShort}`
} }
}; };

View File

@ -1,5 +1,5 @@
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface'; import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import { DEFAULT_CURRENCY, DEFAULT_ROOT_URL } from '@ghostfolio/common/config'; import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
@ -12,10 +12,6 @@ export class ConfigurationService {
this.environmentConfiguration = cleanEnv(process.env, { this.environmentConfiguration = cleanEnv(process.env, {
ACCESS_TOKEN_SALT: str(), ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }), ALPHA_VANTAGE_API_KEY: str({ default: '' }),
BASE_CURRENCY: str({
choices: ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'RUB', 'USD'],
default: DEFAULT_CURRENCY
}),
BETTER_UPTIME_API_KEY: str({ default: '' }), BETTER_UPTIME_API_KEY: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: 1 }), CACHE_QUOTES_TTL: num({ default: 1 }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),

View File

@ -13,6 +13,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { Job } from 'bull'; import { Job } from 'bull';
import { import {
addDays,
format, format,
getDate, getDate,
getMonth, getMonth,
@ -101,15 +102,7 @@ export class DataGatheringProcessor {
}); });
} }
// Count month one up for iteration currentDate = addDays(currentDate, 1);
currentDate = new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate) + 1,
0
)
);
} }
await this.marketDataService.updateMany({ data }); await this.marketDataService.updateMany({ data });

View File

@ -127,6 +127,10 @@ export class DataGatheringService {
uniqueAssets = await this.getUniqueAssets(); uniqueAssets = await this.getUniqueAssets();
} }
if (uniqueAssets.length <= 0) {
return;
}
const assetProfiles = const assetProfiles =
await this.dataProviderService.getAssetProfiles(uniqueAssets); await this.dataProviderService.getAssetProfiles(uniqueAssets);
const symbolProfiles = const symbolProfiles =
@ -145,7 +149,9 @@ export class DataGatheringService {
}); });
} catch (error) { } catch (error) {
Logger.error( Logger.error(
`Failed to enhance data for symbol ${symbol} by ${dataEnhancer.getName()}`, `Failed to enhance data for ${symbol} (${
assetProfile.dataSource
}) by ${dataEnhancer.getName()}`,
error, error,
'DataGatheringService' 'DataGatheringService'
); );

View File

@ -9,6 +9,7 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import * as Alphavantage from 'alphavantage';
import { format, isAfter, isBefore, parse } from 'date-fns'; import { format, isAfter, isBefore, parse } from 'date-fns';
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces'; import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
@ -20,7 +21,7 @@ export class AlphaVantageService implements DataProviderInterface {
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {
this.alphaVantage = require('alphavantage')({ this.alphaVantage = Alphavantage({
key: this.configurationService.get('ALPHA_VANTAGE_API_KEY') key: this.configurationService.get('ALPHA_VANTAGE_API_KEY')
}); });
} }
@ -126,6 +127,9 @@ export class AlphaVantageService implements DataProviderInterface {
return { return {
items: result?.bestMatches?.map((bestMatch) => { items: result?.bestMatches?.map((bestMatch) => {
return { return {
assetClass: undefined,
assetSubClass: undefined,
currency: bestMatch['8. currency'],
dataSource: this.getName(), dataSource: this.getName(),
name: bestMatch['2. name'], name: bestMatch['2. name'],
symbol: bestMatch['1. symbol'] symbol: bestMatch['1. symbol']

View File

@ -1,10 +1,13 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
@ -20,14 +23,9 @@ import got from 'got';
@Injectable() @Injectable()
export class CoinGeckoService implements DataProviderInterface { export class CoinGeckoService implements DataProviderInterface {
private baseCurrency: string;
private readonly URL = 'https://api.coingecko.com/api/v3'; private readonly URL = 'https://api.coingecko.com/api/v3';
public constructor( public constructor() {}
private readonly configurationService: ConfigurationService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return true; return true;
@ -39,13 +37,22 @@ export class CoinGeckoService implements DataProviderInterface {
const response: Partial<SymbolProfile> = { const response: Partial<SymbolProfile> = {
assetClass: AssetClass.CASH, assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY, assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
dataSource: this.getName(), dataSource: this.getName(),
symbol: aSymbol symbol: aSymbol
}; };
try { try {
const { name } = await got(`${this.URL}/coins/${aSymbol}`).json<any>(); const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { name } = await got(`${this.URL}/coins/${aSymbol}`, {
// @ts-ignore
signal: abortController.signal
}).json<any>();
response.name = name; response.name = name;
} catch (error) { } catch (error) {
@ -78,12 +85,22 @@ export class CoinGeckoService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
try { try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { prices } = await got( const { prices } = await got(
`${ `${
this.URL this.URL
}/coins/${aSymbol}/market_chart/range?vs_currency=${this.baseCurrency.toLowerCase()}&from=${getUnixTime( }/coins/${aSymbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime(
from from
)}&to=${getUnixTime(to)}` )}&to=${getUnixTime(to)}`,
{
// @ts-ignore
signal: abortController.signal
}
).json<any>(); ).json<any>();
const result: { const result: {
@ -127,19 +144,29 @@ export class CoinGeckoService implements DataProviderInterface {
} }
try { try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got( const response = await got(
`${this.URL}/simple/price?ids=${aSymbols.join( `${this.URL}/simple/price?ids=${aSymbols.join(
',' ','
)}&vs_currencies=${this.baseCurrency.toLowerCase()}` )}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
{
// @ts-ignore
signal: abortController.signal
}
).json<any>(); ).json<any>();
for (const symbol in response) { for (const symbol in response) {
if (Object.prototype.hasOwnProperty.call(response, symbol)) { if (Object.prototype.hasOwnProperty.call(response, symbol)) {
results[symbol] = { results[symbol] = {
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.COINGECKO, dataSource: DataSource.COINGECKO,
marketPrice: response[symbol][this.baseCurrency.toLowerCase()], marketPrice: response[symbol][DEFAULT_CURRENCY.toLowerCase()],
marketState: 'open' marketState: 'open'
}; };
} }
@ -165,9 +192,16 @@ export class CoinGeckoService implements DataProviderInterface {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
const { coins } = await got( const abortController = new AbortController();
`${this.URL}/search?query=${query}`
).json<any>(); setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { coins } = await got(`${this.URL}/search?query=${query}`, {
// @ts-ignore
signal: abortController.signal
}).json<any>();
items = coins.map(({ id: symbol, name }) => { items = coins.map(({ id: symbol, name }) => {
return { return {
@ -175,7 +209,7 @@ export class CoinGeckoService implements DataProviderInterface {
symbol, symbol,
assetClass: AssetClass.CASH, assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY, assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
dataSource: this.getName() dataSource: this.getName()
}; };
}); });

View File

@ -1,4 +1,5 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -32,15 +33,35 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response; return response;
} }
let abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const profile = await got( const profile = await got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json` `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`,
{
// @ts-ignore
signal: abortController.signal
}
) )
.json<any>() .json<any>()
.catch(() => { .catch(() => {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
return got( return got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split( `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
'.' '.'
)?.[0]}.json` )?.[0]}.json`,
{
// @ts-ignore
signal: abortController.signal
}
) )
.json<any>() .json<any>()
.catch(() => { .catch(() => {
@ -54,15 +75,35 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
response.isin = isin; response.isin = isin;
} }
abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const holdings = await got( const holdings = await got(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json` `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`,
{
// @ts-ignore
signal: abortController.signal
}
) )
.json<any>() .json<any>()
.catch(() => { .catch(() => {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
return got( return got(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split( `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(
'.' '.'
)?.[0]}.json` )?.[0]}.json`,
{
// @ts-ignore
signal: abortController.signal
}
) )
.json<any>() .json<any>()
.catch(() => { .catch(() => {

View File

@ -1,4 +1,3 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
@ -26,16 +25,13 @@ jest.mock(
); );
describe('YahooFinanceDataEnhancerService', () => { describe('YahooFinanceDataEnhancerService', () => {
let configurationService: ConfigurationService;
let cryptocurrencyService: CryptocurrencyService; let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService; let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
beforeAll(async () => { beforeAll(async () => {
configurationService = new ConfigurationService();
cryptocurrencyService = new CryptocurrencyService(); cryptocurrencyService = new CryptocurrencyService();
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService( yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
configurationService,
cryptocurrencyService cryptocurrencyService
); );
}); });

View File

@ -1,13 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { isCurrency } from '@ghostfolio/common/helper'; import { isCurrency } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataSource, DataSource,
Prisma,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
@ -16,23 +16,18 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable() @Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService private readonly cryptocurrencyService: CryptocurrencyService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) { public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
let symbol = aYahooFinanceSymbol.replace( let symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${this.baseCurrency}$`), new RegExp(`-${DEFAULT_CURRENCY}$`),
this.baseCurrency DEFAULT_CURRENCY
); );
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) { if (symbol.includes('=X') && !symbol.includes(DEFAULT_CURRENCY)) {
symbol = `${this.baseCurrency}${symbol}`; symbol = `${DEFAULT_CURRENCY}${symbol}`;
} }
return symbol.replace('=X', ''); return symbol.replace('=X', '');
@ -47,21 +42,18 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
*/ */
public convertToYahooFinanceSymbol(aSymbol: string) { public convertToYahooFinanceSymbol(aSymbol: string) {
if ( if (
aSymbol.includes(this.baseCurrency) && aSymbol.includes(DEFAULT_CURRENCY) &&
aSymbol.length > this.baseCurrency.length aSymbol.length > DEFAULT_CURRENCY.length
) { ) {
if ( if (
isCurrency( isCurrency(
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length) aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
) )
) { ) {
return `${aSymbol}=X`; return `${aSymbol}=X`;
} else if ( } else if (
this.cryptocurrencyService.isCryptocurrency( this.cryptocurrencyService.isCryptocurrency(
aSymbol.replace( aSymbol.replace(new RegExp(`-${DEFAULT_CURRENCY}$`), DEFAULT_CURRENCY)
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
) )
) { ) {
// Add a dash before the last three characters // Add a dash before the last three characters
@ -69,8 +61,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
// DOGEUSD -> DOGE-USD // DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD // SOL1USD -> SOL1-USD
return aSymbol.replace( return aSymbol.replace(
new RegExp(`-?${this.baseCurrency}$`), new RegExp(`-?${DEFAULT_CURRENCY}$`),
`-${this.baseCurrency}` `-${DEFAULT_CURRENCY}`
); );
} }
} }
@ -102,11 +94,11 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
const { countries, sectors, url } = const { countries, sectors, url } =
await this.getAssetProfile(yahooSymbol); await this.getAssetProfile(yahooSymbol);
if (countries) { if ((countries as unknown as Prisma.JsonArray)?.length > 0) {
response.countries = countries; response.countries = countries;
} }
if (sectors) { if ((sectors as unknown as Prisma.JsonArray)?.length > 0) {
response.sectors = sectors; response.sectors = sectors;
} }

View File

@ -5,7 +5,10 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -18,19 +21,16 @@ import {
import Big from 'big.js'; import Big from 'big.js';
import { format, isToday } from 'date-fns'; import { format, isToday } from 'date-fns';
import got from 'got'; import got from 'got';
import ms from 'ms';
@Injectable() @Injectable()
export class EodHistoricalDataService implements DataProviderInterface { export class EodHistoricalDataService implements DataProviderInterface {
private apiKey: string; private apiKey: string;
private baseCurrency: string;
private readonly URL = 'https://eodhistoricaldata.com/api'; private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY'); this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
} }
public canHandle(symbol: string) { public canHandle(symbol: string) {
@ -78,6 +78,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
const symbol = this.convertToEodSymbol(aSymbol); const symbol = this.convertToEodSymbol(aSymbol);
try { try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got( const response = await got(
`${this.URL}/eod/${symbol}?api_token=${ `${this.URL}/eod/${symbol}?api_token=${
this.apiKey this.apiKey
@ -86,9 +92,8 @@ export class EodHistoricalDataService implements DataProviderInterface {
DATE_FORMAT DATE_FORMAT
)}&period={aGranularity}`, )}&period={aGranularity}`,
{ {
timeout: { // @ts-ignore
request: DEFAULT_REQUEST_TIMEOUT signal: abortController.signal
}
} }
).json<any>(); ).json<any>();
@ -138,14 +143,19 @@ export class EodHistoricalDataService implements DataProviderInterface {
} }
try { try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const realTimeResponse = await got( const realTimeResponse = await got(
`${this.URL}/real-time/${symbols[0]}?api_token=${ `${this.URL}/real-time/${symbols[0]}?api_token=${
this.apiKey this.apiKey
}&fmt=json&s=${symbols.join(',')}`, }&fmt=json&s=${symbols.join(',')}`,
{ {
timeout: { // @ts-ignore
request: DEFAULT_REQUEST_TIMEOUT signal: abortController.signal
}
} }
).json<any>(); ).json<any>();
@ -176,7 +186,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
})?.currency; })?.currency;
result[this.convertFromEodSymbol(code)] = { result[this.convertFromEodSymbol(code)] = {
currency: currency ?? this.baseCurrency, currency: currency ?? DEFAULT_CURRENCY,
dataSource: DataSource.EOD_HISTORICAL_DATA, dataSource: DataSource.EOD_HISTORICAL_DATA,
marketPrice: close, marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed' marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
@ -187,24 +197,24 @@ export class EodHistoricalDataService implements DataProviderInterface {
{} {}
); );
if (response[`${this.baseCurrency}GBP`]) { if (response[`${DEFAULT_CURRENCY}GBP`]) {
response[`${this.baseCurrency}GBp`] = { response[`${DEFAULT_CURRENCY}GBp`] = {
...response[`${this.baseCurrency}GBP`], ...response[`${DEFAULT_CURRENCY}GBP`],
currency: `${this.baseCurrency}GBp`, currency: `${DEFAULT_CURRENCY}GBp`,
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}GBp`, symbol: `${DEFAULT_CURRENCY}GBp`,
value: response[`${this.baseCurrency}GBP`].marketPrice value: response[`${DEFAULT_CURRENCY}GBP`].marketPrice
}) })
}; };
} }
if (response[`${this.baseCurrency}ILS`]) { if (response[`${DEFAULT_CURRENCY}ILS`]) {
response[`${this.baseCurrency}ILA`] = { response[`${DEFAULT_CURRENCY}ILA`] = {
...response[`${this.baseCurrency}ILS`], ...response[`${DEFAULT_CURRENCY}ILS`],
currency: `${this.baseCurrency}ILA`, currency: `${DEFAULT_CURRENCY}ILA`,
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ILA`, symbol: `${DEFAULT_CURRENCY}ILA`,
value: response[`${this.baseCurrency}ILS`].marketPrice value: response[`${DEFAULT_CURRENCY}ILS`].marketPrice
}) })
}; };
} }
@ -273,7 +283,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
if (symbol.endsWith('.FOREX')) { if (symbol.endsWith('.FOREX')) {
symbol = symbol.replace('GBX', 'GBp'); symbol = symbol.replace('GBX', 'GBp');
symbol = symbol.replace('.FOREX', ''); symbol = symbol.replace('.FOREX', '');
symbol = `${this.baseCurrency}${symbol}`; symbol = `${DEFAULT_CURRENCY}${symbol}`;
} }
return symbol; return symbol;
@ -286,17 +296,17 @@ export class EodHistoricalDataService implements DataProviderInterface {
*/ */
private convertToEodSymbol(aSymbol: string) { private convertToEodSymbol(aSymbol: string) {
if ( if (
aSymbol.startsWith(this.baseCurrency) && aSymbol.startsWith(DEFAULT_CURRENCY) &&
aSymbol.length > this.baseCurrency.length aSymbol.length > DEFAULT_CURRENCY.length
) { ) {
if ( if (
isCurrency( isCurrency(
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length) aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
) )
) { ) {
return `${aSymbol return `${aSymbol
.replace('GBp', 'GBX') .replace('GBp', 'GBX')
.replace(this.baseCurrency, '')}.FOREX`; .replace(DEFAULT_CURRENCY, '')}.FOREX`;
} }
} }
@ -310,10 +320,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
symbol: string; symbol: string;
value: number; value: number;
}) { }) {
if (symbol === `${this.baseCurrency}GBp`) { if (symbol === `${DEFAULT_CURRENCY}GBp`) {
// Convert GPB to GBp (pence) // Convert GPB to GBp (pence)
return new Big(value).mul(100).toNumber(); return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ILA`) { } else if (symbol === `${DEFAULT_CURRENCY}ILA`) {
// Convert ILS to ILA // Convert ILS to ILA
return new Big(value).mul(100).toNumber(); return new Big(value).mul(100).toNumber();
} }
@ -331,12 +341,17 @@ export class EodHistoricalDataService implements DataProviderInterface {
let searchResult = []; let searchResult = [];
try { try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got( const response = await got(
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`, `${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
{ {
timeout: { // @ts-ignore
request: DEFAULT_REQUEST_TIMEOUT signal: abortController.signal
}
} }
).json<any>(); ).json<any>();

View File

@ -5,6 +5,10 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
@ -16,7 +20,6 @@ import got from 'got';
@Injectable() @Injectable()
export class FinancialModelingPrepService implements DataProviderInterface { export class FinancialModelingPrepService implements DataProviderInterface {
private apiKey: string; private apiKey: string;
private baseCurrency: string;
private readonly URL = 'https://financialmodelingprep.com/api/v3'; private readonly URL = 'https://financialmodelingprep.com/api/v3';
public constructor( public constructor(
@ -25,7 +28,6 @@ export class FinancialModelingPrepService implements DataProviderInterface {
this.apiKey = this.configurationService.get( this.apiKey = this.configurationService.get(
'FINANCIAL_MODELING_PREP_API_KEY' 'FINANCIAL_MODELING_PREP_API_KEY'
); );
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
} }
public canHandle(symbol: string) { public canHandle(symbol: string) {
@ -64,8 +66,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
try { try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { historical } = await got( const { historical } = await got(
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}` `${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`,
{
// @ts-ignore
signal: abortController.signal
}
).json<any>(); ).json<any>();
const result: { const result: {
@ -111,13 +123,23 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} }
try { try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const response = await got( const response = await got(
`${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}` `${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`,
{
// @ts-ignore
signal: abortController.signal
}
).json<any>(); ).json<any>();
for (const { price, symbol } of response) { for (const { price, symbol } of response) {
results[symbol] = { results[symbol] = {
currency: this.baseCurrency, currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.FINANCIAL_MODELING_PREP, dataSource: DataSource.FINANCIAL_MODELING_PREP,
marketPrice: price, marketPrice: price,
@ -145,8 +167,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const result = await got( const result = await got(
`${this.URL}/search?query=${query}&apikey=${this.apiKey}` `${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
{
// @ts-ignore
signal: abortController.signal
}
).json<any>(); ).json<any>();
items = result.map(({ currency, name, symbol }) => { items = result.map(({ currency, name, symbol }) => {

View File

@ -6,6 +6,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
extractNumberFromString, extractNumberFromString,
@ -95,7 +96,17 @@ export class ManualService implements DataProviderInterface {
return {}; return {};
} }
const { body } = await got(url, { headers }); const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { body } = await got(url, {
headers,
// @ts-ignore
signal: abortController.signal
});
const $ = cheerio.load(body); const $ = cheerio.load(body);

View File

@ -5,7 +5,10 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import {
DEFAULT_REQUEST_TIMEOUT,
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -135,6 +138,12 @@ export class RapidApiService implements DataProviderInterface {
oneYearAgo: { value: number; valueText: string }; oneYearAgo: { value: number; valueText: string };
}> { }> {
try { try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { fgi } = await got( const { fgi } = await got(
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, `https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
{ {
@ -142,7 +151,9 @@ export class RapidApiService implements DataProviderInterface {
useQueryString: 'true', useQueryString: 'true',
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com', 'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY') 'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY')
} },
// @ts-ignore
signal: abortController.signal
} }
).json<any>(); ).json<any>();

View File

@ -1,5 +1,4 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -7,6 +6,7 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -18,15 +18,10 @@ import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote';
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService, private readonly cryptocurrencyService: CryptocurrencyService,
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
) { ) {}
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return true; return true;
@ -212,50 +207,50 @@ export class YahooFinanceService implements DataProviderInterface {
}; };
if ( if (
symbol === `${this.baseCurrency}GBP` && symbol === `${DEFAULT_CURRENCY}GBP` &&
yahooFinanceSymbols.includes(`${this.baseCurrency}GBp=X`) yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}GBp=X`)
) { ) {
// Convert GPB to GBp (pence) // Convert GPB to GBp (pence)
response[`${this.baseCurrency}GBp`] = { response[`${DEFAULT_CURRENCY}GBp`] = {
...response[symbol], ...response[symbol],
currency: 'GBp', currency: 'GBp',
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}GBp`, symbol: `${DEFAULT_CURRENCY}GBp`,
value: response[symbol].marketPrice value: response[symbol].marketPrice
}) })
}; };
} else if ( } else if (
symbol === `${this.baseCurrency}ILS` && symbol === `${DEFAULT_CURRENCY}ILS` &&
yahooFinanceSymbols.includes(`${this.baseCurrency}ILA=X`) yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}ILA=X`)
) { ) {
// Convert ILS to ILA // Convert ILS to ILA
response[`${this.baseCurrency}ILA`] = { response[`${DEFAULT_CURRENCY}ILA`] = {
...response[symbol], ...response[symbol],
currency: 'ILA', currency: 'ILA',
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ILA`, symbol: `${DEFAULT_CURRENCY}ILA`,
value: response[symbol].marketPrice value: response[symbol].marketPrice
}) })
}; };
} else if ( } else if (
symbol === `${this.baseCurrency}ZAR` && symbol === `${DEFAULT_CURRENCY}ZAR` &&
yahooFinanceSymbols.includes(`${this.baseCurrency}ZAc=X`) yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}ZAc=X`)
) { ) {
// Convert ZAR to ZAc (cents) // Convert ZAR to ZAc (cents)
response[`${this.baseCurrency}ZAc`] = { response[`${DEFAULT_CURRENCY}ZAc`] = {
...response[symbol], ...response[symbol],
currency: 'ZAc', currency: 'ZAc',
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${this.baseCurrency}ZAc`, symbol: `${DEFAULT_CURRENCY}ZAc`,
value: response[symbol].marketPrice value: response[symbol].marketPrice
}) })
}; };
} }
} }
if (yahooFinanceSymbols.includes(`${this.baseCurrency}USX=X`)) { if (yahooFinanceSymbols.includes(`${DEFAULT_CURRENCY}USX=X`)) {
// Convert USD to USX (cent) // Convert USD to USX (cent)
response[`${this.baseCurrency}USX`] = { response[`${DEFAULT_CURRENCY}USX`] = {
currency: 'USX', currency: 'USX',
dataSource: this.getName(), dataSource: this.getName(),
marketPrice: new Big(1).mul(100).toNumber(), marketPrice: new Big(1).mul(100).toNumber(),
@ -303,8 +298,8 @@ export class YahooFinanceService implements DataProviderInterface {
(quoteType === 'CRYPTOCURRENCY' && (quoteType === 'CRYPTOCURRENCY' &&
this.cryptocurrencyService.isCryptocurrency( this.cryptocurrencyService.isCryptocurrency(
symbol.replace( symbol.replace(
new RegExp(`-${this.baseCurrency}$`), new RegExp(`-${DEFAULT_CURRENCY}$`),
this.baseCurrency DEFAULT_CURRENCY
) )
)) || )) ||
quoteTypes.includes(quoteType) quoteTypes.includes(quoteType)
@ -314,7 +309,7 @@ export class YahooFinanceService implements DataProviderInterface {
if (quoteType === 'CRYPTOCURRENCY') { if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database. // Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
// Transactions need to be converted manually to the base currency before // Transactions need to be converted manually to the base currency before
return symbol.includes(this.baseCurrency); return symbol.includes(DEFAULT_CURRENCY);
} else if (quoteType === 'FUTURE') { } else if (quoteType === 'FUTURE') {
// Allow GC=F, but not MGC=F // Allow GC=F, but not MGC=F
return symbol.length === 4; return symbol.length === 4;
@ -373,13 +368,13 @@ export class YahooFinanceService implements DataProviderInterface {
symbol: string; symbol: string;
value: number; value: number;
}) { }) {
if (symbol === `${this.baseCurrency}GBp`) { if (symbol === `${DEFAULT_CURRENCY}GBp`) {
// Convert GPB to GBp (pence) // Convert GPB to GBp (pence)
return new Big(value).mul(100).toNumber(); return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ILA`) { } else if (symbol === `${DEFAULT_CURRENCY}ILA`) {
// Convert ILS to ILA // Convert ILS to ILA
return new Big(value).mul(100).toNumber(); return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ZAc`) { } else if (symbol === `${DEFAULT_CURRENCY}ZAc`) {
// Convert ZAR to ZAc (cents) // Convert ZAR to ZAc (cents)
return new Big(value).mul(100).toNumber(); return new Big(value).mul(100).toNumber();
} }

View File

@ -1,10 +1,12 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
PROPERTY_CURRENCIES
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format, isToday } from 'date-fns'; import { format, isToday } from 'date-fns';
@ -12,13 +14,11 @@ import { isNumber, uniq } from 'lodash';
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
private baseCurrency: string;
private currencies: string[] = []; private currencies: string[] = [];
private currencyPairs: IDataGatheringItem[] = []; private currencyPairs: IDataGatheringItem[] = [];
private exchangeRates: { [currencyPair: string]: number } = {}; private exchangeRates: { [currencyPair: string]: number } = {};
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -26,7 +26,7 @@ export class ExchangeRateDataService {
) {} ) {}
public getCurrencies() { public getCurrencies() {
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency]; return this.currencies?.length > 0 ? this.currencies : [DEFAULT_CURRENCY];
} }
public getCurrencyPairs() { public getCurrencyPairs() {
@ -43,7 +43,6 @@ export class ExchangeRateDataService {
} }
public async initialize() { public async initialize() {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
this.currencies = await this.prepareCurrencies(); this.currencies = await this.prepareCurrencies();
this.currencyPairs = []; this.currencyPairs = [];
this.exchangeRates = {}; this.exchangeRates = {};
@ -113,9 +112,9 @@ export class ExchangeRateDataService {
if (!this.exchangeRates[symbol]) { if (!this.exchangeRates[symbol]) {
// Not found, calculate indirectly via base currency // Not found, calculate indirectly via base currency
this.exchangeRates[symbol] = this.exchangeRates[symbol] =
resultExtended[`${currency1}${this.baseCurrency}`]?.[date] resultExtended[`${currency1}${DEFAULT_CURRENCY}`]?.[date]
?.marketPrice * ?.marketPrice *
resultExtended[`${this.baseCurrency}${currency2}`]?.[date] resultExtended[`${DEFAULT_CURRENCY}${currency2}`]?.[date]
?.marketPrice; ?.marketPrice;
// Calculate the opposite direction // Calculate the opposite direction
@ -144,9 +143,8 @@ export class ExchangeRateDataService {
} else { } else {
// Calculate indirectly via base currency // Calculate indirectly via base currency
const factor1 = const factor1 =
this.exchangeRates[`${aFromCurrency}${this.baseCurrency}`]; this.exchangeRates[`${aFromCurrency}${DEFAULT_CURRENCY}`];
const factor2 = const factor2 = this.exchangeRates[`${DEFAULT_CURRENCY}${aToCurrency}`];
this.exchangeRates[`${this.baseCurrency}${aToCurrency}`];
factor = factor1 * factor2; factor = factor1 * factor2;
@ -204,28 +202,28 @@ export class ExchangeRateDataService {
let marketPriceBaseCurrencyToCurrency: number; let marketPriceBaseCurrencyToCurrency: number;
try { try {
if (this.baseCurrency === aFromCurrency) { if (aFromCurrency === DEFAULT_CURRENCY) {
marketPriceBaseCurrencyFromCurrency = 1; marketPriceBaseCurrencyFromCurrency = 1;
} else { } else {
marketPriceBaseCurrencyFromCurrency = ( marketPriceBaseCurrencyFromCurrency = (
await this.marketDataService.get({ await this.marketDataService.get({
dataSource, dataSource,
date: aDate, date: aDate,
symbol: `${this.baseCurrency}${aFromCurrency}` symbol: `${DEFAULT_CURRENCY}${aFromCurrency}`
}) })
)?.marketPrice; )?.marketPrice;
} }
} catch {} } catch {}
try { try {
if (this.baseCurrency === aToCurrency) { if (aToCurrency === DEFAULT_CURRENCY) {
marketPriceBaseCurrencyToCurrency = 1; marketPriceBaseCurrencyToCurrency = 1;
} else { } else {
marketPriceBaseCurrencyToCurrency = ( marketPriceBaseCurrencyToCurrency = (
await this.marketDataService.get({ await this.marketDataService.get({
dataSource, dataSource,
date: aDate, date: aDate,
symbol: `${this.baseCurrency}${aToCurrency}` symbol: `${DEFAULT_CURRENCY}${aToCurrency}`
}) })
)?.marketPrice; )?.marketPrice;
} }
@ -295,14 +293,14 @@ export class ExchangeRateDataService {
private prepareCurrencyPairs(aCurrencies: string[]) { private prepareCurrencyPairs(aCurrencies: string[]) {
return aCurrencies return aCurrencies
.filter((currency) => { .filter((currency) => {
return currency !== this.baseCurrency; return currency !== DEFAULT_CURRENCY;
}) })
.map((currency) => { .map((currency) => {
return { return {
currency1: this.baseCurrency, currency1: DEFAULT_CURRENCY,
currency2: currency, currency2: currency,
dataSource: this.dataProviderService.getDataSourceForExchangeRates(), dataSource: this.dataProviderService.getDataSourceForExchangeRates(),
symbol: `${this.baseCurrency}${currency}` symbol: `${DEFAULT_CURRENCY}${currency}`
}; };
}); });
} }

View File

@ -3,7 +3,6 @@ import { CleanedEnvAccessors } from 'envalid';
export interface Environment extends CleanedEnvAccessors { export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string; ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string; ALPHA_VANTAGE_API_KEY: string;
BASE_CURRENCY: string;
BETTER_UPTIME_API_KEY: string; BETTER_UPTIME_API_KEY: string;
CACHE_QUOTES_TTL: number; CACHE_QUOTES_TTL: number;
CACHE_TTL: number; CACHE_TTL: number;

View File

@ -63,6 +63,10 @@
"baseHref": "/pt/", "baseHref": "/pt/",
"localize": ["pt"] "localize": ["pt"]
}, },
"development-tr": {
"baseHref": "/tr/",
"localize": ["tr"]
},
"production": { "production": {
"fileReplacements": [ "fileReplacements": [
{ {
@ -165,6 +169,9 @@
"development-pt": { "development-pt": {
"browserTarget": "client:build:development-pt" "browserTarget": "client:build:development-pt"
}, },
"development-tr": {
"browserTarget": "client:build:development-tr"
},
"production": { "production": {
"browserTarget": "client:build:production" "browserTarget": "client:build:production"
} }
@ -182,7 +189,8 @@
"messages.fr.xlf", "messages.fr.xlf",
"messages.it.xlf", "messages.it.xlf",
"messages.nl.xlf", "messages.nl.xlf",
"messages.pt.xlf" "messages.pt.xlf",
"messages.tr.xlf"
] ]
} }
}, },
@ -226,6 +234,10 @@
"pt": { "pt": {
"baseHref": "/pt/", "baseHref": "/pt/",
"translation": "apps/client/src/locales/messages.pt.xlf" "translation": "apps/client/src/locales/messages.pt.xlf"
},
"tr": {
"baseHref": "/tr/",
"translation": "apps/client/src/locales/messages.tr.xlf"
} }
}, },
"sourceLocale": "en" "sourceLocale": "en"

View File

@ -1,37 +1,26 @@
<header> <header>
<gf-header
class="position-fixed w-100"
[currentRoute]="currentRoute"
[info]="info"
[pageTitle]="pageTitle"
[user]="user"
(signOut)="onSignOut()"
></gf-header>
</header>
<main role="main">
<div <div
*ngIf="canCreateAccount || (info?.systemMessage && user)" *ngIf="canCreateAccount || (info?.systemMessage && user)"
class="container info-message-container" class="info-message-container"
> >
<div class="row"> <div class="info-message-inner-container position-fixed w-100">
<div class="col-md-8 offset-md-2 text-center"> <div class="align-items-center d-flex h-100 justify-content-center">
<a <a
*ngIf="canCreateAccount" *ngIf="canCreateAccount"
class="text-center" class="text-center"
[routerLink]="routerLinkRegister" [routerLink]="routerLinkRegister"
> >
<div <div
class="cursor-pointer d-inline-block info-message px-3 py-2" class="cursor-pointer d-inline-block info-message"
(click)="onCreateAccount()" (click)="onCreateAccount()"
> >
<span>You are using the Live Demo.</span> <span i18n>You are using the Live Demo.</span>
<span class="a ml-2">Create Account</span> <span class="a ml-2" i18n>Create Account</span>
</div></a </div></a
> >
<div <div
*ngIf="!canCreateAccount && info?.systemMessage && user" *ngIf="!canCreateAccount && info?.systemMessage && user"
class="cursor-pointer d-inline-block info-message px-3 py-2 text-truncate" class="cursor-pointer d-inline-block info-message text-truncate"
(click)="onShowSystemMessage()" (click)="onShowSystemMessage()"
> >
{{ info.systemMessage }} {{ info.systemMessage }}
@ -40,6 +29,18 @@
</div> </div>
</div> </div>
<gf-header
class="position-fixed w-100"
[currentRoute]="currentRoute"
[hasTabs]="hasTabs"
[info]="info"
[pageTitle]="pageTitle"
[user]="user"
(signOut)="onSignOut()"
></gf-header>
</header>
<main role="main">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
@ -151,6 +152,11 @@
<li> <li>
<a href="../pt" title="Ghostfolio in Português">Português</a> <a href="../pt" title="Ghostfolio in Português">Português</a>
</li> </li>
<!--
<li>
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
</li>
-->
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -4,31 +4,47 @@
display: block; display: block;
min-height: 100vh; min-height: 100vh;
&.has-info-message {
header {
height: calc(2 * var(--mat-toolbar-standard-height));
.info-message-container {
height: var(--mat-toolbar-standard-height);
.info-message-inner-container {
background-color: rgba(var(--palette-primary-500), 1);
height: var(--mat-toolbar-standard-height);
z-index: 999;
.info-message {
color: rgba(var(--palette-foreground-text), 1);
font-size: 80%;
max-width: 100%;
.a {
font-weight: 500;
}
}
}
}
}
main {
min-height: calc(100vh - 2 * var(--mat-toolbar-standard-height));
}
}
footer { footer {
background-color: rgba(var(--palette-foreground-text), 0.05); background-color: rgba(var(--palette-foreground-text), 0.05);
font-size: 90%; font-size: 90%;
} }
header {
height: var(--mat-toolbar-standard-height);
}
main { main {
min-height: 100vh; min-height: calc(100vh - var(--mat-toolbar-standard-height));
padding-top: 5rem;
.info-message-container {
height: 3.5rem;
margin-top: -0.5rem;
.info-message {
background-color: rgba(var(--palette-foreground-text), 0.05);
border-radius: 2rem;
font-size: 80%;
max-width: 100%;
.a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
}
}
}
} }
} }
@ -36,12 +52,4 @@
footer { footer {
background-color: rgba(var(--palette-foreground-text-dark), 0.05); background-color: rgba(var(--palette-foreground-text-dark), 0.05);
} }
main {
.info-message-container {
.info-message {
background-color: rgba(var(--palette-foreground-text-dark), 0.05);
}
}
}
} }

View File

@ -3,6 +3,7 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
HostBinding,
Inject, Inject,
OnDestroy, OnDestroy,
OnInit OnInit
@ -28,14 +29,20 @@ import { UserService } from './services/user/user.service';
styleUrls: ['./app.component.scss'] styleUrls: ['./app.component.scss']
}) })
export class AppComponent implements OnDestroy, OnInit { export class AppComponent implements OnDestroy, OnInit {
@HostBinding('class.has-info-message') get getHasMessage() {
return this.hasInfoMessage;
}
public canCreateAccount: boolean; public canCreateAccount: boolean;
public currentRoute: string; public currentRoute: string;
public currentYear = new Date().getFullYear(); public currentYear = new Date().getFullYear();
public deviceType: string; public deviceType: string;
public hasInfoMessage: boolean;
public hasPermissionForBlog: boolean; public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean; public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasTabs = false;
public info: InfoItem; public info: InfoItem;
public pageTitle: string; public pageTitle: string;
public routerLinkAbout = ['/' + $localize`about`]; public routerLinkAbout = ['/' + $localize`about`];
@ -103,6 +110,14 @@ export class AppComponent implements OnDestroy, OnInit {
const urlSegments = urlSegmentGroup.segments; const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path; this.currentRoute = urlSegments[0].path;
this.hasTabs =
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
this.currentRoute === 'admin' ||
this.currentRoute === 'home' ||
this.currentRoute === 'portfolio' ||
this.currentRoute === 'zen') &&
this.deviceType !== 'mobile';
this.showFooter = this.showFooter =
(this.currentRoute === 'blog' || (this.currentRoute === 'blog' ||
this.currentRoute === this.routerLinkFaq[0].slice(1) || this.currentRoute === this.routerLinkFaq[0].slice(1) ||
@ -140,6 +155,12 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.createUserAccount permissions.createUserAccount
); );
this.hasInfoMessage =
hasPermission(
this.user?.permissions,
permissions.createUserAccount
) || !!this.info.systemMessage;
this.initializeTheme(this.user?.settings.colorScheme); this.initializeTheme(this.user?.settings.colorScheme);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

View File

@ -1,3 +1,15 @@
<div *ngIf="hasPermissionToCreateAccess" class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
Add Access
</a>
</div>
<table class="gf-table w-100" mat-table [dataSource]="dataSource"> <table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="alias"> <ng-container matColumnDef="alias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>

View File

@ -19,6 +19,7 @@ import { Access } from '@ghostfolio/common/interfaces';
}) })
export class AccessTableComponent implements OnChanges, OnInit { export class AccessTableComponent implements OnChanges, OnInit {
@Input() accesses: Access[]; @Input() accesses: Access[];
@Input() hasPermissionToCreateAccess = false;
@Input() showActions: boolean; @Input() showActions: boolean;
@Output() accessDeleted = new EventEmitter<string>(); @Output() accessDeleted = new EventEmitter<string>();

View File

@ -3,13 +3,20 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { AccessTableComponent } from './access-table.component'; import { AccessTableComponent } from './access-table.component';
@NgModule({ @NgModule({
declarations: [AccessTableComponent], declarations: [AccessTableComponent],
exports: [AccessTableComponent], exports: [AccessTableComponent],
imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule], imports: [
CommonModule,
MatButtonModule,
MatMenuModule,
MatTableModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfPortfolioAccessTableModule {} export class GfPortfolioAccessTableModule {}

View File

@ -29,13 +29,13 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./account-detail-dialog.component.scss'] styleUrls: ['./account-detail-dialog.component.scss']
}) })
export class AccountDetailDialog implements OnDestroy, OnInit { export class AccountDetailDialog implements OnDestroy, OnInit {
public accountType: string;
public balance: number; public balance: number;
public currency: string; public currency: string;
public equity: number; public equity: number;
public name: string; public name: string;
public orders: OrderWithAccount[]; public orders: OrderWithAccount[];
public platformName: string; public platformName: string;
public transactionCount: number;
public user: User; public user: User;
public valueInBaseCurrency: number; public valueInBaseCurrency: number;
@ -65,15 +65,14 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe( .subscribe(
({ ({
accountType,
balance, balance,
currency, currency,
name, name,
Platform, Platform,
transactionCount,
value, value,
valueInBaseCurrency valueInBaseCurrency
}) => { }) => {
this.accountType = translate(accountType);
this.balance = balance; this.balance = balance;
this.currency = currency; this.currency = currency;
@ -85,6 +84,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.name = name; this.name = name;
this.platformName = Platform?.name ?? '-'; this.platformName = Platform?.name ?? '-';
this.transactionCount = transactionCount;
this.valueInBaseCurrency = valueInBaseCurrency; this.valueInBaseCurrency = valueInBaseCurrency;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

View File

@ -44,8 +44,8 @@
> >
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="accountType" <gf-value i18n size="medium" [value]="transactionCount"
>Account Type</gf-value >Activities</gf-value
> >
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">

View File

@ -85,7 +85,7 @@
<ng-container matColumnDef="transactions"> <ng-container matColumnDef="transactions">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="px-1 text-right" class="justify-content-end px-1"
mat-header-cell mat-header-cell
mat-sort-header="transactionCount" mat-sort-header="transactionCount"
> >
@ -93,9 +93,7 @@
<span class="d-none d-sm-block" i18n>Activities</span> <span class="d-none d-sm-block" i18n>Activities</span>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
<ng-container *ngIf="element.accountType === 'SECURITIES'">{{ {{ element.transactionCount }}
element.transactionCount
}}</ng-container>
</td> </td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell> <td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
{{ transactionCount }} {{ transactionCount }}

View File

@ -8,7 +8,6 @@ import { Subject } from 'rxjs';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
selector: 'gf-admin-settings', selector: 'gf-admin-settings',
styleUrls: ['./admin-settings.component.scss'], styleUrls: ['./admin-settings.component.scss'],
templateUrl: './admin-settings.component.html' templateUrl: './admin-settings.component.html'

View File

@ -1,14 +1,17 @@
<mat-toolbar class="px-2"> <mat-toolbar class="px-0">
<ng-container *ngIf="user"> <ng-container *ngIf="user">
<a <div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
class="align-items-center d-flex h-100 no-min-width px-2 rounded-0" <a
mat-button class="align-items-center justify-content-start rounded-0"
[routerLink]="['/']" mat-button
> [ngClass]="{ 'w-100': hasTabs }"
<gf-logo [label]="pageTitle"></gf-logo> [routerLink]="['/']"
</a> >
<gf-logo class="px-2" [label]="pageTitle"></gf-logo>
</a>
</div>
<span class="spacer"></span> <span class="spacer"></span>
<ul class="alig-items-center d-flex list-inline m-0"> <ul class="alig-items-center d-flex list-inline m-0 px-2">
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
@ -26,7 +29,7 @@
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -39,7 +42,7 @@
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -52,7 +55,7 @@
</li> </li>
<li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item"> <li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -65,7 +68,7 @@
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -83,7 +86,7 @@
class="list-inline-item" class="list-inline-item"
> >
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -96,7 +99,7 @@
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -129,33 +132,37 @@
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container *ngIf="user?.access?.length > 0"> <ng-container *ngIf="user?.access?.length > 0">
<button mat-menu-item (click)="impersonateAccount(null)"> <button mat-menu-item (click)="impersonateAccount(null)">
<ion-icon <span class="align-items-center d-flex">
*ngIf="user?.access?.length > 0" <ion-icon
class="mr-2" *ngIf="user?.access?.length > 0"
[name]=" class="mr-2"
impersonationId [name]="
? 'radio-button-off-outline' impersonationId
: 'radio-button-on-outline' ? 'radio-button-off-outline'
" : 'radio-button-on-outline'
></ion-icon> "
<span i18n>Me</span> ></ion-icon>
<span i18n>Me</span>
</span>
</button> </button>
<button <button
*ngFor="let accessItem of user?.access" *ngFor="let accessItem of user?.access"
mat-menu-item mat-menu-item
(click)="impersonateAccount(accessItem.id)" (click)="impersonateAccount(accessItem.id)"
> >
<ion-icon <span class="align-items-center d-flex">
class="mr-2" <ion-icon
name="square-outline" class="mr-2"
[name]=" name="square-outline"
accessItem.id === impersonationId [name]="
? 'radio-button-on-outline' accessItem.id === impersonationId
: 'radio-button-off-outline' ? 'radio-button-on-outline'
" : 'radio-button-off-outline'
></ion-icon> "
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span> ></ion-icon>
<span *ngIf="!accessItem.alias" i18n>User</span> <span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
<span *ngIf="!accessItem.alias" i18n>User</span>
</span>
</button> </button>
<hr class="m-0" /> <hr class="m-0" />
</ng-container> </ng-container>
@ -242,21 +249,25 @@
</ul> </ul>
</ng-container> </ng-container>
<ng-container *ngIf="user === null"> <ng-container *ngIf="user === null">
<a <div class="d-flex h-100 logo-container" [ngClass]="{ filled: hasTabs }">
class="align-items-center d-flex h-100 mx-2 no-min-width px-2 rounded-0" <a
mat-button class="align-items-center justify-content-start rounded-0"
[routerLink]="['/']" mat-button
> [ngClass]="{ 'w-100': hasTabs }"
<gf-logo [routerLink]="['/']"
[label]="pageTitle" >
[showLabel]="currentRoute !== 'register'" <gf-logo
></gf-logo> class="px-2"
</a> [label]="pageTitle"
[showLabel]="currentRoute !== 'register'"
></gf-logo>
</a>
</div>
<span class="spacer"></span> <span class="spacer"></span>
<ul class="alig-items-center d-flex list-inline m-0"> <ul class="alig-items-center d-flex list-inline m-0 px-2">
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -269,7 +280,7 @@
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -282,6 +293,7 @@
</li> </li>
<li *ngIf="hasPermissionForSubscription" class="list-inline-item"> <li *ngIf="hasPermissionForSubscription" class="list-inline-item">
<a <a
class="d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -297,7 +309,7 @@
class="list-inline-item" class="list-inline-item"
> >
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -317,13 +329,13 @@
></a> ></a>
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<button class="mx-1" mat-flat-button (click)="openLoginDialog()"> <button class="d-sm-block" mat-flat-button (click)="openLoginDialog()">
<ng-container i18n>Sign in</ng-container> <ng-container i18n>Sign in</ng-container>
</button> </button>
</li> </li>
<li <li
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser" *ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
class="list-inline-item" class="list-inline-item ml-1"
> >
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"

View File

@ -7,8 +7,18 @@
.mat-toolbar { .mat-toolbar {
background-color: var(--light-background); background-color: var(--light-background);
.spacer { .logo-container {
flex: 1 1 auto; &.filled {
background-color: rgba(var(--palette-foreground-base), 0.02);
}
@media (min-width: 576px) {
width: 14rem;
}
}
.list-inline-item {
margin: 0;
} }
.mdc-button { .mdc-button {
@ -24,11 +34,21 @@
font-size: 1.5rem; font-size: 1.5rem;
} }
} }
.spacer {
flex: 1 1 auto;
}
} }
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
.mat-toolbar { .mat-toolbar {
background-color: var(--dark-background); background-color: var(--dark-background);
.logo-container {
&.filled {
background-color: rgba(var(--palette-foreground-base-dark), 0.02);
}
}
} }
} }

View File

@ -29,6 +29,7 @@ import { catchError, takeUntil } from 'rxjs/operators';
}) })
export class HeaderComponent implements OnChanges { export class HeaderComponent implements OnChanges {
@Input() currentRoute: string; @Input() currentRoute: string;
@Input() hasTabs: boolean;
@Input() info: InfoItem; @Input() info: InfoItem;
@Input() pageTitle: string; @Input() pageTitle: string;
@Input() user: User; @Input() user: User;

View File

@ -5,6 +5,15 @@
<gf-value class="justify-content-end" [value]="timeInMarket"></gf-value> <gf-value class="justify-content-end" [value]="timeInMarket"></gf-value>
</div> </div>
</div> </div>
<div
class="flex-nowrap px-3 py-1 row"
[hidden]="summary?.ordersCount === null"
>
<div class="flex-grow-1 ml-3 text-truncate" i18n>
{{ summary?.ordersCount }} {summary?.ordersCount, plural, =1 {transaction}
other {transactions}}
</div>
</div>
<div class="row"> <div class="row">
<div class="col"><hr /></div> <div class="col"><hr /></div>
</div> </div>
@ -75,10 +84,7 @@
</div> </div>
</div> </div>
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n> <div class="flex-grow-1 text-truncate" i18n>Fees</div>
Fees for {{ summary?.ordersCount }} {summary?.ordersCount, plural, =1
{transaction} other {transactions}}
</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span> <span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span>
<gf-value <gf-value
@ -270,6 +276,18 @@
<div class="row"> <div class="row">
<div class="col"><hr /></div> <div class="col"><hr /></div>
</div> </div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Interest</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.interest"
></gf-value>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Dividend</div> <div class="flex-grow-1 text-truncate" i18n>Dividend</div>
<div class="justify-content-end"> <div class="justify-content-end">

View File

@ -0,0 +1,28 @@
import { Directive, HostListener, Output, EventEmitter } from '@angular/core';
@Directive({
selector: '[gfFileDrop]'
})
export class FileDropDirective {
@Output() filesDropped = new EventEmitter<FileList>();
@HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
}
@HostListener('dragover', ['$event']) onDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
}
@HostListener('drop', ['$event']) onDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
// Prevent the browser's default behavior for handling the file drop
event.dataTransfer.dropEffect = 'copy';
this.filesDropped.emit(event.dataTransfer.files);
}
}

View File

@ -0,0 +1,9 @@
import { NgModule } from '@angular/core';
import { FileDropDirective } from './file-drop.directive';
@NgModule({
declarations: [FileDropDirective],
exports: [FileDropDirective]
})
export class GfFileDropModule {}

View File

@ -1,28 +1,20 @@
import { import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
ChangeDetectorRef,
Component,
HostBinding,
OnDestroy,
OnInit
} from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page has-tabs' },
selector: 'gf-about-page', selector: 'gf-about-page',
styleUrls: ['./about-page.scss'], styleUrls: ['./about-page.scss'],
templateUrl: './about-page.html' templateUrl: './about-page.html'
}) })
export class AboutPageComponent implements OnDestroy, OnInit { export class AboutPageComponent implements OnDestroy, OnInit {
@HostBinding('class.with-info-message') get getHasMessage() { public deviceType: string;
return this.hasMessage;
}
public hasMessage: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public tabs: TabConfiguration[] = []; public tabs: TabConfiguration[] = [];
public user: User; public user: User;
@ -32,9 +24,10 @@ export class AboutPageComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService,
private userService: UserService private userService: UserService
) { ) {
const { globalPermissions, systemMessage } = this.dataService.fetchInfo(); const { globalPermissions } = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission( this.hasPermissionForSubscription = hasPermission(
globalPermissions, globalPermissions,
@ -71,12 +64,6 @@ export class AboutPageComponent implements OnDestroy, OnInit {
}); });
this.user = state.user; this.user = state.user;
this.hasMessage =
hasPermission(
this.user?.permissions,
permissions.createUserAccount
) || !!systemMessage;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
@ -88,7 +75,9 @@ export class AboutPageComponent implements OnDestroy, OnInit {
}); });
} }
public ngOnInit() {} public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();

View File

@ -2,7 +2,12 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
</mat-tab-nav-panel> </mat-tab-nav-panel>
<nav mat-align-tabs="center" mat-tab-nav-bar [tabPanel]="tabPanel"> <nav
mat-align-tabs="center"
mat-tab-nav-bar
[disablePagination]="true"
[tabPanel]="tabPanel"
>
<ng-container *ngFor="let tab of tabs"> <ng-container *ngFor="let tab of tabs">
<a <a
#rla="routerLinkActive" #rla="routerLinkActive"
@ -14,7 +19,10 @@
[routerLink]="tab.path" [routerLink]="tab.path"
[routerLinkActiveOptions]="{ exact: true }" [routerLinkActiveOptions]="{ exact: true }"
> >
<ion-icon size="large" [name]="tab.iconName"></ion-icon> <ion-icon
[name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large': 'small'"
></ion-icon>
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div> <div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a> </a>
</ng-container> </ng-container>

View File

@ -2,27 +2,6 @@
:host { :host {
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: flex;
flex-direction: column;
height: calc(100vh - 5rem);
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: constant(safe-area-inset-bottom);
::ng-deep {
.mat-mdc-tab-link-container {
--mat-tab-header-active-focus-indicator-color: transparent;
--mat-tab-header-active-hover-indicator-color: transparent;
--mdc-tab-indicator-active-indicator-color: transparent;
.mat-mdc-tab-link {
&:hover {
opacity: 0.75;
}
}
}
}
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {

View File

@ -2,7 +2,6 @@ import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@Component({ @Component({
host: { class: 'page' },
selector: 'gf-changelog-page', selector: 'gf-changelog-page',
styleUrls: ['./changelog-page.scss'], styleUrls: ['./changelog-page.scss'],
templateUrl: './changelog-page.html' templateUrl: './changelog-page.html'

View File

@ -2,7 +2,6 @@ import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@Component({ @Component({
host: { class: 'page' },
selector: 'gf-license-page', selector: 'gf-license-page',
styleUrls: ['./license-page.scss'], styleUrls: ['./license-page.scss'],
templateUrl: './license-page.html' templateUrl: './license-page.html'

View File

@ -4,7 +4,6 @@ import { Subject } from 'rxjs';
const ossFriends = require('../../../../assets/oss-friends.json'); const ossFriends = require('../../../../assets/oss-friends.json');
@Component({ @Component({
host: { class: 'page' },
selector: 'gf-oss-friends-page', selector: 'gf-oss-friends-page',
styleUrls: ['./oss-friends-page.scss'], styleUrls: ['./oss-friends-page.scss'],
templateUrl: './oss-friends-page.html' templateUrl: './oss-friends-page.html'

View File

@ -8,7 +8,6 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page' },
selector: 'gf-about-overview-page', selector: 'gf-about-overview-page',
styleUrls: ['./about-overview-page.scss'], styleUrls: ['./about-overview-page.scss'],
templateUrl: './about-overview-page.html' templateUrl: './about-overview-page.html'

View File

@ -2,7 +2,6 @@ import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@Component({ @Component({
host: { class: 'page' },
selector: 'gf-privacy-policy-page', selector: 'gf-privacy-policy-page',
styleUrls: ['./privacy-policy-page.scss'], styleUrls: ['./privacy-policy-page.scss'],
templateUrl: './privacy-policy-page.html' templateUrl: './privacy-policy-page.html'

View File

@ -10,7 +10,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Account as AccountModel, AccountType } from '@prisma/client'; import { Account as AccountModel } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -151,7 +151,6 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
} }
public openUpdateAccountDialog({ public openUpdateAccountDialog({
accountType,
balance, balance,
comment, comment,
currency, currency,
@ -163,7 +162,6 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, { const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, {
data: { data: {
account: { account: {
accountType,
balance, balance,
comment, comment,
currency, currency,
@ -232,7 +230,6 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, { const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, {
data: { data: {
account: { account: {
accountType: AccountType.SECURITIES,
balance: 0, balance: 0,
comment: null, comment: null,
currency: this.user?.settings?.baseCurrency, currency: this.user?.settings?.baseCurrency,

View File

@ -8,15 +8,6 @@
<input matInput name="name" required [(ngModel)]="data.account.name" /> <input matInput name="name" required [(ngModel)]="data.account.name" />
</mat-form-field> </mat-form-field>
</div> </div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.account.accountType">
<mat-option i18n value="CASH">Cash</mat-option>
<mat-option i18n value="SECURITIES">Securities</mat-option>
</mat-select>
</mat-form-field>
</div>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label> <mat-label i18n>Currency</mat-label>

View File

@ -1,30 +1,25 @@
import { Component, HostBinding, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TabConfiguration } from '@ghostfolio/common/interfaces'; import { TabConfiguration } from '@ghostfolio/common/interfaces';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@Component({ @Component({
host: { class: 'page has-tabs' },
selector: 'gf-admin-page', selector: 'gf-admin-page',
styleUrls: ['./admin-page.scss'], styleUrls: ['./admin-page.scss'],
templateUrl: './admin-page.html' templateUrl: './admin-page.html'
}) })
export class AdminPageComponent implements OnDestroy, OnInit { export class AdminPageComponent implements OnDestroy, OnInit {
@HostBinding('class.with-info-message') get getHasMessage() { public deviceType: string;
return this.hasMessage;
}
public hasMessage: boolean;
public tabs: TabConfiguration[] = []; public tabs: TabConfiguration[] = [];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor(private dataService: DataService) { public constructor(private deviceService: DeviceDetectorService) {}
const { systemMessage } = this.dataService.fetchInfo();
this.hasMessage = !!systemMessage;
}
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.tabs = [ this.tabs = [
{ {
iconName: 'reader-outline', iconName: 'reader-outline',

View File

@ -2,7 +2,12 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
</mat-tab-nav-panel> </mat-tab-nav-panel>
<nav mat-align-tabs="center" mat-tab-nav-bar [tabPanel]="tabPanel"> <nav
mat-align-tabs="center"
mat-tab-nav-bar
[disablePagination]="true"
[tabPanel]="tabPanel"
>
<ng-container *ngFor="let tab of tabs"> <ng-container *ngFor="let tab of tabs">
<a <a
#rla="routerLinkActive" #rla="routerLinkActive"
@ -14,7 +19,10 @@
[routerLink]="tab.path" [routerLink]="tab.path"
[routerLinkActiveOptions]="{ exact: true }" [routerLinkActiveOptions]="{ exact: true }"
> >
<ion-icon size="large" [name]="tab.iconName"></ion-icon> <ion-icon
[name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large': 'small'"
></ion-icon>
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div> <div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a> </a>
</ng-container> </ng-container>

View File

@ -2,27 +2,6 @@
:host { :host {
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: flex;
flex-direction: column;
height: calc(100vh - 5rem);
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: constant(safe-area-inset-bottom);
::ng-deep {
.mat-mdc-tab-link-container {
--mat-tab-header-active-focus-indicator-color: transparent;
--mat-tab-header-active-hover-indicator-color: transparent;
--mdc-tab-indicator-active-indicator-color: transparent;
.mat-mdc-tab-link {
&:hover {
opacity: 0.75;
}
}
}
}
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {

View File

@ -0,0 +1,17 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-ghostfolio-2-page',
standalone: true,
templateUrl: './ghostfolio-2-page.html'
})
export class Ghostfolio2PageComponent {
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkAboutChangelog = ['/' + $localize`about`, 'changelog'];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkMarkets = ['/' + $localize`markets`];
}

View File

@ -0,0 +1,286 @@
<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 2.0</h1>
<div class="mb-3 text-muted"><small>2023-09-09</small></div>
<img
alt="Ghostfolio 2.0 Teaser"
class="border rounded w-100"
src="../assets/images/blog/ghostfolio-2.jpg"
title="Announcing Ghostfolio 2.0"
/>
</div>
<section class="mb-4">
<p>
Since late 2020, when
<a [routerLink]="routerLinkAbout">Ghostfolio</a> took shape, the
main goal has remained the same: to simplify investment tracking
while prioritizing user privacy and enable investors to make
informed decisions. Our journey so far has been incredible, with
over 300 releases since the
<a href="../en/blog/2021/07/hello-ghostfolio">first major release</a
>, close to 300000 pulls on Docker Hub, and collaboration with 50+
contributors from around the globe. Ghostfolio was recently featured
on
<a
href="https://news.ycombinator.com/item?id=37337482"
target="_blank"
>Hacker News</a
>, where it ranked on the front page and briefly hit the #1 spot.
Shortly after, the projects repository was trending on GitHub.
These achievements emphasize the growing recognition for our project
and the path we are on.
</p>
<p>
Now, we are thrilled to present Ghostfolio 2.0, another milestone in
our journey to empower investors.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Introducing Ghostfolio 2.0</h2>
<p>
Ghostfolio 2.0 is the evolution of our
<a [routerLink]="routerLinkFeatures"
>open source wealth management software</a
>, elevating both user and developer experiences. We have extended
data import capabilities, added comprehensive analytics, increased
stability, and utilized the latest technology to deliver these
improvements. Here is a closer look at a selection of the
improvements you can expect from this
<a [routerLink]="routerLinkAboutChangelog">release</a>, alongside
uncounted smaller additions and enhancements.
</p>
<h3 class="h5">Extended Data Import Capabilities</h3>
<p>
Importing account activities is an important aspect of any portfolio
management software. With Ghostfolio 2.0, we have extended our data
import functionality, ensuring a seamless experience for users. Our
system supports multiple formats to make the experience more
seamless. Additionally, there is an API, providing you with even
greater flexibility and control over how you import transactions.
</p>
<h3 class="h5">Comprehensive Analytics</h3>
<p>
Understanding your wealth is key. The latest release offers more
comprehensive analytics to categorize your securities, providing
invaluable insights into your investment portfolio for making
informed decisions and optimizing diversification.
</p>
<h3 class="h5">Reliable Stability</h3>
<p>
Ensuring the stability of software is crucial, and a platform for
managing your wealth is no exception. The increased robustness of
our architecture means that you can count on Ghostfolio to be there
when you need it most, no matter the
<a [routerLink]="routerLinkMarkets">market conditions</a>.
</p>
<h3 class="h5">Cutting-Edge Technology Stack</h3>
<p>
Ghostfolio 2.0 leverages the latest tech stack to deliver a superior
user and developer experience. We have upgraded to
<a href="https://angular.io" target="_blank">Angular 16</a>,
<a href="https://nestjs.com" target="_blank">Nest.js 10</a>,
<a href="https://www.prisma.io" target="_blank">Prisma 5</a>, and
<a href="https://nx.dev" target="_blank">Nx 16</a>, ensuring that
the software is at the forefront of innovation. This upgrade allows
us to provide you with the best possible user experience, making
investment tracking more intuitive than ever before.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Thriving Ghostfolio Community</h2>
<p>
In open source software (OSS) projects like Ghostfolio, the
community is the driving force behind its success. Without the
incredible support of our users and contributors, it would not have
been possible. As we celebrate the launch of Ghostfolio 2.0, we are
delighted to showcase the growth of the Ghostfolio community:
</p>
<ul>
<li>
Ghostfolio has accumulated <strong>2500+ stars</strong> on
<a
href="https://github.com/ghostfolio/ghostfolio"
target="_blank"
title="Find Ghostfolio on GitHub"
>GitHub</a
>, highlighting the appreciation and adoption of our platform by
the community.
</li>
<li>
Our
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
title="Join the Ghostfolio Slack community"
>Slack</a
>
community has expanded to over <strong>350 members</strong>,
creating a space for like-minded investors to connect, share
insights, and collaborate.
</li>
<li>
On
<a href="https://twitter.com/ghostfolio_" target="_blank">X</a>
(formerly Twitter), over
<strong>300 investors and personal finance enthusiasts</strong>
follow Ghostfolio, keen to stay updated on the latest
developments.
</li>
</ul>
<p>
This is just the beginning. Ghostfolio is dedicated to ongoing
improvement and helping grow a vibrant community of investors. We
invite you to join us on this exciting journey.
</p>
<p>
<strong>Join our Slack community</strong>: Connect with fellow
investors, share your insights, and stay updated on the latest news
by joining our
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
title="Join the Ghostfolio Slack community"
>Slack</a
>
community. It is a dynamic space where you can learn, collaborate,
and grow together with us.
</p>
<p>
<strong>Follow us on X</strong>: For release updates and market
insights, follow
<a href="https://twitter.com/ghostfolio_" target="_blank"
>Ghostfolio on X</a
>. It is the perfect place to stay informed and connect with our
team.
</p>
<p>
<strong>Give us a Star</strong>: If you have found value in
Ghostfolio or appreciate our commitment to simplifying investment
tracking, please consider giving us a star on
<a
href="https://github.com/ghostfolio/ghostfolio"
target="_blank"
title="Find Ghostfolio on GitHub"
>GitHub</a
>. Your support helps us reach a wider audience and make a
difference in the world of wealth management.
</p>
<p>
<strong>Become a contributor</strong>: If you are a developer
passionate about open source projects and personal finance, we
welcome your contributions.
<a href="https://github.com/ghostfolio/ghostfolio" target="_blank"
>Join our developer community</a
>, collaborate with like-minded people, and help shape the future of
Ghostfolio.
</p>
</section>
<section>
<p>
Ghostfolio 2.0 represents a major step forward in our mission to
empower investors, and we could not be more excited about the future
of the project. Together, we can build an outstanding tool that
makes our lives easier. Thank you for being a part of the Ghostfolio
community.
</p>
<p>Thomas from Ghostfolio</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Angular</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Announcement</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Collaboration</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Contribution</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Evolution</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">Ghostfolio 2.0</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Nest.js</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Nx</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">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">Platform</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">Prisma</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">Release</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">Stack</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Technology</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web3</span>
</li>
</ul>
</section>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Announcing Ghostfolio 2.0
</li>
</ol>
</nav>
</article>
</div>
</div>
</div>

View File

@ -145,6 +145,15 @@ const routes: Routes = [
'./2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component' './2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component'
).then((c) => c.GhostfolioJoinsOssFriendsPageComponent), ).then((c) => c.GhostfolioJoinsOssFriendsPageComponent),
title: 'Ghostfolio joins OSS Friends' title: 'Ghostfolio joins OSS Friends'
},
{
canActivate: [AuthGuard],
path: '2023/09/ghostfolio-2',
loadComponent: () =>
import('./2023/09/ghostfolio-2/ghostfolio-2-page.component').then(
(c) => c.Ghostfolio2PageComponent
),
title: 'Ghostfolio 2.0'
} }
]; ];

View File

@ -8,6 +8,32 @@
finance</small finance</small
> >
</h1> </h1>
<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/09/ghostfolio-2"
>
<div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">
Announcing Ghostfolio 2.0
</div>
<div class="d-flex text-muted">2023-09-09</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-content> <mat-card-content>
<div class="container p-0"> <div class="container p-0">

View File

@ -142,11 +142,11 @@
> >
<mat-card-content <mat-card-content
><a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> is a fully ><a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> is a fully
managed Ghostfolio cloud offering for ambitious investors. The revenue managed Ghostfolio cloud offering for ambitious investors. Revenue is
is used to cover the hosting infrastructure and to fund the ongoing used to cover the costs of the hosting infrastructure and to fund
development. It is the Open Source code base with some extras like the ongoing development. It is the Open Source code base with some extras
<a [routerLink]="routerLinkMarkets">markets overview</a> and a like the <a [routerLink]="routerLinkMarkets">markets overview</a> and
professional data provider.</mat-card-content a professional data provider.</mat-card-content
> >
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">

View File

@ -245,7 +245,7 @@
<h4 i18n>Multi-Language</h4> <h4 i18n>Multi-Language</h4>
<p class="m-0"> <p class="m-0">
Use Ghostfolio in multiple languages: English, Dutch, French, Use Ghostfolio in multiple languages: English, Dutch, French,
German, Italian, Portuguese and Spanish are currently German, Italian, Portuguese, Spanish and Turkish are currently
supported. supported.
</p> </p>
</div> </div>

View File

@ -1,28 +1,20 @@
import { import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
ChangeDetectorRef,
Component,
HostBinding,
OnDestroy,
OnInit
} from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page has-tabs' },
selector: 'gf-home-page', selector: 'gf-home-page',
styleUrls: ['./home-page.scss'], styleUrls: ['./home-page.scss'],
templateUrl: './home-page.html' templateUrl: './home-page.html'
}) })
export class HomePageComponent implements OnDestroy, OnInit { export class HomePageComponent implements OnDestroy, OnInit {
@HostBinding('class.with-info-message') get getHasMessage() { public deviceType: string;
return this.hasMessage;
}
public hasMessage: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public tabs: TabConfiguration[] = []; public tabs: TabConfiguration[] = [];
public user: User; public user: User;
@ -32,9 +24,10 @@ export class HomePageComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService,
private userService: UserService private userService: UserService
) { ) {
const { globalPermissions, systemMessage } = this.dataService.fetchInfo(); const { globalPermissions } = this.dataService.fetchInfo();
this.hasPermissionToAccessFearAndGreedIndex = hasPermission( this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
globalPermissions, globalPermissions,
@ -70,18 +63,14 @@ export class HomePageComponent implements OnDestroy, OnInit {
]; ];
this.user = state.user; this.user = state.user;
this.hasMessage =
hasPermission(
this.user?.permissions,
permissions.createUserAccount
) || !!systemMessage;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
} }
public ngOnInit() {} public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();

View File

@ -2,7 +2,12 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
</mat-tab-nav-panel> </mat-tab-nav-panel>
<nav mat-align-tabs="center" mat-tab-nav-bar [tabPanel]="tabPanel"> <nav
mat-align-tabs="center"
mat-tab-nav-bar
[disablePagination]="true"
[tabPanel]="tabPanel"
>
<ng-container *ngFor="let tab of tabs"> <ng-container *ngFor="let tab of tabs">
<a <a
#rla="routerLinkActive" #rla="routerLinkActive"
@ -14,7 +19,10 @@
[routerLink]="tab.path" [routerLink]="tab.path"
[routerLinkActiveOptions]="{ exact: true }" [routerLinkActiveOptions]="{ exact: true }"
> >
<ion-icon size="large" [name]="tab.iconName"></ion-icon> <ion-icon
[name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large': 'small'"
></ion-icon>
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div> <div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a> </a>
</ng-container> </ng-container>

View File

@ -2,27 +2,6 @@
:host { :host {
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: flex;
flex-direction: column;
height: calc(100vh - 5rem);
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: constant(safe-area-inset-bottom);
::ng-deep {
.mat-mdc-tab-link-container {
--mat-tab-header-active-focus-indicator-color: transparent;
--mat-tab-header-active-hover-indicator-color: transparent;
--mdc-tab-indicator-active-indicator-color: transparent;
.mat-mdc-tab-link {
&:hover {
opacity: 0.75;
}
}
}
}
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {

View File

@ -1,9 +1,17 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col text-center"> <div class="col text-center">
<h1 class="font-weight-bold intro mt-5" i18n> <div>
Manage your wealth like a boss <div class="badge badge-light badge-pill border mb-3 px-3 py-2">
</h1> <a href="../en/blog/2023/09/ghostfolio-2"
><span class="mr-1 text-uppercase" i18n>New</span>
<span class="font-weight-normal">Ghostfolio 2.0</span></a
>
</div>
<h1 class="font-weight-bold intro" i18n>
Manage your wealth like a boss
</h1>
</div>
<p class="lead mb-4" i18n> <p class="lead mb-4" i18n>
Ghostfolio is a privacy-first, open source dashboard for your personal Ghostfolio is a privacy-first, open source dashboard for your personal
finances. Break down your asset allocation, know your net worth and make finances. Break down your asset allocation, know your net worth and make
@ -52,7 +60,7 @@
<div *ngIf="hasPermissionForStatistics" class="row mb-5"> <div *ngIf="hasPermissionForStatistics" class="row mb-5">
<div <div
class="col-md-4 d-flex my-1" class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }" [ngClass]="{ 'justify-content-center': deviceType !== 'mobile' }"
> >
<a <a
class="d-block" class="d-block"
@ -70,7 +78,7 @@
</div> </div>
<div <div
class="col-md-4 d-flex my-1" class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }" [ngClass]="{ 'justify-content-center': deviceType !== 'mobile' }"
> >
<a <a
class="d-block" class="d-block"
@ -88,7 +96,7 @@
</div> </div>
<div <div
class="col-md-4 d-flex my-1" class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }" [ngClass]="{ 'justify-content-center': deviceType !== 'mobile' }"
> >
<a <a
class="d-block" class="d-block"
@ -134,6 +142,14 @@
title="DEV Community - A constructive and inclusive social network for software developers" title="DEV Community - A constructive and inclusive social network for software developers"
></a> ></a>
</div> </div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-hacker-news mask"
href="https://news.ycombinator.com"
target="_blank"
title="Hacker News"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1"> <div class="col-md-3 d-flex justify-content-center my-1">
<a <a
class="d-block logo logo-openstartup" class="d-block logo logo-openstartup"

View File

@ -53,6 +53,10 @@
mask-image: url('/assets/images/logo-dev-community.svg'); mask-image: url('/assets/images/logo-dev-community.svg');
} }
&.logo-hacker-news {
mask-image: url('/assets/images/logo-hacker-news.svg');
}
&.logo-openstartup { &.logo-openstartup {
background-image: url('/assets/images/logo-openstartup.png'); background-image: url('/assets/images/logo-openstartup.png');
background-position: center; background-position: center;
@ -128,6 +132,7 @@
&.logo-agplv3, &.logo-agplv3,
&.logo-alternative-to, &.logo-alternative-to,
&.logo-dev-community, &.logo-dev-community,
&.logo-hacker-news,
&.logo-privacy-tools, &.logo-privacy-tools,
&.logo-reddit, &.logo-reddit,
&.logo-sackgeld, &.logo-sackgeld,

View File

@ -32,6 +32,7 @@
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<gf-value <gf-value
i18n i18n
i18n-subLabel
size="large" size="large"
subLabel="(Last 24 hours)" subLabel="(Last 24 hours)"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
@ -42,6 +43,7 @@
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<gf-value <gf-value
i18n i18n
i18n-subLabel
size="large" size="large"
subLabel="(Last 30 days)" subLabel="(Last 30 days)"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
@ -52,6 +54,7 @@
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<gf-value <gf-value
i18n i18n
i18n-subLabel
size="large" size="large"
subLabel="(Last 30 days)" subLabel="(Last 30 days)"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
@ -119,6 +122,7 @@
<a class="d-block" href="https://status.ghostfol.io"> <a class="d-block" href="https://status.ghostfol.io">
<gf-value <gf-value
i18n i18n
i18n-subLabel
size="large" size="large"
subLabel="(Last 90 days)" subLabel="(Last 90 days)"
[isPercent]="true" [isPercent]="true"

View File

@ -24,7 +24,6 @@ import { ImportActivitiesDialog } from './import-activities-dialog/import-activi
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces'; import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
@Component({ @Component({
host: { class: 'page' },
selector: 'gf-activities-page', selector: 'gf-activities-page',
styleUrls: ['./activities-page.scss'], styleUrls: ['./activities-page.scss'],
templateUrl: './activities-page.html' templateUrl: './activities-page.html'
@ -238,9 +237,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, { const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, {
data: { data: {
activity, activity,
accounts: this.user?.accounts?.filter((account) => { accounts: this.user?.accounts,
return account.accountType === 'SECURITIES';
}),
user: this.user user: this.user
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
@ -282,9 +279,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, { const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, {
data: { data: {
accounts: this.user?.accounts?.filter((account) => { accounts: this.user?.accounts,
return account.accountType === 'SECURITIES';
}),
activity: { activity: {
...aActivity, ...aActivity,
accountId: aActivity?.accountId ?? this.defaultAccountId, accountId: aActivity?.accountId ?? this.defaultAccountId,

View File

@ -20,7 +20,7 @@ import { translate } from '@ghostfolio/ui/i18n';
import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client'; import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { EMPTY, Observable, Subject, lastValueFrom, of } from 'rxjs'; import { EMPTY, Observable, Subject, lastValueFrom, of } from 'rxjs';
import { catchError, map, startWith, takeUntil } from 'rxjs/operators'; import { catchError, delay, map, startWith, takeUntil } from 'rxjs/operators';
import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces'; import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces';
@ -139,7 +139,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
}); });
this.activityForm.valueChanges this.activityForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(
// Slightly delay until the more specific form control value changes have
// completed
delay(300),
takeUntil(this.unsubscribeSubject)
)
.subscribe(async () => { .subscribe(async () => {
let exchangeRateOfFee = 1; let exchangeRateOfFee = 1;
let exchangeRateOfUnitPrice = 1; let exchangeRateOfUnitPrice = 1;
@ -217,6 +222,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
if ( if (
this.activityForm.controls['type'].value === 'BUY' || this.activityForm.controls['type'].value === 'BUY' ||
this.activityForm.controls['type'].value === 'FEE' ||
this.activityForm.controls['type'].value === 'ITEM' this.activityForm.controls['type'].value === 'ITEM'
) { ) {
this.total = this.total =
@ -233,6 +239,28 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.activityForm.controls['accountId'].valueChanges.subscribe(
(accountId) => {
const type = this.activityForm.controls['type'].value;
if (
type === 'FEE' ||
type === 'INTEREST' ||
type === 'ITEM' ||
type === 'LIABILITY'
) {
const currency =
this.data.accounts.find(({ id }) => {
return id === accountId;
})?.currency ?? this.data.user.settings.baseCurrency;
this.activityForm.controls['currency'].setValue(currency);
this.activityForm.controls['currencyOfFee'].setValue(currency);
this.activityForm.controls['currencyOfUnitPrice'].setValue(currency);
}
}
);
this.activityForm.controls['searchSymbol'].valueChanges.subscribe(() => { this.activityForm.controls['searchSymbol'].valueChanges.subscribe(() => {
if (this.activityForm.controls['searchSymbol'].invalid) { if (this.activityForm.controls['searchSymbol'].invalid) {
this.data.activity.SymbolProfile = null; this.data.activity.SymbolProfile = null;
@ -268,19 +296,21 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
Validators.required Validators.required
); );
this.activityForm.controls['accountId'].updateValueAndValidity(); this.activityForm.controls['accountId'].updateValueAndValidity();
this.activityForm.controls['currency'].setValue(
this.data.user.settings.baseCurrency const currency =
); this.data.accounts.find(({ id }) => {
this.activityForm.controls['currencyOfFee'].setValue( return id === this.activityForm.controls['accountId'].value;
this.data.user.settings.baseCurrency })?.currency ?? this.data.user.settings.baseCurrency;
);
this.activityForm.controls['currencyOfUnitPrice'].setValue( this.activityForm.controls['currency'].setValue(currency);
this.data.user.settings.baseCurrency this.activityForm.controls['currencyOfFee'].setValue(currency);
); this.activityForm.controls['currencyOfUnitPrice'].setValue(currency);
this.activityForm.controls['dataSource'].removeValidators( this.activityForm.controls['dataSource'].removeValidators(
Validators.required Validators.required
); );
this.activityForm.controls['dataSource'].updateValueAndValidity(); this.activityForm.controls['dataSource'].updateValueAndValidity();
this.activityForm.controls['feeInCustomCurrency'].reset();
this.activityForm.controls['name'].setValidators(Validators.required); this.activityForm.controls['name'].setValidators(Validators.required);
this.activityForm.controls['name'].updateValueAndValidity(); this.activityForm.controls['name'].updateValueAndValidity();
this.activityForm.controls['quantity'].setValue(1); this.activityForm.controls['quantity'].setValue(1);
@ -290,31 +320,57 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['searchSymbol'].updateValueAndValidity(); this.activityForm.controls['searchSymbol'].updateValueAndValidity();
this.activityForm.controls['updateAccountBalance'].disable(); this.activityForm.controls['updateAccountBalance'].disable();
this.activityForm.controls['updateAccountBalance'].setValue(false); this.activityForm.controls['updateAccountBalance'].setValue(false);
} else if (type === 'LIABILITY') { } else if (
type === 'FEE' ||
type === 'INTEREST' ||
type === 'LIABILITY'
) {
this.activityForm.controls['accountId'].removeValidators( this.activityForm.controls['accountId'].removeValidators(
Validators.required Validators.required
); );
this.activityForm.controls['accountId'].updateValueAndValidity(); this.activityForm.controls['accountId'].updateValueAndValidity();
this.activityForm.controls['currency'].setValue(
this.data.user.settings.baseCurrency const currency =
); this.data.accounts.find(({ id }) => {
this.activityForm.controls['currencyOfFee'].setValue( return id === this.activityForm.controls['accountId'].value;
this.data.user.settings.baseCurrency })?.currency ?? this.data.user.settings.baseCurrency;
);
this.activityForm.controls['currencyOfUnitPrice'].setValue( this.activityForm.controls['currency'].setValue(currency);
this.data.user.settings.baseCurrency this.activityForm.controls['currencyOfFee'].setValue(currency);
); this.activityForm.controls['currencyOfUnitPrice'].setValue(currency);
this.activityForm.controls['dataSource'].removeValidators( this.activityForm.controls['dataSource'].removeValidators(
Validators.required Validators.required
); );
this.activityForm.controls['dataSource'].updateValueAndValidity(); this.activityForm.controls['dataSource'].updateValueAndValidity();
if (
(type === 'FEE' &&
this.activityForm.controls['feeInCustomCurrency'].value === 0) ||
type === 'INTEREST' ||
type === 'LIABILITY'
) {
this.activityForm.controls['feeInCustomCurrency'].reset();
}
this.activityForm.controls['name'].setValidators(Validators.required); this.activityForm.controls['name'].setValidators(Validators.required);
this.activityForm.controls['name'].updateValueAndValidity(); this.activityForm.controls['name'].updateValueAndValidity();
this.activityForm.controls['quantity'].setValue(1);
if (type === 'FEE') {
this.activityForm.controls['quantity'].setValue(0);
} else if (type === 'INTEREST' || type === 'LIABILITY') {
this.activityForm.controls['quantity'].setValue(1);
}
this.activityForm.controls['searchSymbol'].removeValidators( this.activityForm.controls['searchSymbol'].removeValidators(
Validators.required Validators.required
); );
this.activityForm.controls['searchSymbol'].updateValueAndValidity(); this.activityForm.controls['searchSymbol'].updateValueAndValidity();
if (type === 'FEE') {
this.activityForm.controls['unitPriceInCustomCurrency'].setValue(0);
}
this.activityForm.controls['updateAccountBalance'].disable(); this.activityForm.controls['updateAccountBalance'].disable();
this.activityForm.controls['updateAccountBalance'].setValue(false); this.activityForm.controls['updateAccountBalance'].setValue(false);
} else { } else {

View File

@ -15,34 +15,51 @@
>{{ typesTranslationMap[activityForm.controls['type'].value] >{{ typesTranslationMap[activityForm.controls['type'].value]
}}</mat-select-trigger }}</mat-select-trigger
> >
<mat-option class="line-height-1" value="BUY"> <mat-option value="BUY">
<span><b>{{ typesTranslationMap['BUY'] }}</b></span> <span><b>{{ typesTranslationMap['BUY'] }}</b></span>
<br /> <small class="d-block line-height-1 text-muted text-nowrap" i18n
<small class="text-muted text-nowrap" i18n
>Stocks, ETFs, bonds, cryptocurrencies, commodities</small >Stocks, ETFs, bonds, cryptocurrencies, commodities</small
> >
</mat-option> </mat-option>
<mat-option class="line-height-1" value="DIVIDEND"> <mat-option
<span><b>{{ typesTranslationMap['DIVIDEND'] }}</b></span> *ngIf="data.user?.settings?.isExperimentalFeatures"
value="FEE"
>
<span><b>{{ typesTranslationMap['FEE'] }}</b></span>
<small class="d-block line-height-1 text-muted text-nowrap" i18n
>One-time fee, annual account fees</small
>
</mat-option> </mat-option>
<mat-option class="line-height-1" value="LIABILITY"> <mat-option value="DIVIDEND">
<span><b>{{ typesTranslationMap['DIVIDEND'] }}</b></span>
<small class="d-block line-height-1 text-muted text-nowrap" i18n
>Distribution of corporate earnings</small
>
</mat-option>
<mat-option
*ngIf="data.user?.settings?.isExperimentalFeatures"
value="INTEREST"
>
<span><b>{{ typesTranslationMap['INTEREST'] }}</b></span>
<small class="d-block line-height-1 text-muted text-nowrap" i18n
>Revenue for lending out money</small
>
</mat-option>
<mat-option value="LIABILITY">
<span><b>{{ typesTranslationMap['LIABILITY'] }}</b></span> <span><b>{{ typesTranslationMap['LIABILITY'] }}</b></span>
<br /> <small class="d-block line-height-1 text-muted text-nowrap" i18n
<small class="text-muted text-nowrap" i18n
>Mortgages, personal loans, credit cards</small >Mortgages, personal loans, credit cards</small
> >
</mat-option> </mat-option>
<mat-option class="line-height-1" value="SELL"> <mat-option value="SELL">
<span><b>{{ typesTranslationMap['SELL'] }}</b></span> <span><b>{{ typesTranslationMap['SELL'] }}</b></span>
<br /> <small class="d-block line-height-1 text-muted text-nowrap" i18n
<small class="text-muted text-nowrap" i18n
>Stocks, ETFs, bonds, cryptocurrencies, commodities</small >Stocks, ETFs, bonds, cryptocurrencies, commodities</small
> >
</mat-option> </mat-option>
<mat-option class="line-height-1" value="ITEM"> <mat-option value="ITEM">
<span><b>{{ typesTranslationMap['ITEM'] }}</b></span> <span><b>{{ typesTranslationMap['ITEM'] }}</b></span>
<br /> <small class="d-block line-height-1 text-muted text-nowrap" i18n
<small class="text-muted text-nowrap" i18n
>Luxury items, real estate, private companies</small >Luxury items, real estate, private companies</small
> >
</mat-option> </mat-option>
@ -125,60 +142,72 @@
</div> </div>
<div <div
class="mb-3" class="mb-3"
[ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'ITEM' || activityForm.controls['type']?.value === 'LIABILITY' }" [ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'FEE' || activityForm.controls['type']?.value === 'INTEREST' || activityForm.controls['type']?.value === 'ITEM' || activityForm.controls['type']?.value === 'LIABILITY' }"
> >
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Quantity</mat-label> <mat-label i18n>Quantity</mat-label>
<input formControlName="quantity" matInput type="number" /> <input formControlName="quantity" matInput type="number" />
</mat-form-field> </mat-form-field>
</div> </div>
<div class="align-items-start d-flex mb-3"> <div
<mat-form-field appearance="outline" class="w-100"> class="mb-3"
<mat-label [ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'FEE' }"
><ng-container [ngSwitch]="activityForm.controls['type']?.value"> >
<ng-container *ngSwitchCase="'DIVIDEND'" i18n <div class="align-items-start d-flex">
>Dividend</ng-container <mat-form-field appearance="outline" class="w-100">
> <mat-label
<ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container> ><ng-container [ngSwitch]="activityForm.controls['type']?.value">
<ng-container *ngSwitchCase="'LIABILITY'" i18n>Value</ng-container> <ng-container *ngSwitchCase="'DIVIDEND'" i18n
<ng-container *ngSwitchDefault i18n>Unit Price</ng-container> >Dividend</ng-container
</ng-container> >
</mat-label> <ng-container *ngSwitchCase="'INTEREST'" i18n>Value</ng-container>
<input <ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container>
formControlName="unitPriceInCustomCurrency" <ng-container *ngSwitchCase="'LIABILITY'" i18n
matInput >Value</ng-container
type="number" >
/> <ng-container *ngSwitchDefault i18n>Unit Price</ng-container>
<div </ng-container>
class="ml-2" </mat-label>
matTextSuffix <input
[ngClass]="{ 'd-none': !activityForm.controls['currency']?.value }" formControlName="unitPriceInCustomCurrency"
> matInput
<mat-select formControlName="currencyOfUnitPrice"> type="number"
<mat-option *ngFor="let currency of currencies" [value]="currency"> />
{{ currency }} <div
</mat-option> class="ml-2"
</mat-select> matTextSuffix
</div> [ngClass]="{ 'd-none': !activityForm.controls['currency']?.value }"
<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-select formControlName="currencyOfUnitPrice">
}}</mat-error <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="ml-2 mt-1 no-min-width"
mat-button
title="Apply current market price"
type="button"
(click)="applyCurrentMarketPrice()"
> >
</mat-form-field> <ion-icon class="text-muted" name="refresh-outline"></ion-icon>
<button </button>
*ngIf="currentMarketPrice && (data.activity.type === 'BUY' || data.activity.type === 'SELL')" </div>
class="ml-2 mt-1 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="d-none"> <div class="d-none">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
@ -187,6 +216,8 @@
<ng-container *ngSwitchCase="'DIVIDEND'" i18n <ng-container *ngSwitchCase="'DIVIDEND'" i18n
>Dividend</ng-container >Dividend</ng-container
> >
<ng-container *ngSwitchCase="'FEE'" i18n>Value</ng-container>
<ng-container *ngSwitchCase="'INTEREST'" i18n>Value</ng-container>
<ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container> <ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container>
<ng-container *ngSwitchCase="'LIABILITY'" i18n>Value</ng-container> <ng-container *ngSwitchCase="'LIABILITY'" i18n>Value</ng-container>
<ng-container *ngSwitchDefault i18n>Unit Price</ng-container> <ng-container *ngSwitchDefault i18n>Unit Price</ng-container>
@ -200,7 +231,7 @@
</div> </div>
<div <div
class="mb-3" class="mb-3"
[ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'ITEM' || activityForm.controls['type']?.value === 'LIABILITY' }" [ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'INTEREST' || activityForm.controls['type']?.value === 'ITEM' || activityForm.controls['type']?.value === 'LIABILITY' }"
> >
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label> <mat-label i18n>Fee</mat-label>

View File

@ -137,6 +137,20 @@ export class ImportActivitiesDialog implements OnDestroy {
} }
} }
public onFilesDropped({
files,
stepper
}: {
files: FileList;
stepper: MatStepper;
}): void {
if (files.length === 0) {
return;
}
this.handleFile({ stepper, file: files[0] });
}
public onImportStepChange(event: StepperSelectionEvent) { public onImportStepChange(event: StepperSelectionEvent) {
if (event.selectedIndex === ImportStep.UPLOAD_FILE) { if (event.selectedIndex === ImportStep.UPLOAD_FILE) {
this.importStep = ImportStep.UPLOAD_FILE; this.importStep = ImportStep.UPLOAD_FILE;
@ -175,97 +189,15 @@ export class ImportActivitiesDialog implements OnDestroy {
aStepper.reset(); aStepper.reset();
} }
public onSelectFile(aStepper: MatStepper) { public onSelectFile(stepper: MatStepper) {
const input = document.createElement('input'); const input = document.createElement('input');
input.accept = 'application/JSON, .csv'; input.accept = 'application/JSON, .csv';
input.type = 'file'; input.type = 'file';
input.onchange = (event) => { input.onchange = (event) => {
this.snackBar.open('⏳ ' + $localize`Validating data...`);
// Getting the file reference // Getting the file reference
const file = (event.target as HTMLInputElement).files[0]; const file = (event.target as HTMLInputElement).files[0];
this.handleFile({ file, stepper });
// Setting up the reader
const reader = new FileReader();
reader.readAsText(file, 'UTF-8');
reader.onload = async (readerEvent) => {
const fileContent = readerEvent.target.result as string;
try {
if (file.name.endsWith('.json')) {
const content = JSON.parse(fileContent);
this.accounts = content.accounts;
if (!isArray(content.activities)) {
if (isArray(content.orders)) {
this.handleImportError({
activities: [],
error: {
error: {
message: [`orders needs to be renamed to activities`]
}
}
});
return;
} else {
throw new Error();
}
}
try {
const { activities } =
await this.importActivitiesService.importJson({
accounts: content.accounts,
activities: content.activities,
isDryRun: true
});
this.activities = activities;
} catch (error) {
console.error(error);
this.handleImportError({ error, activities: content.activities });
}
return;
} else if (file.name.endsWith('.csv')) {
try {
const data = await this.importActivitiesService.importCsv({
fileContent,
isDryRun: true,
userAccounts: this.data.user.accounts
});
this.activities = data.activities;
} catch (error) {
console.error(error);
this.handleImportError({
activities: error?.activities ?? [],
error: {
error: { message: error?.error?.message ?? [error?.message] }
}
});
}
return;
}
throw new Error();
} catch (error) {
console.error(error);
this.handleImportError({
activities: [],
error: { error: { message: ['Unexpected format'] } }
});
} finally {
this.importStep = ImportStep.SELECT_ACTIVITIES;
this.snackBar.dismiss();
aStepper.next();
this.changeDetectorRef.markForCheck();
}
};
}; };
input.click(); input.click();
@ -282,6 +214,97 @@ export class ImportActivitiesDialog implements OnDestroy {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private async handleFile({
file,
stepper
}: {
file: File;
stepper: MatStepper;
}): Promise<void> {
this.snackBar.open('⏳ ' + $localize`Validating data...`);
// Setting up the reader
const reader = new FileReader();
reader.readAsText(file, 'UTF-8');
reader.onload = async (readerEvent) => {
const fileContent = readerEvent.target.result as string;
try {
if (file.name.endsWith('.json')) {
const content = JSON.parse(fileContent);
this.accounts = content.accounts;
if (!isArray(content.activities)) {
if (isArray(content.orders)) {
this.handleImportError({
activities: [],
error: {
error: {
message: [`orders needs to be renamed to activities`]
}
}
});
return;
} else {
throw new Error();
}
}
try {
const { activities } =
await this.importActivitiesService.importJson({
accounts: content.accounts,
activities: content.activities,
isDryRun: true
});
this.activities = activities;
} catch (error) {
console.error(error);
this.handleImportError({ error, activities: content.activities });
}
return;
} else if (file.name.endsWith('.csv')) {
try {
const data = await this.importActivitiesService.importCsv({
fileContent,
isDryRun: true,
userAccounts: this.data.user.accounts
});
this.activities = data.activities;
} catch (error) {
console.error(error);
this.handleImportError({
activities: error?.activities ?? [],
error: {
error: { message: error?.error?.message ?? [error?.message] }
}
});
}
return;
}
throw new Error();
} catch (error) {
console.error(error);
this.handleImportError({
activities: [],
error: { error: { message: ['Unexpected format'] } }
});
} finally {
this.importStep = ImportStep.SELECT_ACTIVITIES;
this.snackBar.dismiss();
stepper.next();
this.changeDetectorRef.markForCheck();
}
};
}
private handleImportError({ private handleImportError({
activities, activities,
error error

View File

@ -70,29 +70,38 @@
<ng-template #selectFile> <ng-template #selectFile>
<div class="d-flex flex-column justify-content-center"> <div class="d-flex flex-column justify-content-center">
<button <button
class="py-4" class="drop-area p-4 text-center text-muted"
color="primary" gfFileDrop
mat-stroked-button
(click)="onSelectFile(stepper)" (click)="onSelectFile(stepper)"
(filesDropped)="onFilesDropped({stepper, files: $event})"
> >
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon> <div
<span i18n>Choose File</span> class="align-items-center d-flex flex-column justify-content-center"
>
<ion-icon
class="cloud-icon"
name="cloud-upload-outline"
></ion-icon>
<span i18n>Choose or drop a file here</span>
</div>
</button> </button>
<p class="mb-0 mt-4 text-center"> <p class="mb-0 mt-3 text-center">
<span class="mr-1" i18n <small>
>The following file formats are supported:</span <span class="mr-1" i18n
> >The following file formats are supported:</span
<a >
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv" <a
target="_blank" href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.csv"
>CSV</a target="_blank"
> >CSV</a
<span class="mx-1" i18n>or</span> >
<a <span class="mx-1" i18n>or</span>
href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.json" <a
target="_blank" href="https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok.json"
>JSON</a target="_blank"
> >JSON</a
>
</small>
</p> </p>
</div> </div>
</ng-template> </ng-template>
@ -109,7 +118,7 @@
> >
</ng-template> </ng-template>
<div class="pt-3"> <div class="pt-3">
<ng-container *ngIf="errorMessages.length === 0; else errorMessage"> <ng-container *ngIf="errorMessages?.length === 0; else errorMessage">
<gf-activities-table <gf-activities-table
*ngIf="importStep === 1" *ngIf="importStep === 1"
[activities]="activities" [activities]="activities"

View File

@ -10,6 +10,7 @@ import { MatSelectModule } from '@angular/material/select';
import { MatStepperModule } from '@angular/material/stepper'; import { MatStepperModule } from '@angular/material/stepper';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfFileDropModule } from '@ghostfolio/client/directives/file-drop/file-drop.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module'; import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
@ -23,6 +24,7 @@ import { ImportActivitiesDialog } from './import-activities-dialog.component';
GfActivitiesTableModule, GfActivitiesTableModule,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,
GfFileDropModule,
GfSymbolModule, GfSymbolModule,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,

View File

@ -32,4 +32,32 @@
right: 1.5rem; right: 1.5rem;
top: calc(50% - 10px); top: calc(50% - 10px);
} }
.drop-area {
background-color: rgba(var(--palette-foreground-base), 0.02);
border: 1px dashed
rgba(
var(--palette-foreground-divider),
var(--palette-foreground-divider-alpha)
);
border-radius: 0.25rem;
&:hover {
border-color: rgba(var(--palette-primary-500), 1) !important;
color: rgba(var(--palette-primary-500), 1);
}
.cloud-icon {
font-size: 2.5rem;
}
}
}
:host-context(.is-dark-theme) {
.drop-area {
border-color: rgba(
var(--palette-foreground-divider-dark),
var(--palette-foreground-divider-alpha-dark)
);
}
} }

View File

@ -27,7 +27,6 @@ import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page' },
selector: 'gf-allocations-page', selector: 'gf-allocations-page',
styleUrls: ['./allocations-page.scss'], styleUrls: ['./allocations-page.scss'],
templateUrl: './allocations-page.html' templateUrl: './allocations-page.html'
@ -173,17 +172,15 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
const accountFilters: Filter[] = this.user.accounts const accountFilters: Filter[] = this.user.accounts.map(
.filter(({ accountType }) => { ({ id, name }) => {
return accountType === 'SECURITIES';
})
.map(({ id, name }) => {
return { return {
id, id,
label: name, label: name,
type: 'ACCOUNT' type: 'ACCOUNT'
}; };
}); }
);
const assetClassFilters: Filter[] = []; const assetClassFilters: Filter[] = [];
for (const assetClass of Object.keys(AssetClass)) { for (const assetClass of Object.keys(AssetClass)) {

View File

@ -26,7 +26,6 @@ import { Subject } from 'rxjs';
import { distinctUntilChanged, map, takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, map, takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page' },
selector: 'gf-analysis-page', selector: 'gf-analysis-page',
styleUrls: ['./analysis-page.scss'], styleUrls: ['./analysis-page.scss'],
templateUrl: './analysis-page.html' templateUrl: './analysis-page.html'
@ -139,17 +138,15 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
const accountFilters: Filter[] = this.user.accounts const accountFilters: Filter[] = this.user.accounts.map(
.filter(({ accountType }) => { ({ id, name }) => {
return accountType === 'SECURITIES';
})
.map(({ id, name }) => {
return { return {
id, id,
label: name, label: name,
type: 'ACCOUNT' type: 'ACCOUNT'
}; };
}); }
);
const assetClassFilters: Filter[] = []; const assetClassFilters: Filter[] = [];
for (const assetClass of Object.keys(AssetClass)) { for (const assetClass of Object.keys(AssetClass)) {

View File

@ -10,7 +10,6 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page' },
selector: 'gf-fire-page', selector: 'gf-fire-page',
styleUrls: ['./fire-page.scss'], styleUrls: ['./fire-page.scss'],
templateUrl: './fire-page.html' templateUrl: './fire-page.html'

View File

@ -20,7 +20,6 @@ import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page' },
selector: 'gf-holdings-page', selector: 'gf-holdings-page',
styleUrls: ['./holdings-page.scss'], styleUrls: ['./holdings-page.scss'],
templateUrl: './holdings-page.html' templateUrl: './holdings-page.html'
@ -114,17 +113,15 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
permissions.createOrder permissions.createOrder
); );
const accountFilters: Filter[] = this.user.accounts const accountFilters: Filter[] = this.user.accounts.map(
.filter(({ accountType }) => { ({ id, name }) => {
return accountType === 'SECURITIES';
})
.map(({ id, name }) => {
return { return {
id, id,
label: name, label: name,
type: 'ACCOUNT' type: 'ACCOUNT'
}; };
}); }
);
const assetClassFilters: Filter[] = []; const assetClassFilters: Filter[] = [];
for (const assetClass of Object.keys(AssetClass)) { for (const assetClass of Object.keys(AssetClass)) {

View File

@ -1,33 +1,18 @@
import { import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
ChangeDetectorRef,
Component,
HostBinding,
OnDestroy,
OnInit
} from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
InfoItem, import { DeviceDetectorService } from 'ngx-device-detector';
TabConfiguration,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page has-tabs' },
selector: 'gf-portfolio-page', selector: 'gf-portfolio-page',
styleUrls: ['./portfolio-page.scss'], styleUrls: ['./portfolio-page.scss'],
templateUrl: './portfolio-page.html' templateUrl: './portfolio-page.html'
}) })
export class PortfolioPageComponent implements OnDestroy, OnInit { export class PortfolioPageComponent implements OnDestroy, OnInit {
@HostBinding('class.with-info-message') get getHasMessage() { public deviceType: string;
return this.hasMessage;
}
public hasMessage: boolean;
public info: InfoItem;
public tabs: TabConfiguration[] = []; public tabs: TabConfiguration[] = [];
public user: User; public user: User;
@ -35,11 +20,9 @@ export class PortfolioPageComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private deviceService: DeviceDetectorService,
private userService: UserService private userService: UserService
) { ) {
this.info = this.dataService.fetchInfo();
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
@ -73,18 +56,14 @@ export class PortfolioPageComponent implements OnDestroy, OnInit {
]; ];
this.user = state.user; this.user = state.user;
this.hasMessage =
hasPermission(
this.user?.permissions,
permissions.createUserAccount
) || !!this.info.systemMessage;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
} }
public ngOnInit() {} public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();

View File

@ -2,7 +2,12 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
</mat-tab-nav-panel> </mat-tab-nav-panel>
<nav mat-align-tabs="center" mat-tab-nav-bar [tabPanel]="tabPanel"> <nav
mat-align-tabs="center"
mat-tab-nav-bar
[disablePagination]="true"
[tabPanel]="tabPanel"
>
<ng-container *ngFor="let tab of tabs"> <ng-container *ngFor="let tab of tabs">
<a <a
#rla="routerLinkActive" #rla="routerLinkActive"
@ -14,7 +19,10 @@
[routerLink]="tab.path" [routerLink]="tab.path"
[routerLinkActiveOptions]="{ exact: true }" [routerLinkActiveOptions]="{ exact: true }"
> >
<ion-icon size="large" [name]="tab.iconName"></ion-icon> <ion-icon
[name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large': 'small'"
></ion-icon>
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div> <div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a> </a>
</ng-container> </ng-container>

View File

@ -2,27 +2,6 @@
:host { :host {
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: flex;
flex-direction: column;
height: calc(100vh - 5rem);
overflow-y: auto;
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: constant(safe-area-inset-bottom);
::ng-deep {
.mat-mdc-tab-link-container {
--mat-tab-header-active-focus-indicator-color: transparent;
--mat-tab-header-active-hover-indicator-color: transparent;
--mdc-tab-indicator-active-indicator-color: transparent;
.mat-mdc-tab-link {
&:hover {
opacity: 0.75;
}
}
}
}
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {

View File

@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { StripeService } from 'ngx-stripe'; import { StripeService } from 'ngx-stripe';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -17,6 +18,7 @@ export class PricingPageComponent implements OnDestroy, OnInit {
public baseCurrency: string; public baseCurrency: string;
public coupon: number; public coupon: number;
public couponId: string; public couponId: string;
public hasPermissionToUpdateUserSettings: boolean;
public importAndExportTooltipBasic = translate( public importAndExportTooltipBasic = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC' 'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC'
); );
@ -55,6 +57,11 @@ export class PricingPageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.coupon = subscriptions?.[this.user?.subscription?.offer]?.coupon; this.coupon = subscriptions?.[this.user?.subscription?.offer]?.coupon;
this.couponId = this.couponId =
subscriptions?.[this.user.subscription.offer]?.couponId; subscriptions?.[this.user.subscription.offer]?.couponId;

View File

@ -6,8 +6,8 @@
<p i18n> <p i18n>
Our official Ghostfolio Premium cloud offering is the easiest way to Our official Ghostfolio Premium cloud offering is the easiest way to
get started. Due to the time it saves, this will be the best option get started. Due to the time it saves, this will be the best option
for most people. The revenue is used to cover the hosting for most people. Revenue is used to cover the costs of the hosting
infrastructure and to fund the ongoing development of Ghostfolio. infrastructure and to fund ongoing development.
</p> </p>
<p *ngIf="user?.subscription?.type === 'Basic'"> <p *ngIf="user?.subscription?.type === 'Basic'">
If you plan to open an account at <i>DEGIRO</i>, <i>frankly</i>, If you plan to open an account at <i>DEGIRO</i>, <i>frankly</i>,
@ -329,11 +329,11 @@
>{{ baseCurrency }}&nbsp;<strong >{{ baseCurrency }}&nbsp;<strong
>{{ price }}</strong >{{ price }}</strong
></ng-container ></ng-container
>&nbsp;<span>per year</span></span >&nbsp;<span i18n>per year</span></span
> >
</p> </p>
<div <div
*ngIf="user?.subscription?.type === 'Basic'" *ngIf="hasPermissionToUpdateUserSettings && user?.subscription?.type === 'Basic'"
class="mt-3 text-center" class="mt-3 text-center"
> >
<button color="primary" mat-flat-button (click)="onCheckout()"> <button color="primary" mat-flat-button (click)="onCheckout()">

View File

@ -4,7 +4,6 @@ import { Router } from '@angular/router';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service'; import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { InfoItem, LineChartItem } from '@ghostfolio/common/interfaces'; import { InfoItem, LineChartItem } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
@ -36,8 +35,7 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
private dialog: MatDialog, private dialog: MatDialog,
private internetIdentityService: InternetIdentityService, private internetIdentityService: InternetIdentityService,
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService, private tokenStorageService: TokenStorageService
private userService: UserService
) { ) {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();

View File

@ -28,6 +28,7 @@
<ng-container *ngIf="hasPermissionForSocialLogin"> <ng-container *ngIf="hasPermissionForSocialLogin">
<div class="my-3 text-muted" i18n>or</div> <div class="my-3 text-muted" i18n>or</div>
<button <button
*ngIf="false"
class="d-block mb-2 px-4 rounded-pill" class="d-block mb-2 px-4 rounded-pill"
mat-stroked-button mat-stroked-button
(click)="onLoginWithInternetIdentity()" (click)="onLoginWithInternetIdentity()"

View File

@ -19,7 +19,7 @@ const routes: Routes = [
.map(({ component, key, name }) => { .map(({ component, key, name }) => {
return { return {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: `open-source-alternative-to-${key}`, path: $localize`open-source-alternative-to` + `-${key}`,
loadComponent: () => loadComponent: () =>
import(`./products/${key}-page.component`).then(() => component), import(`./products/${key}-page.component`).then(() => component),
title: $localize`Open Source Alternative to ${name}` title: $localize`Open Source Alternative to ${name}`

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