Compare commits

..

155 Commits

Author SHA1 Message Date
60b2115e3b Release 2.13.0 (#2514) 2023-10-20 08:24:49 +02:00
e7956943ba Make holdings request only once (#2453)
* Make holdings request only once

* Update changelog
2023-10-20 08:21:23 +02:00
f66edf8de0 Add membership card component (#2507)
* Add membership card component

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-20 08:18:34 +02:00
29028a81f5 Add i18n service to query XML files (#2503)
* Add i18n service to query XML files

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-19 17:13:40 +02:00
c9878c9050 Migrated users table in Admin Control to mat-table (#2469)
* Migrated users table in Admin Control to mat-table

* Update changelog
2023-10-19 16:58:01 +02:00
73ac4b4197 Add chart to account detail dialog (#2502)
* Add chart to account detail dialog

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-19 15:51:31 +02:00
016634a77f Feature/setup i18n page (#2508)
* Setup i18n page

* Add meta description
2023-10-18 17:35:07 +02:00
ea65dc5034 Release 2.12.0 (#2505) 2023-10-17 20:52:02 +02:00
84db54babd Change checkboxes to slide toggles on user settings page (#2497)
* Change checkboxes to slide toggles on user settings page

* Update changelog
2023-10-17 20:49:54 +02:00
653c9c62a8 Sort imports (#2490) 2023-10-17 20:42:32 +02:00
74278073b3 Bugfix/fix query to get asset profiles matching data source and symbol (#2504)
* Match dataSource and symbol

* Update changelog
2023-10-17 20:36:01 +02:00
0375b938a2 Add confirmation dialog (#2501) 2023-10-17 20:14:46 +02:00
32df7620d9 Add support for creating asset profiles with MANUAL data source (#2479)
* Add support for creating asset profiles with MANUAL data source

* Refactoring

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-17 18:33:22 +02:00
8492a8fed0 Upgrade simplewebauthn (#2498)
* Upgrade simplewebauthn to new major version

* Update changelog
2023-10-17 09:17:44 +02:00
30e561c06f Feature/extend assistant with search for asset profile (#2499)
* Extend assistant with search for asset profile

* Extend search results by currency, symbol and asset sub class

* Update changelog
2023-10-16 21:54:36 +02:00
7243090c0e Feature/copy client locales to assets of server (#2493)
* Copy client locales to server’s assets

* Update changelog
2023-10-15 18:08:44 +02:00
7ae49eb839 Add endpoint for account balances (#2484)
* Add endpoint for account balances

* Update changelog

---------

Co-authored-by: Pavol Kolcun <pavol.kolcun@student.tuke.sk>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-15 17:09:47 +02:00
bf816c3b89 Bugfix/show transfer balance button based on permission (#2489)
* Show button depending on permission

* Update changelog
2023-10-15 10:02:55 +02:00
20f9225daa Release 2.11.0 (#2488) 2023-10-14 19:12:37 +02:00
b6101c6375 Feature/import historical data (#2448)
* Import historical data for an asset

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-14 19:06:27 +02:00
e1022846b9 Bugfix/fix displayed currency of cash balance in account dialog (#2480)
* Fix currency

* Update changelog
2023-10-14 15:01:29 +02:00
9ba79f6721 Improve style (#2478) 2023-10-13 22:11:05 +02:00
0ac97bd112 Transfer cash balance between accounts (#2455)
* Transfer cash balance between accounts

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-13 21:46:49 +02:00
827270704a Bugfix/fix import for activities of type fee and interest (#2474)
* Fix import for activities of type fee and interest

* Update changelog
2023-10-13 19:51:02 +02:00
8634463597 Feature/upgrade prisma to version 5.4.2 (#2477)
* Upgrade prisma to version 5.4.2

* Update changelog
2023-10-13 19:50:06 +02:00
3905782ad6 Fix fab-container (#2476) 2023-10-13 19:49:30 +02:00
5db984ffef Add date column to benchmark component (#2466)
* Add date column to benchmark component
2023-10-12 10:21:00 +02:00
fb3cd4b689 Remove icon (#2467) 2023-10-11 09:58:18 +02:00
3b5a34f6f3 Use fab-button in access management tab (#2456)
* Use fab-button in access management tab

* Refactor fab container

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-11 09:57:35 +02:00
22b43b5bfc Feature/extract locales 20231010 (#2462)
* Update locales

* Update changelog
2023-10-10 20:17:45 +02:00
6c66033eb4 Add date to markets overview by benchmarks (#2436)
* Add date

* Update changelog
2023-10-10 17:31:53 +02:00
162fc25e23 Release 2.10.0 (#2459) 2023-10-09 20:31:34 +02:00
45f385a483 Feature/improve symbol conversion in eod historical data service (#2457)
* Improve conversion of currency symbols

* Update changelog
2023-10-09 20:29:56 +02:00
e9ef911548 Feature/improve search results display in assistant (#2458)
* Only show search results if search is active

* Update changelog
2023-10-09 20:28:39 +02:00
d8d4d8f001 Change jobs table in admin control to mat-table (#2444)
* Change jobs table in admin control to mat-table

* Update changelog
2023-10-09 19:38:33 +02:00
f47c7313af Support enter key press to submit access dialog form (#2437)
* Support enter key press to submit access dialog form

* Update changelog
2023-10-09 19:11:09 +02:00
31f0056a2d Release 2.9.0 (#2454) 2023-10-08 20:34:39 +02:00
550e646079 Feature/introduce assistant (#2451)
* Introduce assistant

* Update changelog
2023-10-08 20:32:00 +02:00
37ff7acf04 Change platform control from select to autocomplete in account dialog (#2429)
* Change platform control from select to autocomplete in account dialog

* Update changelog
2023-10-08 19:17:55 +02:00
8236091477 Feature/add support for search query in portfolio position endpoint (#2443)
* Introduce search query filter

* Update changelog
2023-10-07 19:30:28 +02:00
2a71cb66de Use port numbers from environment variables in docker-compose.dev.yml (#2406) 2023-10-07 17:18:13 +02:00
e60fe48fdd Add dialog for cash transfer between accounts (#2433)
* Add dialog for cash transfer between accounts

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-07 14:46:13 +02:00
d40bc5070a Feature/remove permission to markets overview on home page (#2441)
* Remove show condition for markets overview

* Update changelog
2023-10-07 09:15:54 +02:00
fda4e0ea7d Use application version from API endpoint in Admin Control panel (#2427)
* Use application version from API endpoint

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-07 09:11:32 +02:00
08d696ce33 Align ok.json with ok.csv (#2439) 2023-10-07 08:44:51 +02:00
46614a7c24 Create carousel component for testimonials (#2394)
* Create carousel component for testimonials

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-06 22:12:09 +02:00
02b433eb1e Prevent empty form submission in account dialog (#2428)
* Prevent empty form submission in account dialog
2023-10-05 21:02:35 +02:00
25112a450b Add support for comment in csv import (#2416)
* Add support for comment in csv import (activities)

* Update changelog
2023-10-05 20:42:35 +02:00
727340748b Clean up imports (#2411) 2023-10-05 20:40:31 +02:00
8ad6492477 Feature/various improvements in client (#2434)
* Various improvements

* Update changelog
2023-10-05 20:31:00 +02:00
4af76f6f6d Fix hasNotDefinedValuesInObject() in object.helper.ts (#2421) 2023-10-05 20:29:34 +02:00
10940214a5 Update OSS Friends (#2431) 2023-10-05 20:27:39 +02:00
d9a6c22e1e Add application version to admin endpoint (#2423)
* Add application version to admin endpoint

* Update changelog
2023-10-04 16:15:08 +02:00
692309988c Add Prisma Studio (#2415) 2023-10-04 08:48:50 +02:00
42a54263f9 Release 2.8.0 (#2420) 2023-10-03 20:10:29 +02:00
4fb88859b2 Improve form in account dialog (#2408)
* Improve form in account dialog

* Update changelog
2023-10-03 19:34:04 +02:00
aa24b5e8c6 Feature/reload platform and tag on change (#2417)
* Reload platforms via info

* Reload tags via info

* Update changelog
2023-10-03 18:58:23 +02:00
90e18338f6 Change UX to set an asset profile as a benchmark (#2409)
* Add checkbox functionality to set / unset benchmark

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-02 20:50:12 +02:00
ad5ae938ef Feature/harmonize settings icon of user account page (#2412)
* Harmonize icon

* Update changelog
2023-10-02 11:29:33 +02:00
c9a8dd4958 Support copy-assets Nx target on Windows (#2410)
* Introduce shx plugin
2023-10-02 10:48:36 +02:00
f1ec5e704e Add version to Overview of Admin Control panel (#2414)
* Add version to Overview of Admin Control panel

* Update changelog
2023-10-02 10:05:01 +02:00
f40f0653c2 Add request params (ship, take) for pagination to GET order endpoint (#2382)
* Add request params (ship, take) for pagination to GET order endpoint

* Update changelog
2023-10-02 08:54:21 +02:00
5f7a230fd3 Bugfix/fix sidebar on user account page (#2413)
* Fix sidebar on user account page

* Update changelog
2023-10-01 19:11:48 +02:00
71feb531e8 Release 2.7.0 (#2404) 2023-09-30 08:26:04 +02:00
ec3552d7f6 Feature/add tabs to user account page (#2396)
* Create components for access, membership and settings

* Add tabs

* Update changelog
2023-09-30 08:24:26 +02:00
41875e70d6 Extend personal finance tools (#2403) 2023-09-30 08:24:00 +02:00
5fa0540936 Feature/add emergency fund setup to static portfolio analysis rules (#2400)
* Add new static portfolio analysis rule: Emergency fund setup

* Update changelog
2023-09-30 08:08:20 +02:00
5b69dee246 Feature/setup inter font family (#2402)
* Setup Inter font family

* Update changelog
2023-09-30 08:06:01 +02:00
19b0fe04a6 Feature/upgrade yahoo finance2 to version 2.8.0 (#2401)
* Upgrade yahoo-finance2 to version 2.8.0

* Update changelog
2023-09-30 07:31:02 +02:00
19ea4479ff Bugfix/fix link on features page (#2398)
* Fix link

* Update changelog
2023-09-30 07:11:44 +02:00
0b2f6a312c Sort imports (#2399) 2023-09-30 07:11:10 +02:00
f79d60014b Release 2.6.0 (#2395) 2023-09-26 18:58:22 +02:00
5b7409d08e Feature/add tag management in admin control panel (#2389)
* Add tag management

* Update locales

* Update changelog
2023-09-26 18:56:09 +02:00
6230aa87e2 Feature/add hacktoberfest 2023 blog post (#2359)
* Add blog post: Hacktoberfest 2023

* Update changelog
2023-09-26 16:08:17 +02:00
8b615d2f56 Feature/upgrade prettier to version 3.0.3 (#2393)
* Upgrade prettier to version 3.0.3

* Update changelog
2023-09-26 15:07:20 +02:00
4100446cac Update OSS Friends (#2385) 2023-09-26 15:05:14 +02:00
ad3e6d637c Improve file name (#2391) 2023-09-26 15:04:32 +02:00
aa87262954 Feature/upgrade yahoo finance2 to version 2.7.0 (#2392)
* Upgrade yahoo-finance2 to version 2.7.0

* Update changelog
2023-09-26 07:51:27 +02:00
01b6bb5b99 Clean up (#2390) 2023-09-25 23:37:52 +02:00
884b7f4de7 Clean up (#2342) 2023-09-24 08:25:25 +02:00
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
b0fb986208 Release 1.304.0 (#2272) 2023-08-27 11:19:14 +02:00
0b59fc639d Feature/upgrade prettier to version 3 (#2163)
* Upgrade prettier to version 3.0.2

* Prettify code

* Update changelog
2023-08-27 11:13:11 +02:00
7ddd6f27b5 Feature/upgrade nx to version 16.7.4 (#2271)
* Upgrade Nx to version 16.7.4

* Update changelog
2023-08-27 10:44:06 +02:00
c5d56f4b47 Fix border (#2268) 2023-08-27 10:20:36 +02:00
2f2b712999 Fix breadcrumb (#2267) 2023-08-27 10:20:21 +02:00
c2fd31f5e5 Feature/add health check endpoints for data enhancers (#2265)
* Add health check for data enhancers

* Update changelog
2023-08-27 10:19:53 +02:00
f2d70f9070 Sort imports (#2266) 2023-08-26 11:22:19 +02:00
f41dd9cd8e Fix lint script (#2264) 2023-08-25 15:13:04 +02:00
366 changed files with 42670 additions and 12326 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

@ -9,6 +9,7 @@
], ],
"attributeSort": "ASC", "attributeSort": "ASC",
"endOfLine": "auto", "endOfLine": "auto",
"plugins": ["prettier-plugin-organize-attributes"],
"printWidth": 80, "printWidth": 80,
"singleQuote": true, "singleQuote": true,
"tabWidth": 2, "tabWidth": 2,

View File

@ -5,6 +5,266 @@ 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.13.0 - 2023-10-20
### Added
- Added a chart to the account detail dialog
- Added an `i18n` service to query `messages.*.xlf` files on the server
### Changed
- Changed the users table in the admin control panel to an `@angular/material` data table
- Improved the styling of the membership status
### Fixed
- Fixed an issue where holdings were requested twice from the server
## 2.12.0 - 2023-10-17
### Added
- Added the endpoint `GET api/v1/account/:id/balances` which provides historical cash balances
- Added support to search for an asset profile by `isin`, `name` and `symbol` as an administrator (experimental)
- Added support for creating asset profiles with `MANUAL` data source
### Changed
- Changed the checkboxes to slide toggles in the user settings of the user account page
- Extended the `copy-assets` `Nx` target to copy the locales to the servers assets
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `5.2.1` to `8.3`
### Fixed
- Displayed the transfer cash balance button based on a permission
- Fixed the biometric authentication
- Fixed the query to get asset profiles that match both the `dataSource` and `symbol` values
## 2.11.0 - 2023-10-14
### Added
- Added support to transfer a part of the cash balance from one to another account
- Extended the markets overview by benchmarks (date of last all time high)
- Added support to import historical market data in the admin control panel
### Changed
- Harmonized the style of the create button on the page for granting and revoking public access to share the portfolio
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.3.1` to `5.4.2`
### Fixed
- Fixed `FEE` and `INTEREST` types in the activities import of `csv` files
- Fixed the displayed currency of the cash balance in the create or update account dialog
## 2.10.0 - 2023-10-09
### Added
- Supported enter key press to submit the form of the create or update access dialog
### Changed
- Improved the display of the results in the search for a holding
- Changed the queue jobs view in the admin control panel to an `@angular/material` data table
- Improved the symbol conversion in the _EOD Historical Data_ service
## 2.9.0 - 2023-10-08
### Added
- Added support to search for a holding by `isin`, `name` and `symbol` (experimental)
- Added support for notes in the activities import
- Added support to search in the platform selector of the create or update account dialog
- Added support for a search query in the portfolio position endpoint
- Added the application version to the endpoint `GET api/v1/admin`
- Introduced a carousel component for the testimonial section on the landing page
### Changed
- Displayed the link to the markets overview on the home page without any permission
### Fixed
- Fixed the style of the active features page in the navigation on desktop
## 2.8.0 - 2023-10-03
### Added
- Supported enter key press to submit the form of the create or update account dialog
- Added the application version to the admin control panel
- Added pagination parameters (`skip`, `take`) to the endpoint `GET api/v1/order`
### Changed
- Harmonized the settings icon of the user account page
- Improved the usability to set an asset profile as a benchmark
- Reload platforms after making a change in the admin control panel
- Reload tags after making a change in the admin control panel
### Fixed
- Fixed the sidebar navigation on the user account page
## 2.7.0 - 2023-09-30
### Added
- Added a new static portfolio analysis rule: Emergency fund setup
- Added tabs to the user account page
### Changed
- Set up the _Inter_ font family
- Upgraded `yahoo-finance2` from version `2.7.0` to `2.8.0`
### Fixed
- Fixed a link on the features page
## 2.6.0 - 2023-09-26
### Added
- Added the management of tags in the admin control panel
- Added a blog post: _Hacktoberfest 2023_
### Changed
- Upgraded `prettier` from version `3.0.2` to `3.0.3`
- Upgraded `yahoo-finance2` from version `2.5.0` to `2.7.0`
## 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 accounts 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 servers 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
### Added
- Added health check endpoints for data enhancers
### Changed
- Upgraded `Nx` from version `16.7.2` to `16.7.4`
- Upgraded `prettier` from version `2.8.4` to `3.0.2`
## 1.303.0 - 2023-08-23 ## 1.303.0 - 2023-08-23
### Added ### Added
@ -59,7 +319,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Optimized the activities import by allowing a different currency than the asset's official one - Optimized the activities import by allowing a different currency than the assets official one
- Added a timeout to the _EOD Historical Data_ requests - Added a timeout to the _EOD Historical Data_ requests
- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service - Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service
@ -566,7 +826,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Persisted today's market data continuously - Persisted todays market data continuously
### Fixed ### Fixed
@ -800,7 +1060,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Filtered activities with type `ITEM` from search results - Filtered activities with type `ITEM` from search results
- Considered the user's language in the _Stripe_ checkout - Considered the users language in the _Stripe_ checkout
- Upgraded the _Stripe_ dependencies - Upgraded the _Stripe_ dependencies
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2` - Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
@ -1458,7 +1718,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
@ -2474,7 +2734,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Moved the countries and sectors charts in the position detail dialog - Moved the countries and sectors charts in the position detail dialog
- Distinguished today's data point of historical data in the admin control panel - Distinguished todays data point of historical data in the admin control panel
- Restructured the server modules - Restructured the server modules
### Fixed ### Fixed
@ -2881,7 +3141,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

@ -18,6 +18,12 @@
### Prisma ### Prisma
#### Access database via GUI
Run `yarn database:gui`
https://www.prisma.io/studio
#### Synchronize schema with database for prototyping #### Synchronize schema with database for prototyping
Run `yarn database:push` Run `yarn database:push`

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

@ -8,4 +8,8 @@ export class CreateAccessDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
granteeUserId?: string; granteeUserId?: string;
@IsOptional()
@IsString()
type?: 'PUBLIC';
} }

View File

@ -1,8 +1,12 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { Accounts } from '@ghostfolio/common/interfaces'; import {
AccountBalancesResponse,
Accounts
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { import type {
AccountWithValue, AccountWithValue,
@ -29,11 +33,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import { CreateAccountDto } from './create-account.dto'; import { CreateAccountDto } from './create-account.dto';
import { TransferBalanceDto } from './transfer-balance.dto';
import { UpdateAccountDto } from './update-account.dto'; import { UpdateAccountDto } from './update-account.dto';
@Controller('account') @Controller('account')
export class AccountController { export class AccountController {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@ -115,6 +121,18 @@ export class AccountController {
return accountsWithAggregations.accounts[0]; return accountsWithAggregations.accounts[0];
} }
@Get(':id/balances')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById(
@Param('id') id: string
): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({
accountId: id,
userId: this.request.user.id
});
}
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async createAccount( public async createAccount(
@ -154,6 +172,58 @@ export class AccountController {
} }
} }
@Post('transfer-balance')
@UseGuards(AuthGuard('jwt'))
public async transferAccountBalance(
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
) {
if (
!hasPermission(this.request.user.permissions, permissions.updateAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const accountsOfUser = await this.accountService.getAccounts(
this.request.user.id
);
const currentAccountIds = accountsOfUser.map(({ id }) => {
return id;
});
if (
![accountIdFrom, accountIdTo].every((accountId) => {
return currentAccountIds.includes(accountId);
})
) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const { currency } = accountsOfUser.find(({ id }) => {
return id === accountIdFrom;
});
await this.accountService.updateAccountBalance({
currency,
accountId: accountIdFrom,
amount: -balance,
userId: this.request.user.id
});
await this.accountService.updateAccountBalance({
currency,
accountId: accountIdTo,
amount: balance,
userId: this.request.user.id
});
}
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) { public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {

View File

@ -109,7 +109,7 @@ export class AccountService {
}); });
} }
public async getAccounts(aUserId: string) { public async getAccounts(aUserId: string): Promise<Account[]> {
const accounts = await this.accounts({ const accounts = await this.accounts({
include: { Order: true, Platform: true }, include: { Order: true, Platform: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
@ -218,13 +218,13 @@ export class AccountService {
accountId, accountId,
amount, amount,
currency, currency,
date, date = new Date(),
userId userId
}: { }: {
accountId: string; accountId: string;
amount: number; amount: number;
currency: string; currency: string;
date: Date; date?: Date;
userId: string; userId: string;
}) { }) {
const { balance, currency: currencyOfAccount } = await this.account({ const { balance, currency: currencyOfAccount } = await this.account({

View File

@ -10,8 +10,9 @@ import {
import { isString } from 'lodash'; import { isString } from 'lodash';
export class CreateAccountDto { export class CreateAccountDto {
@IsOptional()
@IsString() @IsString()
accountType: AccountType; accountType?: AccountType;
@IsNumber() @IsNumber()
balance: number; balance: number;

View File

@ -0,0 +1,12 @@
import { IsNumber, IsString } from 'class-validator';
export class TransferBalanceDto {
@IsString()
accountIdFrom: string;
@IsString()
accountIdTo: string;
@IsNumber()
balance: number;
}

View File

@ -10,8 +10,9 @@ import {
import { isString } from 'lodash'; import { isString } from 'lodash';
export class UpdateAccountDto { export class UpdateAccountDto {
@IsOptional()
@IsString() @IsString()
accountType: AccountType; accountType?: AccountType;
@IsNumber() @IsNumber()
balance: number; balance: number;

View File

@ -1,9 +1,9 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { import {
DEFAULT_PAGE_SIZE,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -12,8 +12,7 @@ import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
EnhancedSymbolProfile, EnhancedSymbolProfile
Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { import type {
@ -43,12 +42,14 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto'; import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
import { UpdateMarketDataDto } from './update-market-data.dto'; import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
@ -254,6 +255,7 @@ export class AdminController {
public async getMarketData( public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string, @Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset, @Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string, @Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('sortDirection') sortDirection?: Prisma.SortOrder,
@ -271,16 +273,10 @@ export class AdminController {
); );
} }
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
const filters: Filter[] = [ filterBySearchQuery
...assetSubClasses.map((assetSubClass) => { });
return <Filter>{
id: assetSubClass,
type: 'ASSET_SUB_CLASS'
};
})
];
return this.adminService.getMarketData({ return this.adminService.getMarketData({
filters, filters,
@ -313,6 +309,43 @@ export class AdminController {
return this.adminService.getMarketDataBySymbol({ dataSource, symbol }); return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
} }
@Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({
dataSource,
date,
marketPrice,
symbol,
state: 'CLOSE'
})
);
return this.marketDataService.updateMany({
data: dataBulkUpdate
});
}
/**
* @deprecated
*/
@Put('market-data/:dataSource/:symbol/:dateString') @Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async update( public async update(
@ -365,8 +398,11 @@ export class AdminController {
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
); );
} }
return this.adminService.addAssetProfile({
return this.adminService.addAssetProfile({ dataSource, symbol }); dataSource,
symbol,
currency: this.request.user.Settings.settings.baseCurrency
});
} }
@Delete('profile-data/:dataSource/:symbol') @Delete('profile-data/:dataSource/:symbol')

View File

@ -1,4 +1,5 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.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';
@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
ApiModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,

View File

@ -1,4 +1,5 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; 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';
@ -6,7 +7,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 +29,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,15 +38,22 @@ 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({
currency,
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Promise<SymbolProfile | never> { }: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
try { try {
if (dataSource === 'MANUAL') {
return this.symbolProfileService.add({
currency,
dataSource,
symbol
});
}
const assetProfiles = await this.dataProviderService.getAssetProfiles([ const assetProfiles = await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol } { dataSource, symbol }
]); ]);
@ -80,15 +91,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
) )
}; };
@ -96,7 +107,8 @@ export class AdminService {
settings: await this.propertyService.get(), settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(), transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(), userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics() users: await this.getUsersWithAnalytics(),
version: environment.version
}; };
} }
@ -128,10 +140,14 @@ export class AdminService {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
} }
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy( const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters, filters,
(filter) => { ({ type }) => {
return filter.type; return type;
} }
); );
@ -144,6 +160,14 @@ export class AdminService {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
} }
if (searchQuery) {
where.OR = [
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
}
if (sortColumn) { if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
@ -170,7 +194,9 @@ export class AdminService {
assetSubClass: true, assetSubClass: true,
comment: true, comment: true,
countries: true, countries: true,
currency: true,
dataSource: true, dataSource: true,
name: true,
Order: { Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
select: { date: true }, select: { date: true },
@ -191,7 +217,9 @@ export class AdminService {
assetSubClass, assetSubClass,
comment, comment,
countries, countries,
currency,
dataSource, dataSource,
name,
Order, Order,
sectors, sectors,
symbol symbol
@ -210,8 +238,10 @@ export class AdminService {
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
currency,
countriesCount, countriesCount,
dataSource, dataSource,
name,
symbol, symbol,
marketDataItemCount, marketDataItemCount,
sectorsCount, sectorsCount,
@ -306,7 +336,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();
} }
@ -336,6 +368,8 @@ export class AdminService {
symbol, symbol,
assetClass: 'CASH', assetClass: 'CASH',
countriesCount: 0, countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''),
name: symbol,
sectorsCount: 0 sectorsCount: 0
}; };
}); });

View File

@ -0,0 +1,11 @@
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, isNotEmptyObject } from 'class-validator';
import { UpdateMarketDataDto } from './update-market-data.dto';
export class UpdateBulkMarketDataDto {
@ArrayNotEmpty()
@IsArray()
@Type(() => UpdateMarketDataDto)
marketData: UpdateMarketDataDto[];
}

View File

@ -1,6 +1,10 @@
import { IsNumber } from 'class-validator'; import { IsDate, IsNumber, IsOptional } from 'class-validator';
export class UpdateMarketDataDto { export class UpdateMarketDataDto {
@IsDate()
@IsOptional()
date?: Date;
@IsNumber() @IsNumber()
marketPrice: number; marketPrice: number;
} }

View File

@ -39,6 +39,7 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module'; import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module'; import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@Module({ @Module({
@ -101,6 +102,7 @@ import { UserModule } from './user/user.module';
SitemapModule, SitemapModule,
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TagModule,
TwitterBotModule, TwitterBotModule,
UserModule UserModule
], ],

View File

@ -41,9 +41,8 @@ export class AuthController {
@Param('accessToken') accessToken: string @Param('accessToken') accessToken: string
): Promise<OAuthResponse> { ): Promise<OAuthResponse> {
try { try {
const authToken = await this.authService.validateAnonymousLogin( const authToken =
accessToken await this.authService.validateAnonymousLogin(accessToken);
);
return { authToken }; return { authToken };
} catch { } catch {
throw new HttpException( throw new HttpException(

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

@ -64,7 +64,7 @@ export class WebAuthService {
} }
}; };
const options = generateRegistrationOptions(opts); const options = await generateRegistrationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
@ -88,10 +88,16 @@ export class WebAuthService {
let verification: VerifiedRegistrationResponse; let verification: VerifiedRegistrationResponse;
try { try {
const opts: VerifyRegistrationResponseOpts = { const opts: VerifyRegistrationResponseOpts = {
credential,
expectedChallenge, expectedChallenge,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID expectedRPID: this.rpID,
response: {
clientExtensionResults: credential.clientExtensionResults,
id: credential.id,
rawId: credential.rawId,
response: credential.response,
type: 'public-key'
}
}; };
verification = await verifyRegistrationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
@ -117,8 +123,8 @@ export class WebAuthService {
*/ */
existingDevice = await this.deviceService.createAuthDevice({ existingDevice = await this.deviceService.createAuthDevice({
counter, counter,
credentialPublicKey, credentialId: Buffer.from(credentialID),
credentialId: credentialID, credentialPublicKey: Buffer.from(credentialPublicKey),
User: { connect: { id: user.id } } User: { connect: { id: user.id } }
}); });
} }
@ -152,7 +158,7 @@ export class WebAuthService {
userVerification: 'preferred' userVerification: 'preferred'
}; };
const options = generateAuthenticationOptions(opts); const options = await generateAuthenticationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
@ -181,7 +187,6 @@ export class WebAuthService {
let verification: VerifiedAuthenticationResponse; let verification: VerifiedAuthenticationResponse;
try { try {
const opts: VerifyAuthenticationResponseOpts = { const opts: VerifyAuthenticationResponseOpts = {
credential,
authenticator: { authenticator: {
credentialID: device.credentialId, credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey, credentialPublicKey: device.credentialPublicKey,
@ -189,9 +194,16 @@ export class WebAuthService {
}, },
expectedChallenge: `${user.authChallenge}`, expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID expectedRPID: this.rpID,
response: {
clientExtensionResults: credential.clientExtensionResults,
id: credential.id,
rawId: credential.rawId,
response: credential.response,
type: 'public-key'
}
}; };
verification = verifyAuthenticationResponse(opts); verification = await verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });

View File

@ -10,6 +10,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
HttpException, HttpException,
Inject, Inject,
@ -32,32 +33,6 @@ export class BenchmarkController {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {
return {
benchmarks: await this.benchmarkService.getBenchmarks()
};
}
@Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string
): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString);
return this.benchmarkService.getMarketDataBySymbol({
dataSource,
startDate,
symbol
});
}
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) { public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
@ -94,4 +69,70 @@ export class BenchmarkController {
); );
} }
} }
@Delete(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async deleteBenchmark(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
const benchmark = await this.benchmarkService.deleteBenchmark({
dataSource,
symbol
});
if (!benchmark) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return benchmark;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {
return {
benchmarks: await this.benchmarkService.getBenchmarks()
};
}
@Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string
): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString);
return this.benchmarkService.getMarketDataBySymbol({
dataSource,
startDate,
symbol
});
}
} }

View File

@ -64,7 +64,7 @@ export class BenchmarkService {
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles(); const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
const promises: Promise<number>[] = []; const promises: Promise<{ date: Date; marketPrice: number }>[] = [];
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
@ -85,15 +85,14 @@ export class BenchmarkService {
let performancePercentFromAllTimeHigh = 0; let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh && marketPrice) { if (allTimeHigh?.marketPrice && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage( performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh, allTimeHigh.marketPrice,
marketPrice marketPrice
); );
} else { } else {
storeInCache = false; storeInCache = false;
} }
return { return {
marketCondition: this.getMarketCondition( marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh performancePercentFromAllTimeHigh
@ -101,6 +100,7 @@ export class BenchmarkService {
name: benchmarkAssetProfiles[index].name, name: benchmarkAssetProfiles[index].name,
performances: { performances: {
allTimeHigh: { allTimeHigh: {
date: allTimeHigh.date,
performancePercent: performancePercentFromAllTimeHigh performancePercent: performancePercentFromAllTimeHigh
} }
} }
@ -245,6 +245,43 @@ export class BenchmarkService {
}; };
} }
public async deleteBenchmark({
dataSource,
symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: {
dataSource,
symbol
}
});
if (!assetProfile) {
return null;
}
let benchmarks =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? [];
benchmarks = benchmarks.filter(({ symbolProfileId }) => {
return symbolProfileId !== assetProfile.id;
});
await this.propertyService.put({
key: PROPERTY_BENCHMARKS,
value: JSON.stringify(benchmarks)
});
return {
dataSource,
symbol,
id: assetProfile.id,
name: assetProfile.name
};
}
private getMarketCondition(aPerformanceInPercent: number) { private getMarketCondition(aPerformanceInPercent: number) {
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET'; return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
} }

View File

@ -7,10 +7,10 @@ import {
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExchangeRateService } from './exchange-rate.service'; import { ExchangeRateService } from './exchange-rate.service';
import { parseISO } from 'date-fns';
@Controller('exchange-rate') @Controller('exchange-rate')
export class ExchangeRateController { export class ExchangeRateController {

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

@ -18,6 +18,19 @@ export class HealthController {
@Get() @Get()
public async getHealth() {} public async getHealth() {}
@Get('data-enhancer/:name')
public async getHealthOfDataEnhancer(@Param('name') name: string) {
const hasResponse =
await this.healthService.hasResponseFromDataEnhancer(name);
if (hasResponse !== true) {
throw new HttpException(
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
StatusCodes.SERVICE_UNAVAILABLE
);
}
}
@Get('data-provider/:dataSource') @Get('data-provider/:dataSource')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getHealthOfDataProvider( public async getHealthOfDataProvider(
@ -30,9 +43,8 @@ export class HealthController {
); );
} }
const hasResponse = await this.healthService.hasResponseFromDataProvider( const hasResponse =
dataSource await this.healthService.hasResponseFromDataProvider(dataSource);
);
if (hasResponse !== true) { if (hasResponse !== true) {
throw new HttpException( throw new HttpException(

View File

@ -1,4 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -7,7 +8,7 @@ import { HealthService } from './health.service';
@Module({ @Module({
controllers: [HealthController], controllers: [HealthController],
imports: [ConfigurationModule, DataProviderModule], imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
providers: [HealthService] providers: [HealthService]
}) })
export class HealthModule {} export class HealthModule {}

View File

@ -1,3 +1,4 @@
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -5,9 +6,14 @@ import { DataSource } from '@prisma/client';
@Injectable() @Injectable()
export class HealthService { export class HealthService {
public constructor( public constructor(
private readonly dataEnhancerService: DataEnhancerService,
private readonly dataProviderService: DataProviderService private readonly dataProviderService: DataProviderService
) {} ) {}
public async hasResponseFromDataEnhancer(aName: string) {
return this.dataEnhancerService.enhance(aName);
}
public async hasResponseFromDataProvider(aDataSource: DataSource) { public async hasResponseFromDataProvider(aDataSource: DataSource) {
return this.dataProviderService.checkQuote(aDataSource); return this.dataProviderService.checkQuote(aDataSource);
} }

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,21 +46,17 @@ 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> {
const info: Partial<InfoItem> = {}; const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean; let isReadOnlyMode: boolean;
const platforms = ( const platforms = await this.platformService.getPlatforms({
await this.platformService.getPlatforms({ orderBy: { name: 'asc' }
orderBy: { name: 'asc' }
})
).map(({ id, name }) => {
return { id, name };
}); });
let systemMessage: string; let systemMessage: string;
@ -139,18 +137,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 +165,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 +190,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 +209,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 +217,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 +332,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 +345,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

@ -89,7 +89,9 @@ export class OrderController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string @Query('skip') skip?: number,
@Query('tags') filterByTags?: string,
@Query('take') take?: number
): Promise<Activities> { ): Promise<Activities> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
@ -105,6 +107,8 @@ export class OrderController {
filters, filters,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true withExcludedAccounts: true
}); });
@ -147,8 +151,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);
} }
@ -215,6 +230,8 @@ export class OrderService {
public async getOrders({ public async getOrders({
filters, filters,
includeDrafts = false, includeDrafts = false,
skip,
take = Number.MAX_SAFE_INTEGER,
types, types,
userCurrency, userCurrency,
userId, userId,
@ -222,6 +239,8 @@ export class OrderService {
}: { }: {
filters?: Filter[]; filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
skip?: number;
take?: number;
types?: TypeOfOrder[]; types?: TypeOfOrder[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
@ -300,6 +319,8 @@ export class OrderService {
return ( return (
await this.orders({ await this.orders({
skip,
take,
where, where,
include: { include: {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
@ -368,7 +389,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

@ -47,6 +47,7 @@ export class PlatformController {
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
); );
} }
return this.platformService.createPlatform(data); return this.platformService.createPlatform(data);
} }

View File

@ -6,6 +6,18 @@ import { Platform, Prisma } from '@prisma/client';
export class PlatformService { export class PlatformService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async createPlatform(data: Prisma.PlatformCreateInput) {
return this.prismaService.platform.create({
data
});
}
public async deletePlatform(
where: Prisma.PlatformWhereUniqueInput
): Promise<Platform> {
return this.prismaService.platform.delete({ where });
}
public async getPlatform( public async getPlatform(
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
): Promise<Platform> { ): Promise<Platform> {
@ -56,12 +68,6 @@ export class PlatformService {
}); });
} }
public async createPlatform(data: Prisma.PlatformCreateInput) {
return this.prismaService.platform.create({
data
});
}
public async updatePlatform({ public async updatePlatform({
data, data,
where where
@ -74,10 +80,4 @@ export class PlatformService {
where where
}); });
} }
public async deletePlatform(
where: Prisma.PlatformWhereUniqueInput
): Promise<Platform> {
return this.prismaService.platform.delete({ where });
}
} }

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,
@ -386,12 +391,14 @@ export class PortfolioController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioPositions> { ): Promise<PortfolioPositions> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterBySearchQuery,
filterByTags filterByTags
}); });
@ -442,8 +449,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

@ -10,13 +10,14 @@ import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rule
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
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 { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
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
@ -50,18 +51,17 @@ import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { import {
Account, Account,
Type as ActivityType,
AssetClass, AssetClass,
DataSource, DataSource,
Order, Order,
Platform, Platform,
Prisma, Prisma,
Tag, Tag
Type as TypeOfOrder
} 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 +90,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 +101,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,
@ -470,9 +465,8 @@ export class PortfolioService {
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
); );
const startDate = this.getStartDate(dateRange, portfolioStart); const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
startDate await portfolioCalculator.getCurrentPositions(startDate);
);
const cashDetails = await this.accountService.getCashDetails({ const cashDetails = await this.accountService.getCashDetails({
filters, filters,
@ -810,9 +804,8 @@ export class PortfolioService {
const transactionPoints = portfolioCalculator.getTransactionPoints(); const transactionPoints = portfolioCalculator.getTransactionPoints();
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
portfolioStart await portfolioCalculator.getCurrentPositions(portfolioStart);
);
const position = currentPositions.positions.find( const position = currentPositions.positions.find(
(item) => item.symbol === aSymbol (item) => item.symbol === aSymbol
@ -1021,6 +1014,9 @@ export class PortfolioService {
filters?: Filter[]; filters?: Filter[];
impersonationId: string; impersonationId: string;
}): Promise<{ hasErrors: boolean; positions: Position[] }> { }): Promise<{ hasErrors: boolean; positions: Position[] }> {
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
@ -1046,13 +1042,12 @@ export class PortfolioService {
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(dateRange, portfolioStart); const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
startDate await portfolioCalculator.getCurrentPositions(startDate);
);
const positions = currentPositions.positions.filter( let positions = currentPositions.positions.filter(({ quantity }) => {
(item) => !item.quantity.eq(0) return !quantity.eq(0);
); });
const dataGatheringItems = positions.map(({ dataSource, symbol }) => { const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
return { return {
@ -1075,12 +1070,25 @@ export class PortfolioService {
symbolProfileMap[symbolProfile.symbol] = symbolProfile; symbolProfileMap[symbolProfile.symbol] = symbolProfile;
} }
if (searchQuery) {
positions = positions.filter(({ symbol }) => {
const enhancedSymbolProfile = symbolProfileMap[symbol];
return (
enhancedSymbolProfile.isin?.toLowerCase().startsWith(searchQuery) ||
enhancedSymbolProfile.name?.toLowerCase().startsWith(searchQuery) ||
enhancedSymbolProfile.symbol?.toLowerCase().startsWith(searchQuery)
);
});
}
return { return {
hasErrors: currentPositions.hasErrors, hasErrors: currentPositions.hasErrors,
positions: positions.map((position) => { positions: positions.map((position) => {
return { return {
...position, ...position,
assetClass: symbolProfileMap[position.symbol].assetClass, assetClass: symbolProfileMap[position.symbol].assetClass,
assetSubClass: symbolProfileMap[position.symbol].assetSubClass,
averagePrice: new Big(position.averagePrice).toNumber(), averagePrice: new Big(position.averagePrice).toNumber(),
grossPerformance: position.grossPerformance?.toNumber() ?? null, grossPerformance: position.grossPerformance?.toNumber() ?? null,
grossPerformancePercentage: grossPerformancePercentage:
@ -1223,12 +1231,6 @@ export class PortfolioService {
userId userId
}); });
if (isEmpty(orders)) {
return {
rules: {}
};
}
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency: userCurrency, currency: userCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
@ -1237,10 +1239,11 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(
const currentPositions = await portfolioCalculator.getCurrentPositions( transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
portfolioStart
); );
const currentPositions =
await portfolioCalculator.getCurrentPositions(portfolioStart);
const positions = currentPositions.positions.filter( const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0) (item) => !item.quantity.eq(0)
@ -1259,33 +1262,48 @@ export class PortfolioService {
userId userId
}); });
const userSettings = <UserSettings>this.request.user.Settings.settings;
return { return {
rules: { rules: {
accountClusterRisk: await this.rulesService.evaluate( accountClusterRisk: isEmpty(orders)
[ ? undefined
new AccountClusterRiskCurrentInvestment( : await this.rulesService.evaluate(
this.exchangeRateDataService, [
accounts new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService,
accounts
),
new AccountClusterRiskSingleAccount(
this.exchangeRateDataService,
accounts
)
],
userSettings
), ),
new AccountClusterRiskSingleAccount( currencyClusterRisk: isEmpty(orders)
? undefined
: await this.rulesService.evaluate(
[
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService,
positions
),
new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService,
positions
)
],
userSettings
),
emergencyFund: await this.rulesService.evaluate(
[
new EmergencyFundSetup(
this.exchangeRateDataService, this.exchangeRateDataService,
accounts userSettings.emergencyFund
) )
], ],
<UserSettings>this.request.user.Settings.settings userSettings
),
currencyClusterRisk: await this.rulesService.evaluate(
[
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService,
positions
),
new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService,
positions
)
],
<UserSettings>this.request.user.Settings.settings
), ),
fees: await this.rulesService.evaluate( fees: await this.rulesService.evaluate(
[ [
@ -1295,7 +1313,7 @@ export class PortfolioService {
this.getFees({ userCurrency, activities: orders }).toNumber() this.getFees({ userCurrency, activities: orders }).toNumber()
) )
], ],
<UserSettings>this.request.user.Settings.settings userSettings
) )
} }
}; };
@ -1351,36 +1369,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
@ -1525,52 +1513,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':
@ -1659,9 +1601,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(
@ -1671,23 +1614,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({
@ -1734,6 +1703,7 @@ export class PortfolioService {
excludedAccountsAndActivities, excludedAccountsAndActivities,
fees, fees,
firstOrderDate, firstOrderDate,
interest,
items, items,
liabilities, liabilities,
netWorth, netWorth,
@ -1756,6 +1726,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,
@ -1772,7 +1775,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,
@ -1827,6 +1830,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,
@ -1970,38 +1988,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

@ -93,9 +93,8 @@ export class SubscriptionService {
public async createSubscriptionViaStripe(aCheckoutSessionId: string) { public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
try { try {
const session = await this.stripe.checkout.sessions.retrieve( const session =
aCheckoutSessionId await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId);
);
await this.createSubscription({ await this.createSubscription({
price: session.amount_total / 100, price: session.amount_total / 100,

View File

@ -15,13 +15,13 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash'; import { isDate, isEmpty } from 'lodash';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
import { parseISO } from 'date-fns';
@Controller('symbol') @Controller('symbol')
export class SymbolController { export class SymbolController {

View File

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class CreateTagDto {
@IsString()
name: string;
}

View File

@ -0,0 +1,104 @@
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto';
import { TagService } from './tag.service';
import { UpdateTagDto } from './update-tag.dto';
@Controller('tag')
export class TagController {
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly tagService: TagService
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
if (!hasPermission(this.request.user.permissions, permissions.createTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.createTag(data);
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
if (!hasPermission(this.request.user.permissions, permissions.updateTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.updateTag({
data: {
...data
},
where: {
id
}
});
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteTag(@Param('id') id: string) {
if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.deleteTag({ id });
}
}

View File

@ -0,0 +1,13 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { TagController } from './tag.controller';
import { TagService } from './tag.service';
@Module({
controllers: [TagController],
exports: [TagService],
imports: [PrismaModule],
providers: [TagService]
})
export class TagModule {}

View File

@ -0,0 +1,79 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client';
@Injectable()
export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async createTag(data: Prisma.TagCreateInput) {
return this.prismaService.tag.create({
data
});
}
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
return this.prismaService.tag.delete({ where });
}
public async getTag(
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
): Promise<Tag> {
return this.prismaService.tag.findUnique({
where: tagWhereUniqueInput
});
}
public async getTags({
cursor,
orderBy,
skip,
take,
where
}: {
cursor?: Prisma.TagWhereUniqueInput;
orderBy?: Prisma.TagOrderByWithRelationInput;
skip?: number;
take?: number;
where?: Prisma.TagWhereInput;
} = {}) {
return this.prismaService.tag.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
public async getTagsWithActivityCount() {
const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: {
_count: {
select: { orders: true }
}
}
});
return tagsWithOrderCount.map(({ _count, id, name }) => {
return {
id,
name,
activityCount: _count.orders
};
});
}
public async updateTag({
data,
where
}: {
data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> {
return this.prismaService.tag.update({
data,
where
});
}
}

View File

@ -0,0 +1,9 @@
import { IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
}

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(
@ -163,6 +163,13 @@ export class UserService {
let currentPermissions = getPermissions(user.role); let currentPermissions = getPermissions(user.role);
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
currentPermissions = without(
currentPermissions,
permissions.accessAssistant
);
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription = user.subscription =
this.subscriptionService.getSubscription(Subscription); this.subscriptionService.getSubscription(Subscription);
@ -188,6 +195,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 +279,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 +287,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,126 @@
<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-finary</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-stockle</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 +266,14 @@
<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>
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</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 +312,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>
@ -200,6 +332,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</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-finary</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-folishare</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -268,6 +404,14 @@
<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-stockle</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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 +586,126 @@
<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-finary</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-stockle</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 +715,127 @@
<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-finary</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-stockle</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 +877,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 +936,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

@ -3,7 +3,7 @@ import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean { export function hasNotDefinedValuesInObject(aObject: Object): boolean {
for (const key in aObject) { for (const key in aObject) {
if (aObject[key] === null || aObject[key] === null) { if (aObject[key] === null || aObject[key] === undefined) {
return true; return true;
} else if (isObject(aObject[key])) { } else if (isObject(aObject[key])) {
return hasNotDefinedValuesInObject(aObject[key]); return hasNotDefinedValuesInObject(aObject[key]);

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

@ -2,6 +2,7 @@ import * as fs from 'fs';
import { join } from 'path'; import { join } from 'path';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
DEFAULT_ROOT_URL, DEFAULT_ROOT_URL,
@ -11,19 +12,11 @@ import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
const descriptions = {
de: 'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
en: 'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.',
es: 'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
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.',
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.'
};
const title = 'Ghostfolio Open Source Wealth Management Software'; const title = 'Ghostfolio Open Source Wealth Management Software';
const titleShort = 'Ghostfolio'; const titleShort = 'Ghostfolio';
const i18nService = new I18nService();
let indexHtmlMap: { [languageCode: string]: string } = {}; let indexHtmlMap: { [languageCode: string]: string } = {};
try { try {
@ -75,6 +68,14 @@ 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}`
},
'/en/blog/2023/09/hacktoberfest-2023': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 - ${titleShort}`
} }
}; };
@ -121,7 +122,10 @@ export const HtmlTemplateMiddleware = async (
languageCode, languageCode,
path, path,
rootUrl, rootUrl,
description: descriptions[languageCode], description: i18nService.getTranslation({
languageCode,
id: 'metaDescription'
}),
featureGraphicPath: featureGraphicPath:
locales[path]?.featureGraphicPath ?? 'assets/cover.png', locales[path]?.featureGraphicPath ?? 'assets/cover.png',
title: locales[path]?.title ?? title title: locales[path]?.title ?? title

View File

@ -1,4 +1,5 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
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 { import {
PortfolioDetails, PortfolioDetails,
@ -6,16 +7,18 @@ import {
UserSettings UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> { export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
private accounts: PortfolioDetails['accounts'];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Investment' name: 'Investment'
}); });
this.accounts = accounts;
} }
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {

View File

@ -1,17 +1,20 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
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 { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces'; import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> { export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
private accounts: PortfolioDetails['accounts'];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Single Account' name: 'Single Account'
}); });
this.accounts = accounts;
} }
public evaluate() { public evaluate() {

View File

@ -1,17 +1,20 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
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 { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private positions: TimelinePosition[] positions: TimelinePosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Investment: Base Currency' name: 'Investment: Base Currency'
}); });
this.positions = positions;
} }
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {

View File

@ -1,17 +1,20 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
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 { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private positions: TimelinePosition[] positions: TimelinePosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Investment' name: 'Investment'
}); });
this.positions = positions;
} }
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {

View File

@ -0,0 +1,46 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
export class EmergencyFundSetup extends Rule<Settings> {
private emergencyFund: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
emergencyFund: number
) {
super(exchangeRateDataService, {
name: 'Emergency Fund: Set up'
});
this.emergencyFund = emergencyFund;
}
public evaluate(ruleSettings: Settings) {
if (this.emergencyFund > ruleSettings.threshold) {
return {
evaluation: 'An emergency fund has been set up',
value: true
};
}
return {
evaluation: 'No emergency fund has been set up',
value: false
};
}
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
threshold: number;
}

View File

@ -1,22 +1,29 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
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 { UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class FeeRatioInitialInvestment extends Rule<Settings> { export class FeeRatioInitialInvestment extends Rule<Settings> {
private fees: number;
private totalInvestment: number;
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private totalInvestment: number, totalInvestment: number,
private fees: number fees: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Investment' name: 'Fee Ratio'
}); });
this.fees = fees;
this.totalInvestment = totalInvestment;
} }
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {
const feeRatio = this.fees / this.totalInvestment; const feeRatio = this.totalInvestment
? this.fees / this.totalInvestment
: 0;
if (feeRatio > ruleSettings.threshold) { if (feeRatio > ruleSettings.threshold) {
return { return {

View File

@ -1,4 +1,5 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccountBalancesResponse } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AccountBalance, Prisma } from '@prisma/client'; import { AccountBalance, Prisma } from '@prisma/client';
@ -13,4 +14,29 @@ export class AccountBalanceService {
data data
}); });
} }
public async getAccountBalances({
accountId,
userId
}: {
accountId: string;
userId: string;
}): Promise<AccountBalancesResponse> {
const balances = await this.prismaService.accountBalance.findMany({
orderBy: {
date: 'asc'
},
select: {
date: true,
id: true,
value: true
},
where: {
accountId,
userId
}
});
return { balances };
}
} }

View File

@ -8,14 +8,20 @@ export class ApiService {
public buildFiltersFromQueryParams({ public buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByAssetSubClasses,
filterBySearchQuery,
filterByTags filterByTags
}: { }: {
filterByAccounts?: string; filterByAccounts?: string;
filterByAssetClasses?: string; filterByAssetClasses?: string;
filterByAssetSubClasses?: string;
filterBySearchQuery?: string;
filterByTags?: string; filterByTags?: string;
}): Filter[] { }): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? []; const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? []; const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const searchQuery = filterBySearchQuery?.toLowerCase();
const tagIds = filterByTags?.split(',') ?? []; const tagIds = filterByTags?.split(',') ?? [];
return [ return [
@ -31,6 +37,16 @@ export class ApiService {
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}; };
}), }),
...assetSubClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_SUB_CLASS'
};
}),
{
id: searchQuery,
type: 'SEARCH_QUERY'
},
...tagIds.map((tagId) => { ...tagIds.map((tagId) => {
return <Filter>{ return <Filter>{
id: tagId, id: tagId,

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,12 +127,14 @@ export class DataGatheringService {
uniqueAssets = await this.getUniqueAssets(); uniqueAssets = await this.getUniqueAssets();
} }
const assetProfiles = await this.dataProviderService.getAssetProfiles( if (uniqueAssets.length <= 0) {
uniqueAssets return;
); }
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
uniqueAssets const assetProfiles =
); await this.dataProviderService.getAssetProfiles(uniqueAssets);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => { const symbolMapping = symbolProfiles.find((symbolProfile) => {
@ -147,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

@ -4,14 +4,18 @@ import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-p
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 { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DataEnhancerService } from './data-enhancer.service';
@Module({ @Module({
exports: [ exports: [
'DataEnhancers', DataEnhancerService,
TrackinsightDataEnhancerService, TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService YahooFinanceDataEnhancerService,
'DataEnhancers'
], ],
imports: [ConfigurationModule, CryptocurrencyModule], imports: [ConfigurationModule, CryptocurrencyModule],
providers: [ providers: [
DataEnhancerService,
TrackinsightDataEnhancerService, TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService, YahooFinanceDataEnhancerService,
{ {

View File

@ -0,0 +1,44 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { HttpException, Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
export class DataEnhancerService {
public constructor(
@Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[]
) {}
public async enhance(aName: string) {
const dataEnhancer = this.dataEnhancers.find((dataEnhancer) => {
return dataEnhancer.getName() === aName;
});
if (!dataEnhancer) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
try {
const assetProfile = await dataEnhancer.enhance({
response: {
assetClass: 'EQUITY',
assetSubClass: 'ETF'
},
symbol: dataEnhancer.getTestSymbol()
});
if (
(assetProfile.countries as unknown as Prisma.JsonArray)?.length > 0 &&
(assetProfile.sectors as unknown as Prisma.JsonArray)?.length > 0
) {
return true;
}
} catch {}
return false;
}
}

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/${ `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
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/${ `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(
symbol.split('.')?.[0] '.'
}.json` )?.[0]}.json`,
{
// @ts-ignore
signal: abortController.signal
}
) )
.json<any>() .json<any>()
.catch(() => { .catch(() => {
@ -126,4 +167,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
public getName() { public getName() {
return 'TRACKINSIGHT'; return 'TRACKINSIGHT';
} }
public getTestSymbol() {
return 'QQQ';
}
} }

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}`
); );
} }
} }
@ -99,15 +91,14 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
yahooSymbol = quotes[0].symbol; yahooSymbol = quotes[0].symbol;
} }
const { countries, sectors, url } = await this.getAssetProfile( const { countries, sectors, url } =
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;
} }
@ -234,6 +225,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
return DataSource.YAHOO; return DataSource.YAHOO;
} }
public getTestSymbol() {
return 'AAPL';
}
public parseAssetClass({ public parseAssetClass({
quoteType, quoteType,
shortName shortName

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,6 @@ 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}`;
} }
return symbol; return symbol;
@ -282,21 +291,22 @@ export class EodHistoricalDataService implements DataProviderInterface {
/** /**
* Converts a symbol to a EOD symbol * Converts a symbol to a EOD symbol
* *
* Currency: USDCHF -> CHF.FOREX * Currency: USDCHF -> USDCHF.FOREX
*/ */
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 let symbol = aSymbol;
.replace('GBp', 'GBX') symbol = symbol.replace('GBp', 'GBX');
.replace(this.baseCurrency, '')}.FOREX`;
return `${symbol}.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

@ -10,4 +10,6 @@ export interface DataEnhancerInterface {
}): Promise<Partial<SymbolProfile>>; }): Promise<Partial<SymbolProfile>>;
getName(): string; getName(): string;
getTestSymbol(): string;
} }

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

@ -0,0 +1,67 @@
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Logger } from '@nestjs/common';
import * as cheerio from 'cheerio';
export class I18nService {
private localesPath = join(__dirname, 'assets', 'locales');
private translations: { [locale: string]: cheerio.CheerioAPI } = {};
public constructor() {
this.loadFiles();
}
public getTranslation({
id,
languageCode
}: {
id: string;
languageCode: string;
}): string {
const $ = this.translations[languageCode];
if (!$) {
Logger.warn(`Translation not found for locale '${languageCode}'`);
}
const translatedText = $(
`trans-unit[id="${id}"] > ${
languageCode === DEFAULT_LANGUAGE_CODE ? 'source' : 'target'
}`
).text();
if (!translatedText) {
Logger.warn(
`Translation not found for id '${id}' in locale '${languageCode}'`
);
}
return translatedText;
}
private loadFiles() {
try {
const files = readdirSync(this.localesPath, 'utf-8');
for (const file of files) {
const xmlData = readFileSync(join(this.localesPath, file), 'utf8');
this.translations[this.parseLanguageCode(file)] =
this.parseXml(xmlData);
}
} catch (error) {
Logger.error(error, 'I18nService');
}
}
private parseLanguageCode(aFileName: string) {
const match = aFileName.match(/\.([a-zA-Z]+)\.xlf$/);
return match ? match[1] : DEFAULT_LANGUAGE_CODE;
}
private parseXml(xmlData: string): cheerio.CheerioAPI {
return cheerio.load(xmlData, { xmlMode: true });
}
}

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

@ -39,18 +39,22 @@ export class MarketDataService {
}); });
} }
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> { public async getMax({ dataSource, symbol }: UniqueAsset) {
const aggregations = await this.prismaService.marketData.aggregate({ return this.prismaService.marketData.findFirst({
_max: { select: {
date: true,
marketPrice: true marketPrice: true
}, },
orderBy: [
{
marketPrice: 'desc'
}
],
where: { where: {
dataSource, dataSource,
symbol symbol
} }
}); });
return aggregations._max.marketPrice;
} }
public async getRange({ public async getRange({

View File

@ -52,20 +52,12 @@ export class SymbolProfileService {
SymbolProfileOverrides: true SymbolProfileOverrides: true
}, },
where: { where: {
AND: [ OR: aUniqueAssets.map(({ dataSource, symbol }) => {
{ return {
dataSource: { dataSource,
in: aUniqueAssets.map(({ dataSource }) => { symbol
return dataSource; };
}) })
},
symbol: {
in: aUniqueAssets.map(({ symbol }) => {
return symbol;
})
}
}
]
} }
}) })
.then((symbolProfiles) => this.getSymbols(symbolProfiles)); .then((symbolProfiles) => this.getSymbols(symbolProfiles));

View File

@ -65,9 +65,8 @@ export class TwitterBotService {
status += benchmarkListing; status += benchmarkListing;
} }
const { data: createdTweet } = await this.twitterClient.v2.tweet( const { data: createdTweet } =
status await this.twitterClient.v2.tweet(status);
);
Logger.log( Logger.log(
`Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`, `Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`,

View File

@ -21,6 +21,7 @@
"tsConfig": "apps/client/tsconfig.app.json", "tsConfig": "apps/client/tsconfig.app.json",
"assets": [], "assets": [],
"styles": [ "styles": [
"apps/client/src/assets/fonts/inter.css",
"apps/client/src/styles/theme.scss", "apps/client/src/styles/theme.scss",
"apps/client/src/styles.scss" "apps/client/src/styles.scss"
], ],
@ -63,6 +64,10 @@
"baseHref": "/pt/", "baseHref": "/pt/",
"localize": ["pt"] "localize": ["pt"]
}, },
"development-tr": {
"baseHref": "/tr/",
"localize": ["tr"]
},
"production": { "production": {
"fileReplacements": [ "fileReplacements": [
{ {
@ -99,40 +104,43 @@
"options": { "options": {
"commands": [ "commands": [
{ {
"command": "mkdir -p dist/apps/client" "command": "shx mkdir -p dist/apps/client"
}, },
{ {
"command": "cp -r apps/client/src/assets dist/apps/client" "command": "shx cp -r apps/client/src/assets dist/apps/client"
}, },
{ {
"command": "cp -r apps/client/src/assets/.well-known dist/apps/client" "command": "shx cp -r apps/client/src/assets/.well-known dist/apps/client"
}, },
{ {
"command": "cp apps/client/src/assets/favicon.ico dist/apps/client" "command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client"
}, },
{ {
"command": "cp apps/client/src/assets/index.html dist/apps/client" "command": "shx cp apps/client/src/assets/index.html dist/apps/client"
}, },
{ {
"command": "cp apps/client/src/assets/robots.txt dist/apps/client" "command": "shx cp apps/client/src/assets/robots.txt dist/apps/client"
}, },
{ {
"command": "cp apps/client/src/assets/site.webmanifest dist/apps/client" "command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
}, },
{ {
"command": "cp node_modules/ionicons/dist/index.js dist/apps/client" "command": "shx cp -r apps/client/src/locales dist/apps/api/assets"
}, },
{ {
"command": "cp node_modules/ionicons/dist/ionicons.js dist/apps/client" "command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
}, },
{ {
"command": "cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons" "command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
}, },
{ {
"command": "cp CHANGELOG.md dist/apps/client/assets" "command": "shx cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
}, },
{ {
"command": "cp LICENSE dist/apps/client/assets" "command": "shx cp CHANGELOG.md dist/apps/client/assets"
},
{
"command": "shx cp LICENSE dist/apps/client/assets"
} }
] ]
} }
@ -165,6 +173,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 +193,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 +238,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

@ -73,6 +73,11 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule) import('./pages/home/home-page.module').then((m) => m.HomePageModule)
}, },
{
path: 'i18n',
loadChildren: () =>
import('./pages/i18n/i18n-page.module').then((m) => m.I18nPageModule)
},
{ {
path: paths.markets, path: paths.markets,
loadChildren: () => loadChildren: () =>

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,19 @@
</div> </div>
</div> </div>
<gf-header
class="position-fixed w-100"
[currentRoute]="currentRoute"
[deviceType]="deviceType"
[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 +153,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,15 @@ 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 === 'account' ||
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 +156,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,6 +1,6 @@
import { Platform } from '@angular/cdk/platform'; import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http'; import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips'; import { MatChipsModule } from '@angular/material/chips';
import { import {
@ -35,6 +35,7 @@ export function NgxStripeFactory(): string {
} }
@NgModule({ @NgModule({
bootstrap: [AppComponent],
declarations: [AppComponent], declarations: [AppComponent],
imports: [ imports: [
AppRoutingModule, AppRoutingModule,
@ -72,6 +73,6 @@ export function NgxStripeFactory(): string {
useFactory: NgxStripeFactory useFactory: NgxStripeFactory
} }
], ],
bootstrap: [AppComponent] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class AppModule {} export class AppModule {}

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

@ -3,5 +3,9 @@
.mat-mdc-dialog-content { .mat-mdc-dialog-content {
max-height: unset; max-height: unset;
.chart-container {
aspect-ratio: 16 / 9;
}
} }
} }

View File

@ -8,11 +8,11 @@ import {
} from '@angular/core'; } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper'; import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { HistoricalDataItem, User } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import Big from 'big.js'; import Big from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@ -29,13 +29,16 @@ 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 hasImpersonationId: boolean;
public historicalDataItems: HistoricalDataItem[];
public isLoadingChart: boolean;
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;
@ -46,6 +49,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<AccountDetailDialog>, public dialogRef: MatDialogRef<AccountDetailDialog>,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService private userService: UserService
) { ) {
this.userService.stateChanged this.userService.stateChanged
@ -59,21 +63,22 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}); });
} }
public ngOnInit(): void { public ngOnInit() {
this.isLoadingChart = true;
this.dataService this.dataService
.fetchAccount(this.data.accountId) .fetchAccount(this.data.accountId)
.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 +90,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();
@ -101,9 +107,45 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.dataService
.fetchPortfolioPerformance({
filters: [
{
id: this.data.accountId,
type: 'ACCOUNT'
}
],
range: 'max'
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => {
this.historicalDataItems = chart.map(
({ date, value, valueInPercentage }) => {
return {
date,
value:
this.hasImpersonationId || this.user.settings.isRestrictedView
? valueInPercentage
: value
};
}
);
this.isLoadingChart = false;
this.changeDetectorRef.markForCheck();
});
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
} }
public onClose(): void { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }

View File

@ -20,6 +20,17 @@
</div> </div>
</div> </div>
<div class="chart-container mb-3">
<gf-investment-chart
class="h-100"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingChart"
[locale]="user?.settings?.locale"
></gf-investment-chart>
</div>
<div class="row"> <div class="row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
@ -44,8 +55,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

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
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 { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module'; import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -17,6 +18,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
GfActivitiesTableModule, GfActivitiesTableModule,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,
GfInvestmentChartModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,

View File

@ -1,3 +1,14 @@
<div *ngIf="showActions" class="d-flex justify-content-end">
<button
class="align-items-center d-flex"
mat-stroked-button
(click)="onTransferBalance()"
>
<ion-icon class="mr-2" name="arrow-redo-outline"></ion-icon>
<ng-container i18n>Transfer Cash Balance</ng-container>...
</button>
</div>
<table class="gf-table w-100" mat-table matSort [dataSource]="dataSource"> <table class="gf-table w-100" mat-table matSort [dataSource]="dataSource">
<ng-container matColumnDef="status"> <ng-container matColumnDef="status">
<th <th
@ -85,7 +96,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 +104,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

@ -34,6 +34,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Output() accountDeleted = new EventEmitter<string>(); @Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>(); @Output() accountToUpdate = new EventEmitter<AccountModel>();
@Output() transferBalance = new EventEmitter<void>();
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
@ -97,6 +98,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
alert(aComment); alert(aComment);
} }
public onTransferBalance() {
this.transferBalance.emit();
}
public onUpdateAccount(aAccount: AccountModel) { public onUpdateAccount(aAccount: AccountModel) {
this.accountToUpdate.emit(aAccount); this.accountToUpdate.emit(aAccount);
} }

View File

@ -6,6 +6,7 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
import { MatTableDataSource } from '@angular/material/table';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config'; import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
@ -24,7 +25,19 @@ import { takeUntil } from 'rxjs/operators';
export class AdminJobsComponent implements OnDestroy, OnInit { export class AdminJobsComponent implements OnDestroy, OnInit {
public defaultDateTimeFormat: string; public defaultDateTimeFormat: string;
public filterForm: FormGroup; public filterForm: FormGroup;
public jobs: AdminJobs['jobs'] = []; public dataSource: MatTableDataSource<AdminJobs['jobs'][0]> =
new MatTableDataSource();
public displayedColumns = [
'index',
'type',
'symbol',
'dataSource',
'attempts',
'created',
'finished',
'status',
'actions'
];
public statusFilterOptions = QUEUE_JOB_STATUS_LIST; public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
public user: User; public user: User;
@ -102,7 +115,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
.fetchJobs({ status: aStatus }) .fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ jobs }) => { .subscribe(({ jobs }) => {
this.jobs = jobs; this.dataSource = new MatTableDataSource(jobs);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

View File

@ -13,122 +13,158 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</form> </form>
<table class="gf-table w-100"> <table class="gf-table w-100" mat-table [dataSource]="dataSource">
<thead> <ng-container matColumnDef="index">
<tr class="mat-header-row"> <th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
<th class="mat-header-cell px-1 py-2 text-right">#</th> #
<th class="mat-header-cell px-1 py-2" i18n>Type</th> </th>
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th> <td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th> {{ element.id }}
<th class="mat-header-cell px-1 py-2 text-right" i18n>Attempts</th> </td>
<th class="mat-header-cell px-1 py-2" i18n>Created</th> </ng-container>
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
<th class="mat-header-cell px-1 py-2" i18n>Status</th> <ng-container matColumnDef="type">
<th class="mat-header-cell px-1 py-2"> <th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<button <ng-container i18n>Type</ng-container>
class="mx-1 no-min-width px-2" </th>
mat-button <td *matCellDef="let element" class="px-1 py-2" mat-cell>
[matMenuTriggerFor]="jobsActionsMenu" <ng-container *ngIf="element.name === 'GATHER_ASSET_PROFILE'" i18n>
(click)="$event.stopPropagation()" Asset Profile
> </ng-container>
<ion-icon name="ellipsis-vertical"></ion-icon> <ng-container
*ngIf="element.name === 'GATHER_HISTORICAL_MARKET_DATA'"
i18n
>
Historical Market Data
</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="symbol">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Symbol</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.data?.symbol }}
</td>
</ng-container>
<ng-container matColumnDef="dataSource">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Data Source</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.data?.dataSource }}
</td>
</ng-container>
<ng-container matColumnDef="attempts">
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
<ng-container i18n>Attempts</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
{{ element.attemptsMade }}
</td>
</ng-container>
<ng-container matColumnDef="created">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Created</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.timestamp | date: defaultDateTimeFormat }}
</td>
</ng-container>
<ng-container matColumnDef="finished">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Finished</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.finishedOn | date: defaultDateTimeFormat }}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Status</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
<ion-icon
*ngIf="element.state === 'active'"
name="play-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'completed'"
class="text-success"
name="checkmark-circle-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'delayed'"
name="time-outline"
[ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }"
></ion-icon>
<ion-icon
*ngIf="element.state === 'failed'"
class="text-danger"
name="alert-circle-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'paused'"
name="pause-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'waiting'"
name="cafe-outline"
></ion-icon>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="jobsActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onDeleteJobs()">
<ng-container i18n>Delete Jobs</ng-container>
</button> </button>
<mat-menu #jobsActionsMenu="matMenu" xPosition="before"> </mat-menu>
<button mat-menu-item (click)="onDeleteJobs()"> </th>
<ng-container i18n>Delete Jobs</ng-container> <td *matCellDef="let element" class="px-1 py-2" mat-cell>
</button> <button
</mat-menu> class="mx-1 no-min-width px-2"
</th> mat-button
</tr> [matMenuTriggerFor]="jobActionsMenu"
</thead> (click)="$event.stopPropagation()"
<tbody> >
<ng-container *ngFor="let job of jobs"> <ion-icon name="ellipsis-horizontal"></ion-icon>
<tr class="mat-row"> </button>
<td class="mat-cell px-1 py-2 text-right">{{ job.id }}</td> <mat-menu #jobActionsMenu="matMenu" xPosition="before">
<td class="mat-cell px-1 py-2"> <button mat-menu-item (click)="onViewData(element.data)">
<span class="align-items-center d-flex"> <ng-container i18n>View Data</ng-container>
<ion-icon </button>
class="mr-1" <button
name="arrow-down-circle-outline" mat-menu-item
></ion-icon> [disabled]="element.stacktrace?.length <= 0"
<ng-container *ngIf="job.name === 'GATHER_ASSET_PROFILE'"> (click)="onViewStacktrace(element.stacktrace)"
<span i18n>Asset Profile</span> >
</ng-container> <ng-container i18n>View Stacktrace</ng-container>
<ng-container </button>
*ngIf="job.name === 'GATHER_HISTORICAL_MARKET_DATA'" <button mat-menu-item (click)="onDeleteJob(element.id)">
> <ng-container i18n>Delete Job</ng-container>
<span i18n>Historical Market Data</span> </button>
</ng-container> </mat-menu>
</span> </td>
</td> </ng-container>
<td class="mat-cell px-1 py-2">{{ job.data?.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ job.data?.dataSource }}</td> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<td class="mat-cell px-1 py-2 text-right"> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
{{ job.attemptsMade }}
</td>
<td class="mat-cell px-1 py-2">
{{ job.timestamp | date: defaultDateTimeFormat }}
</td>
<td class="mat-cell px-1 py-2">
{{ job.finishedOn | date: defaultDateTimeFormat }}
</td>
<td class="mat-cell px-1 py-2">
<ion-icon
*ngIf="job.state === 'active'"
name="play-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'completed'"
class="text-success"
name="checkmark-circle-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'delayed'"
name="time-outline"
[ngClass]="{ 'text-danger': job.stacktrace?.length > 0 }"
></ion-icon>
<ion-icon
*ngIf="job.state === 'failed'"
class="text-danger"
name="alert-circle-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'paused'"
name="pause-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'waiting'"
name="cafe-outline"
></ion-icon>
</td>
<td class="mat-cell px-1 py-2">
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="jobActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onViewData(job.data)">
<ng-container i18n>View Data</ng-container>
</button>
<button
mat-menu-item
[disabled]="job.stacktrace?.length <= 0"
(click)="onViewStacktrace(job.stacktrace)"
>
<ng-container i18n>View Stacktrace</ng-container>
</button>
<button mat-menu-item (click)="onDeleteJob(job.id)">
<ng-container i18n>Delete Job</ng-container>
</button>
</mat-menu>
</td>
</tr>
</ng-container>
</tbody>
</table> </table>
</div> </div>
</div> </div>

View File

@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
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 { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { AdminJobsComponent } from './admin-jobs.component'; import { AdminJobsComponent } from './admin-jobs.component';
@ -15,6 +16,7 @@ import { AdminJobsComponent } from './admin-jobs.component';
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatSelectModule, MatSelectModule,
MatTableModule,
ReactiveFormsModule ReactiveFormsModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -29,7 +29,7 @@
}" }"
[title]=" [title]="
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1) (itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
| date : defaultDateFormat) ?? '' | date: defaultDateFormat) ?? ''
" "
(click)=" (click)="
onOpenMarketDataDetail({ onOpenMarketDataDetail({

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