Compare commits

..

133 Commits

Author SHA1 Message Date
ff111d4c6c Release 1.247.0 (#1801) 2023-03-23 19:26:01 +01:00
bc6e9a8b68 Bugfix/fix total amount calculation in portfolio evolution chart (#1799)
* Fix total amount calculation

* Update changelog
2023-03-23 19:23:31 +01:00
bd1963ec26 Feature/add asset and asset sub class to search endpoint (#1795)
* Add asset and asset sub class to search endpoint

* Update changelog
2023-03-23 19:11:38 +01:00
a0bec9e97f Feature/remove mail address part 2 (#1800)
* Remove mail address and update Slack url

* Update changelog
2023-03-23 14:14:31 +01:00
c45df20d88 Sort imports (#1797) 2023-03-21 19:34:40 +01:00
fa1d669633 Feature/upgrade prisma to version 4.11.0 (#1798)
* Upgrade prisma to version 4.11.0

* Update changelog
2023-03-20 20:08:08 +01:00
1009b462e9 Feature/add subscription expiration dates to the admin control panel (#1796)
* Add expiration date

* Update changelog
2023-03-19 12:03:47 +01:00
b404858904 Release 1.246.0 (#1794) 2023-03-18 10:36:40 +01:00
7ec033577f Feature/extend trackinsight data enhancer by isin (#1793)
* Extend data enhancer by isin

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

* asset and asset sub class
* isin

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

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

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

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

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

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

* Add debounce
* Persist annualInterestRate
* Partially disable date picker

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

* Update changelog

---------

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

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

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

* Update changelog

---------

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

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

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

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

* Update documentation

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

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

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

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

* Consider language of user in Stripe checkout

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

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

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

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

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

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

* Ignore .env file

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

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

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

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

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

* Update changelog

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Update changelog
2023-02-16 16:25:23 +01:00
0ec50819f5 Release 1.234.0 (#1703) 2023-02-15 10:52:02 +01:00
c9abe818bc Revert import (#1702) 2023-02-15 10:50:19 +01:00
bfa32537a8 Feature/improve usability of import activities action (#1695)
* Improve usability of import activities action

* Update changelog
2023-02-15 10:07:25 +01:00
cef15afab8 Add styling (#1701) 2023-02-15 10:01:35 +01:00
1b9587c454 Update default coupon duration (#1700) 2023-02-15 10:00:04 +01:00
de76b0d8c3 Feature/add data import and export to pricing page (#1697)
* Add data import and export

* Update changelog
2023-02-15 09:52:09 +01:00
e62989c981 Feature/copy logic of ghostfolio scraper api service to manual service (#1691)
* Copy logic of GhostfolioScraperApiService to ManualService

* Update changelog
2023-02-15 09:50:31 +01:00
d6b71e6314 Bugfix/fix links in subscription interstitial dialog (#1696)
* Fix links

* Update changelog
2023-02-14 18:25:12 +01:00
8c59bfd6d7 Feature/upgrade prisma to version 4.10.1 (#1688)
* Upgrade prisma to version 4.10.1

* Update changelog
2023-02-14 11:35:04 +01:00
f32df73256 Feature/migrate pages to angular material 15 (#1689)
* Migrate to Angular Material 15

* Update changelog
2023-02-14 10:04:22 +01:00
9d03a8002c Feature/improve content of faq and landing page (#1687)
* Conditionally show content

* Update changelog
2023-02-13 09:41:25 +01:00
3c36ca29af Feature/upgrade ionicons to version 6.1.2 (#1676)
* Upgrade ionicons to version 6.1.2

* Update changelog
2023-02-12 10:08:04 +01:00
efed7e3c2b Modify default exposed port (#1681)
* Modify default exposed port

* Update changelog
2023-02-11 10:37:44 +01:00
b09d3cea95 Fix landing page by setting a default value for countriesOfSubscribers
* Set default value for countriesOfSubscribers

* Update changelog
2023-02-11 09:57:27 +01:00
eabd2f3934 Add url (#1683) 2023-02-11 09:55:03 +01:00
cc184c2827 Feature/upgrade prettier to version 2.8.4 (#1675)
* Upgrade prettier to version 2.8.4

* Update changelog
2023-02-10 09:27:26 +01:00
436f791fa4 Feature/upgrade chart.js to version 4.2.0 (#1567)
* Upgrade chart.js to version 4.2.0

* Update changelog
2023-02-09 21:22:55 +01:00
e935a57dec Release 1.233.0 (#1678) 2023-02-09 20:30:53 +01:00
203909d917 Feature/upgrade eslint dependencies (#1674)
* Upgrade eslint dependencies

* Update changelog
2023-02-09 10:22:50 +01:00
eed4f57f30 Clean up (#1669) 2023-02-09 09:59:29 +01:00
7878036bac Feature/remove google play badge from landing page (#1672)
* Remove Google Play badge

* Update changelog
2023-02-08 14:17:49 +01:00
75d140b436 Harmonize file name (#1662) 2023-02-07 08:44:22 +01:00
a79f31b006 Feature/add accounts import export (#1635)
* Add accounts to activities export

* Add logic for importing accounts

* Update changelog
2023-02-06 21:59:59 +01:00
45cfd61dbb Feature/improve styling in admin control panel (#1665)
* Improve styling

* Update changelog
2023-02-06 11:35:56 +01:00
7fcfca952e Release 1.232.0 (#1664) 2023-02-05 19:46:18 +01:00
279f16cc67 Feature/extract locales 20230205 (#1663)
* Extract locales

* Update changelog
2023-02-05 19:44:33 +01:00
e7b1d8a5d3 Feature/upgrade ngx markdown to version 15.1.0 (#1657)
* Upgrade ngx-markdown to version 15.1.0

* Update changelog
2023-02-05 18:57:35 +01:00
1b2f8e5586 Feature/extend analytics by country (#1661)
* Extend analytics by country

* Fix Upgrade Plan button of subscription interstitial

* Update changelog
2023-02-05 18:57:12 +01:00
e4468252c6 Feature/upgrade ng extract i18n merge to version 2.5.0 (#1656)
* Upgrade ng-extract-i18n-merge to version 2.5.0

* Update changelog
2023-02-05 11:44:06 +01:00
ad3ebd42bb Feature/migrate mat suffix to angular material 15 (#1655)
* Migrate matSuffix to @angular/material 15

* Update changelog
2023-02-05 09:49:37 +01:00
55b03733f4 Release 1.231.0 (#1654) 2023-02-04 11:28:48 +01:00
0000317041 Upgrade Nx and Angular (#1646)
* Upgrade Nx and Angular

* Update changelog

* Feature/eliminate angular material css vars (#1648)

* Eliminate angular-material-css-vars

* Update changelog
2023-02-04 11:26:06 +01:00
e5f2a3865d Update types of node and papaparse (#1650) 2023-02-03 13:27:59 +01:00
c61561664f Relax validation for REDIS_HOST (#1652)
* Relax validation for REDIS_HOST

* Update changelog
2023-02-02 13:39:13 +01:00
a7d8a63ab8 Remove year (#1649) 2023-02-01 08:58:17 +01:00
5c51c1e825 Reorder features (#1644) 2023-01-30 20:22:03 +01:00
3a67bf9bb4 Add /de/pricing and /de/blog (#1645) 2023-01-30 20:21:37 +01:00
f7597c213d Feature/add dividend and fees to position detail dialog (#1643)
* Add dividend and fees to position detail dialog

* Update changelog
2023-01-30 20:21:16 +01:00
2e7f46ad78 Feature/allow account for activity of type item (#1641)
* Support linking wealth items to account

* Update changelog
2023-01-30 20:00:07 +01:00
cfffb99f52 Feature/extract locales 20230129 (#1642)
* Improve translations

* Update changelog
2023-01-30 19:45:27 +01:00
69ac3408f1 Release 1.230.0 (#1639) 2023-01-29 10:00:28 +01:00
e1806b4bd8 Feature/add sourceforge logo to landing page (#1638)
* Add SourceForge

* Update changelog
2023-01-29 09:58:53 +01:00
6aae0cc1e4 Bugfix/fix issue with value in value redaction interceptor (#1627)
* Fix issue with value in value redaction interceptor

* Fix format of world map

* Update changelog
2023-01-29 09:58:05 +01:00
5d8a50a80d Feature/add interstitial for subscription (#1637)
* Add interstitial

* Improve pricing page

* Update changelog
2023-01-28 09:42:15 +01:00
662231e830 Feature/upgrade prisma to version 4.9.0 (#1630)
* Upgrade prisma to version 4.9.0

* Update changelog
2023-01-28 09:41:33 +01:00
4d84459b5b Clean up imports (#1632) 2023-01-27 08:49:31 +01:00
efba7429c1 Bugfix/fix click of unknown accounts (#1629)
* Check for unknown key

* Update changelog
2023-01-25 12:00:52 +01:00
9cae5a3e79 Feature/improve sackgeld.com blog post (#1628)
* Improve blog post and add quote

* Update changelog
2023-01-24 08:11:21 +01:00
c2ed0a436f Downgrade to Node.js 16 (for development) (#1633) 2023-01-23 21:52:46 +01:00
8486c02575 Update Node to version 18.x (#1595)
* Update Node to version 18.x

* Add .nvmrc

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-01-23 08:28:19 +01:00
5122ef3456 Release 1.229.0 (#1626) 2023-01-21 16:10:04 +01:00
579b86665e Feature/add sackgeld.com blog post (#1625)
* Add blog post: Ghostfolio auf Sackgeld.com vorgestellt

* Update changelog
2023-01-21 16:07:32 +01:00
52b3ad6dc3 Feature/refactor value redaction interceptor (#1624)
* Reuse redactAttributes()

* Update changelog
2023-01-21 11:46:56 +01:00
bf9b60aa74 Feature/add sackgeld.com to as seen in section (#1622)
* Add Sackgeld.com

* Update changelog
2023-01-21 11:31:52 +01:00
6cd51fb044 Feature/hide irrelevant errors in client (#1623)
* Hide irrelevant errors in client

* Update changelog
2023-01-21 11:30:53 +01:00
271001f523 Feature/remove toggle on allocations page (#1620)
* Rename allocationCurrent, remove allocationInvestment

* Update changelog
2023-01-21 09:52:58 +01:00
a7e513a6d1 Bugfix/fix filtered value for emergency fund (#1619)
* Fix filtered value

* Update changelog
2023-01-20 20:51:08 +01:00
b5f256be95 Remove mail address (#1621) 2023-01-20 20:50:32 +01:00
279 changed files with 18785 additions and 5913 deletions

View File

View File

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

1
.gitignore vendored
View File

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

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v16

View File

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

View File

@ -5,6 +5,286 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.247.0 - 2023-03-23
### Added
- Added the asset and asset sub class to the search functionality
- Added the subscription expiration date to the users table of the admin control panel
### Changed
- Updated the URL of the Ghostfolio Slack channel
- Upgraded `prisma` from version `4.10.1` to `4.11.0`
### Fixed
- Fixed the total amount calculation in the portfolio evolution chart
## 1.246.0 - 2023-03-18
### Added
- Added support for asset and asset sub class to the `EOD_HISTORICAL_DATA` data source type
- Added `isin` to the asset profile model
### Changed
- Extended the _Trackinsight_ data enhancer for asset profile data by `isin`
- Improved the language localization for _Gather Data_
### Fixed
- Fixed the border color in the _FIRE_ calculator (dark mode)
## 1.245.0 - 2023-03-12
### Added
- Added the search functionality for the `EOD_HISTORICAL_DATA` data source type
### Changed
- Improved the usability of the _FIRE_ calculator
- Improved the exchange rate service for a specific date used in activities with a manual currency
- Upgraded `ngx-device-detector` from version `3.0.0` to `5.0.1`
## 1.244.0 - 2023-03-09
### Added
- Extended the _FIRE_ calculator by a retirement date setting
## 1.243.0 - 2023-03-08
### Added
- Added `COINGECKO` as a default to `DATA_SOURCES`
### Changed
- Improved the validation of the manual currency for the activity fee and unit price
- Harmonized the axis style of charts
- Made setting `NODE_ENV: production` optional (to avoid `ENOENT: no such file or directory` errors on startup)
- Removed the environment variable `ENABLE_FEATURE_CUSTOM_SYMBOLS`
## 1.242.0 - 2023-03-04
### Changed
- Simplified the database seeding
- Upgraded `ngx-skeleton-loader` from version `5.0.0` to `7.0.0`
### Fixed
- Downgraded `Node.js` from version `18` to `16` (Dockerfile) to resolve `SIGSEGV` (segmentation fault) during the `prisma` database migrations (see https://github.com/prisma/prisma/issues/10649)
## 1.241.0 - 2023-03-01
### Changed
- Filtered activities with type `ITEM` from search results
- Considered the user's language in the _Stripe_ checkout
- Upgraded the _Stripe_ dependencies
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
## 1.240.0 - 2023-02-26
### Added
- Supported a manual currency for the activity unit price
### Fixed
- Fixed the feature graphic of the _Ghostfolio meets Umbrel_ blog post
## 1.239.0 - 2023-02-25
### Added
- Added a blog post: _Ghostfolio meets Umbrel_
### Changed
- Removed the dependency `rimraf`
## 1.238.0 - 2023-02-25
### Added
- Added `COINGECKO` as a new data source type
- Added support for data provider information to the position detail dialog
- Added the configuration to publish a `linux/arm/v7` docker image
- Added _Reddit_ to the _As seen in_ section on the landing page
- Added _Umbrel_ to the _As seen in_ section on the landing page
### Changed
- Renamed the example environment variable file from `.env` to `.env.example`
- Upgraded `zone.js` from version `0.11.8` to `0.12.0`
### Fixed
- Fixed `RangeError: Maximum call stack size exceeded` for values of type `Big` in the value redaction interceptor for the impersonation mode
- Reset the letter spacing in buttons
### Todo
- Ensure that you still have a `.env` file in your project
## 1.237.0 - 2023-02-19
### Added
- Added the support details to the pricing page
### Changed
- Increased the file size limit for the activities import
- Improved the style of the search results for symbols
- Migrated the style of `GfHeaderModule` to `@angular/material` `15` (mdc)
- Upgraded `angular` from version `15.1.2` to `15.1.5`
- Upgraded `Nx` from version `15.6.3` to `15.7.2`
### Fixed
- Fixed an issue with exact matches in the activities table filter (`VT` vs. `VTI`)
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
## 1.236.0 - 2023-02-17
### Changed
- Beautified the ETF names in the asset profile
- Removed the data source type `GHOSTFOLIO`
### Fixed
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
- Fixed the buying power calculation if no emergency fund is set but an activity is tagged as _Emergency Fund_
- Fixed the url on logout during the local development
## 1.235.0 - 2023-02-16
### Changed
- Improved the styles on the about page
- Eliminated the `GhostfolioScraperApiService`
## 1.234.0 - 2023-02-15
### Added
- Added the data import and export feature to the pricing page
### Changed
- Copy the logic of `GhostfolioScraperApiService` to `ManualService`
- Improved the content of the landing page
- Improved the content of the Frequently Asked Questions (FAQ) page
- Improved the usability of the _Import Activities..._ action
- Eliminated the permission `enableImport`
- Set the exposed port as an environment variable (`PORT`) in `Dockerfile`
- Migrated the style of `AboutPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `BlogPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `ChangelogPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `ResourcesPageModule` to `@angular/material` `15` (mdc)
- Upgraded `chart.js` from version `4.0.1` to `4.2.0`
- Upgraded `ionicons` from version `6.0.4` to `6.1.2`
- Upgraded `prettier` from version `2.8.1` to `2.8.4`
- Upgraded `prisma` from version `4.9.0` to `4.10.1`
### Fixed
- Fixed an issue on the landing page caused by the global heat map of subscribers
- Fixed the links in the interstitial for the subscription
### Todo
- Remove the environment variable `ENABLE_FEATURE_IMPORT`
- Rename the `dataSource` from `GHOSTFOLIO` to `MANUAL`
- Eliminate `GhostfolioScraperApiService`
## 1.233.0 - 2023-02-09
### Added
- Added support to export accounts
- Added suport to import accounts
### Changed
- Improved the styling in the admin control panel
- Removed the _Google Play_ badge from the landing page
- Upgraded `eslint` dependencies
## 1.232.0 - 2023-02-05
### Changed
- Improved the language localization for German (`de`)
- Migrated the style of `ActivitiesPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `GfCreateOrUpdateActivityDialogModule` to `@angular/material` `15` (mdc)
- Migrated the style of `GfMarketDataDetailDialogModule` to `@angular/material` `15` (mdc)
- Upgraded `ng-extract-i18n-merge` from version `2.1.2` to `2.5.0`
- Upgraded `ngx-markdown` from version `14.0.1` to `15.1.0`
### Fixed
- Fixed the `Upgrade Plan` button of the interstitial for the subscription
## 1.231.0 - 2023-02-04
### Added
- Added the dividend and fees to the position detail dialog
- Added support to link a (wealth) item to an account
### Changed
- Relaxed the validation rule of the _Redis_ host environment variable (`REDIS_HOST`)
- Improved the language localization for German (`de`)
- Eliminated `angular-material-css-vars`
- Upgraded `angular` from version `14.2.0` to `15.1.2`
- Upgraded `Nx` from version `15.0.13` to `15.6.3`
## 1.230.0 - 2023-01-29
### Added
- Added an interstitial for the subscription
- Added _SourceForge_ to the _As seen in_ section on the landing page
- Added a quote to the blog post _Ghostfolio auf Sackgeld.com vorgestellt_
### Changed
- Improved the unit format (`%`) in the global heat map component of the public page
- Improved the pricing page
- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`)
- Upgraded `prisma` from version `4.8.0` to `4.9.0`
### Fixed
- Fixed the click of unknown accounts in the portfolio proportion chart component
- Fixed an issue with `value` in the value redaction interceptor for the impersonation mode
## 1.229.0 - 2023-01-21
### Added
- Added a blog post: _Ghostfolio auf Sackgeld.com vorgestellt_
- Added _Sackgeld.com_ to the _As seen in_ section on the landing page
### Changed
- Removed the toggle _Original Shares_ vs. _Current Shares_ on the allocations page
- Hid error messages related to no current investment in the client
- Refactored the value redaction interceptor for the impersonation mode
### Fixed
- Fixed the value of the active (emergency fund) filter in percentage on the allocations page
## 1.228.1 - 2023-01-18 ## 1.228.1 - 2023-01-18
### Added ### Added
@ -604,7 +884,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added the alias to the `Access` database schema - Added the alias to the `Access` database schema
- Added support for translated time distances - Added support for translated time distances
- Added a _GitHub Action_ to create an `arm64` docker image - Added a _GitHub Action_ to create an `linux/arm64` docker image
### Changed ### Changed
@ -1217,7 +1497,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Beautified the ETF names in the symbol profile - Beautified the ETF names in the asset profile
### Fixed ### Fixed
@ -1642,7 +1922,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Extended the historical data view in the admin control panel - Extended the historical data view in the admin control panel
- Upgraded _Stripe_ dependencies - Upgraded the _Stripe_ dependencies
- Upgraded `prisma` from version `3.7.0` to `3.8.1` - Upgraded `prisma` from version `3.7.0` to `3.8.1`
### Fixed ### Fixed

25
DEVELOPMENT.md Normal file
View File

@ -0,0 +1,25 @@
# Ghostfolio Development Guide
## Git
### Rebase
`git rebase -i --autosquash main`
## Dependencies
### Nx
#### Upgrade
1. Run `yarn nx migrate latest`
1. Make sure `package.json` changes make sense and then run `yarn install`
1. Run `yarn nx migrate --run-migrations`
### Prisma
#### Create schema migration (local)
Run `yarn prisma migrate dev --name added_job_title`
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate

View File

@ -57,5 +57,5 @@ RUN apt update && apt install -y \
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE 3333 EXPOSE ${PORT:-3333}
CMD [ "yarn", "start:prod" ] CMD [ "yarn", "start:prod" ]

View File

@ -40,7 +40,7 @@ Ghostfolio is for you if you are...
- 🧘 into minimalism - 🧘 into minimalism
- 🧺 caring about diversifying your financial resources - 🧺 caring about diversifying your financial resources
- 🆓 interested in financial independence - 🆓 interested in financial independence
- 🙅 saying no to spreadsheets in 2023 - 🙅 saying no to spreadsheets
- 😎 still reading this list - 😎 still reading this list
## Features ## Features
@ -75,7 +75,7 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
## Self-hosting ## Self-hosting
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`. We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`.
<div align="center"> <div align="center">
@ -85,20 +85,19 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Default Value | Description | | Name | Default Value | Description |
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application.<br />`AUD` \| `CAD` \| `CNY` \| `EUR` \| `GBP` \| `JPY` \| `RUB` \| `USD`<br />Caution: Only set if you intend to track cryptocurrencies in a non-`USD` currency. This cannot be changed later! | | `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | | `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on | | `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) | | `PORT` | `3333` | The port where the Ghostfolio application will run on |
| `PORT` | `3333` | The port where the Ghostfolio application will run on | | `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database | | `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database | | `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_HOST` | | The host where _Redis_ is running | | `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PASSWORD` | | The password of _Redis_ | | `REDIS_PORT` | | The port where _Redis_ is running |
| `REDIS_PORT` | | The port where _Redis_ is running |
### Run with Docker Compose ### Run with Docker Compose
@ -106,7 +105,8 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
- Basic knowledge of Docker - Basic knowledge of Docker
- Installation of [Docker](https://www.docker.com/products/docker-desktop) - Installation of [Docker](https://www.docker.com/products/docker-desktop)
- Local copy of this Git repository (clone) - Create a local copy of this Git repository (clone)
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
#### a. Run environment #### a. Run environment
@ -125,13 +125,10 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
``` ```
#### Fetch Historical Data #### Setup
Open http://localhost:3333 in your browser and accomplish these steps:
1. Open http://localhost:3333 in your browser
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`) 1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
#### Upgrade Version #### Upgrade Version
@ -148,20 +145,20 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 16+) - [Node.js](https://nodejs.org/en/download) (version 16)
- [Yarn](https://yarnpkg.com/en/docs/install) - [Yarn](https://yarnpkg.com/en/docs/install)
- A local copy of this Git repository (clone) - Create a local copy of this Git repository (clone)
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
### Setup ### Setup
1. Run `yarn install` 1. Run `yarn install`
1. Run `yarn build:dev` to build the source code including the assets 1. Run `yarn build:dev` to build the source code including the assets
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) 1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data 1. Run `yarn database:setup` to initialize the database schema
1. Start the server and the client (see [_Development_](#Development)) 1. Start the server and the client (see [_Development_](#Development))
1. Open http://localhost:4200/en in your browser
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`) 1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
### Start Server ### Start Server
@ -175,7 +172,7 @@ Run `yarn start:server`
### Start Client ### Start Client
Run `yarn start:client` Run `yarn start:client` and open http://localhost:4200/en in your browser
### Start _Storybook_ ### Start _Storybook_
@ -269,7 +266,7 @@ You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonym
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you. Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio). If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).

View File

@ -1,10 +1,7 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import {
nullifyValuesInObject,
nullifyValuesInObjects
} from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { Accounts } from '@ghostfolio/common/interfaces'; import { Accounts } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { import type {
@ -22,7 +19,8 @@ import {
Param, Param,
Post, Post,
Put, Put,
UseGuards UseGuards,
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -39,8 +37,7 @@ export class AccountController {
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser
private readonly userService: UserService
) {} ) {}
@Delete(':id') @Delete(':id')
@ -85,8 +82,9 @@ export class AccountController {
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAllAccounts( public async getAllAccounts(
@Headers('impersonation-id') impersonationId @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
): Promise<Accounts> { ): Promise<Accounts> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId( await this.impersonationService.validateImpersonationId(
@ -94,41 +92,17 @@ export class AccountController {
this.request.user.id this.request.user.id
); );
let accountsWithAggregations = return this.portfolioService.getAccountsWithAggregations({
await this.portfolioService.getAccountsWithAggregations({ userId: impersonationUserId || this.request.user.id,
userId: impersonationUserId || this.request.user.id, withExcludedAccounts: true
withExcludedAccounts: true });
});
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'balanceInBaseCurrency',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value',
'valueInBaseCurrency'
])
};
}
return accountsWithAggregations;
} }
@Get(':id') @Get(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById( public async getAccountById(
@Headers('impersonation-id') impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Param('id') id: string @Param('id') id: string
): Promise<AccountWithValue> { ): Promise<AccountWithValue> {
const impersonationUserId = const impersonationUserId =
@ -137,35 +111,13 @@ export class AccountController {
this.request.user.id this.request.user.id
); );
let accountsWithAggregations = const accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations({ await this.portfolioService.getAccountsWithAggregations({
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true withExcludedAccounts: true
}); });
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'balanceInBaseCurrency',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value',
'valueInBaseCurrency'
])
};
}
return accountsWithAggregations.accounts[0]; return accountsWithAggregations.accounts[0];
} }

View File

@ -17,6 +17,10 @@ export class CreateAccountDto {
@IsString() @IsString()
currency: string; currency: string;
@IsOptional()
@IsString()
id?: string;
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isExcluded?: boolean; isExcluded?: boolean;

View File

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

View File

@ -1,24 +1,23 @@
import { join } from 'path'; import { join } from 'path';
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { ConfigurationModule } from '../services/configuration.module';
import { CronService } from '../services/cron.service';
import { DataGatheringModule } from '../services/data-gathering.module';
import { DataProviderModule } from '../services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '../services/exchange-rate-data.module';
import { PrismaModule } from '../services/prisma.module';
import { TwitterBotModule } from '../services/twitter-bot/twitter-bot.module';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module'; import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
@ -30,6 +29,7 @@ import { InfoModule } from './info/info.module';
import { LogoModule } from './logo/logo.module'; import { LogoModule } from './logo/logo.module';
import { OrderModule } from './order/order.module'; import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.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 { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@ -45,7 +45,7 @@ import { UserModule } from './user/user.module';
BullModule.forRoot({ BullModule.forRoot({
redis: { redis: {
host: process.env.REDIS_HOST, host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT, 10), port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
password: process.env.REDIS_PASSWORD password: process.env.REDIS_PASSWORD
} }
}), }),

View File

@ -61,8 +61,10 @@ export class AuthService {
// Create new user if not found // Create new user if not found
user = await this.userService.createUser({ user = await this.userService.createUser({
provider, data: {
thirdPartyId: principalId provider,
thirdPartyId: principalId
}
}); });
} }
@ -96,8 +98,10 @@ export class AuthService {
// Create new user if not found // Create new user if not found
user = await this.userService.createUser({ user = await this.userService.createUser({
provider, data: {
thirdPartyId provider,
thirdPartyId
}
}); });
} }

View File

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

View File

@ -1,6 +1,13 @@
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { Controller, Get, Param, UseGuards } from '@nestjs/common'; import {
Controller,
Get,
HttpException,
Param,
UseGuards
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExchangeRateService } from './exchange-rate.service'; import { ExchangeRateService } from './exchange-rate.service';
@ -18,9 +25,18 @@ export class ExchangeRateController {
): Promise<IDataProviderHistoricalResponse> { ): Promise<IDataProviderHistoricalResponse> {
const date = new Date(dateString); const date = new Date(dateString);
return this.exchangeRateService.getExchangeRate({ const exchangeRate = await this.exchangeRateService.getExchangeRate({
date, date,
symbol symbol
}); });
if (exchangeRate) {
return { marketPrice: exchangeRate };
}
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
} }
} }

View File

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

View File

@ -14,6 +14,22 @@ export class ExportService {
activityIds?: string[]; activityIds?: string[];
userId: string; userId: string;
}): Promise<Export> { }): Promise<Export> {
const accounts = await this.prismaService.account.findMany({
orderBy: {
name: 'asc'
},
select: {
accountType: true,
balance: true,
currency: true,
id: true,
isExcluded: true,
name: true,
platformId: true
},
where: { userId }
});
let activities = await this.prismaService.order.findMany({ let activities = await this.prismaService.order.findMany({
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
select: { select: {
@ -38,6 +54,7 @@ export class ExportService {
return { return {
meta: { date: new Date().toISOString(), version: environment.version }, meta: { date: new Date().toISOString(), version: environment.version },
accounts,
activities: activities.map( activities: activities.map(
({ ({
accountId, accountId,

View File

@ -1,11 +1,11 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Injectable, NestMiddleware } from '@nestjs/common'; import { Injectable, NestMiddleware } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
@ -18,18 +18,10 @@ export class FrontendMiddleware implements NestMiddleware {
public indexHtmlIt = ''; public indexHtmlIt = '';
public indexHtmlNl = ''; public indexHtmlNl = '';
public indexHtmlPt = ''; public indexHtmlPt = '';
public isProduction: boolean;
public constructor( public constructor(
private readonly configService: ConfigService,
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) { ) {
const NODE_ENV =
this.configService.get<'development' | 'production'>('NODE_ENV') ??
'development';
this.isProduction = NODE_ENV === 'production';
try { try {
this.indexHtmlDe = fs.readFileSync( this.indexHtmlDe = fs.readFileSync(
this.getPathOfIndexHtmlFile('de'), this.getPathOfIndexHtmlFile('de'),
@ -83,12 +75,24 @@ export class FrontendMiddleware implements NestMiddleware {
) { ) {
featureGraphicPath = 'assets/images/blog/20221226.jpg'; featureGraphicPath = 'assets/images/blog/20221226.jpg';
title = `The importance of tracking your personal finances - ${title}`; title = `The importance of tracking your personal finances - ${title}`;
} else if (
request.path.startsWith(
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt'
)
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
} else if (
request.path.startsWith('/en/blog/2023/02/ghostfolio-meets-umbrel')
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
title = `Ghostfolio meets Umbrel - ${title}`;
} }
if ( if (
request.path.startsWith('/api/') || request.path.startsWith('/api/') ||
this.isFileRequest(request.url) || this.isFileRequest(request.url) ||
!this.isProduction !environment.production
) { ) {
// Skip // Skip
next(); next();

View File

@ -1,8 +1,15 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator'; import { IsArray, IsOptional, ValidateNested } from 'class-validator';
export class ImportDataDto { export class ImportDataDto {
@IsOptional()
@IsArray()
@Type(() => CreateAccountDto)
@ValidateNested({ each: true })
accounts: CreateAccountDto[];
@IsArray() @IsArray()
@Type(() => CreateOrderDto) @Type(() => CreateOrderDto)
@ValidateNested({ each: true }) @ValidateNested({ each: true })

View File

@ -2,6 +2,7 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ImportResponse } from '@ghostfolio/common/interfaces'; import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -20,7 +21,6 @@ import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isEmpty } from 'lodash';
import { ImportDataDto } from './import-data.dto'; import { ImportDataDto } from './import-data.dto';
import { ImportService } from './import.service'; import { ImportService } from './import.service';
@ -39,7 +39,13 @@ export class ImportController {
@Body() importData: ImportDataDto, @Body() importData: ImportDataDto,
@Query('dryRun') isDryRun?: boolean @Query('dryRun') isDryRun?: boolean
): Promise<ImportResponse> { ): Promise<ImportResponse> {
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) { if (
!hasPermission(
this.request.user.permissions,
permissions.createAccount
) ||
!hasPermission(this.request.user.permissions, permissions.createOrder)
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -61,9 +67,10 @@ export class ImportController {
try { try {
const activities = await this.importService.import({ const activities = await this.importService.import({
maxActivitiesToImport,
isDryRun, isDryRun,
maxActivitiesToImport,
userCurrency, userCurrency,
accountsDto: importData.accounts ?? [],
activitiesDto: importData.activities, activitiesDto: importData.activities,
userId: this.request.user.id userId: this.request.user.id
}); });

View File

@ -1,4 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
@ -100,18 +101,75 @@ export class ImportService {
} }
public async import({ public async import({
accountsDto,
activitiesDto, activitiesDto,
isDryRun = false, isDryRun = false,
maxActivitiesToImport, maxActivitiesToImport,
userCurrency, userCurrency,
userId userId
}: { }: {
accountsDto: Partial<CreateAccountDto>[];
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
isDryRun?: boolean; isDryRun?: boolean;
maxActivitiesToImport: number; maxActivitiesToImport: number;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<Activity[]> { }): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {};
if (!isDryRun && accountsDto?.length) {
const existingAccounts = await this.accountService.accounts({
where: {
id: {
in: accountsDto.map(({ id }) => {
return id;
})
}
}
});
for (const account of accountsDto) {
// Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find(
(existingAccount) => existingAccount.id === account.id
);
// If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== userId) {
let oldAccountId: string;
const platformId = account.platformId;
delete account.platformId;
if (accountWithSameId) {
oldAccountId = account.id;
delete account.id;
}
const newAccountObject = {
...account,
User: { connect: { id: userId } }
};
if (platformId) {
Object.assign(newAccountObject, {
Platform: { connect: { id: platformId } }
});
}
const newAccount = await this.accountService.createAccount(
newAccountObject,
userId
);
// Store the new to old account ID mappings for updating activities
if (accountWithSameId && oldAccountId) {
accountIdMapping[oldAccountId] = newAccount.id;
}
}
}
}
for (const activity of activitiesDto) { for (const activity of activitiesDto) {
if (!activity.dataSource) { if (!activity.dataSource) {
if (activity.type === 'ITEM') { if (activity.type === 'ITEM') {
@ -120,6 +178,13 @@ export class ImportService {
activity.dataSource = this.dataProviderService.getPrimaryDataSource(); activity.dataSource = this.dataProviderService.getPrimaryDataSource();
} }
} }
// If a new account is created, then update the accountId in all activities
if (!isDryRun) {
if (Object.keys(accountIdMapping).includes(activity.accountId)) {
activity.accountId = accountIdMapping[activity.accountId];
}
}
} }
const assetProfiles = await this.validateActivities({ const assetProfiles = await this.validateActivities({
@ -128,12 +193,18 @@ export class ImportService {
userId userId
}); });
const accountIds = (await this.accountService.getAccounts(userId)).map( const accounts = (await this.accountService.getAccounts(userId)).map(
(account) => { (account) => {
return account.id; return { id: account.id, name: account.name };
} }
); );
if (isDryRun) {
accountsDto.forEach(({ id, name }) => {
accounts.push({ id, name });
});
}
const activities: Activity[] = []; const activities: Activity[] = [];
for (const { for (const {
@ -149,11 +220,15 @@ export class ImportService {
unitPrice unitPrice
} of activitiesDto) { } of activitiesDto) {
const date = parseISO(<string>(<unknown>dateString)); const date = parseISO(<string>(<unknown>dateString));
const validatedAccountId = accountIds.includes(accountId) const validatedAccount = accounts.find(({ id }) => {
? accountId return id === accountId;
: undefined; });
let order: OrderWithAccount; let order:
| OrderWithAccount
| (Omit<OrderWithAccount, 'Account'> & {
Account?: { id: string; name: string };
});
if (isDryRun) { if (isDryRun) {
order = { order = {
@ -164,7 +239,7 @@ export class ImportService {
type, type,
unitPrice, unitPrice,
userId, userId,
accountId: validatedAccountId, accountId: validatedAccount?.id,
accountUserId: undefined, accountUserId: undefined,
createdAt: new Date(), createdAt: new Date(),
id: uuidv4(), id: uuidv4(),
@ -179,6 +254,7 @@ export class ImportService {
countries: null, countries: null,
createdAt: undefined, createdAt: undefined,
id: undefined, id: undefined,
isin: null,
name: null, name: null,
scraperConfiguration: null, scraperConfiguration: null,
sectors: null, sectors: null,
@ -187,6 +263,7 @@ export class ImportService {
url: null, url: null,
...assetProfiles[symbol] ...assetProfiles[symbol]
}, },
Account: validatedAccount,
symbolProfileId: undefined, symbolProfileId: undefined,
updatedAt: new Date() updatedAt: new Date()
}; };
@ -199,7 +276,7 @@ export class ImportService {
type, type,
unitPrice, unitPrice,
userId, userId,
accountId: validatedAccountId, accountId: validatedAccount?.id,
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: { create: {
@ -221,6 +298,7 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
//@ts-ignore
activities.push({ activities.push({
...order, ...order,
value, value,

View File

@ -6,8 +6,8 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEMO_USER_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS, PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG, PROPERTY_STRIPE_CONFIG,
@ -59,9 +59,7 @@ export class InfoService {
} }
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
if ( if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
) {
info.fearAndGreedDataSource = encodeDataSource( info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSource ghostfolioFearAndGreedIndexDataSource
); );
@ -72,10 +70,6 @@ export class InfoService {
globalPermissions.push(permissions.enableFearAndGreedIndex); globalPermissions.push(permissions.enableFearAndGreedIndex);
} }
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
isReadOnlyMode = (await this.propertyService.getByKey( isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE PROPERTY_IS_READ_ONLY_MODE
@ -124,7 +118,7 @@ export class InfoService {
baseCurrency: this.configurationService.get('BASE_CURRENCY'), baseCurrency: this.configurationService.get('BASE_CURRENCY'),
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(), benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
currencies: this.exchangeRateDataService.getCurrencies(), currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: await this.getDemoAuthToken(),
statistics: await this.getStatistics(), statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions(), subscriptions: await this.getSubscriptions(),
tags: await this.tagService.get() tags: await this.tagService.get()
@ -252,10 +246,18 @@ export class InfoService {
)) as string; )) as string;
} }
private getDemoAuthToken() { private async getDemoAuthToken() {
return this.jwtService.sign({ const demoUserId = (await this.propertyService.getByKey(
id: DEMO_USER_ID PROPERTY_DEMO_USER_ID
}); )) as string;
if (demoUserId) {
return this.jwtService.sign({
id: demoUserId
});
}
return undefined;
} }
private async getStatistics() { private async getStatistics() {

View File

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

View File

@ -76,23 +76,21 @@ export class OrderService {
userId: string; userId: string;
} }
): Promise<Order> { ): Promise<Order> {
const defaultAccount = ( let Account;
await this.accountService.getAccounts(data.userId)
).find((account) => { if (data.accountId) {
return account.isDefault === true; Account = {
}); connect: {
id_userId: {
userId: data.userId,
id: data.accountId
}
}
};
}
const tags = data.tags ?? []; const tags = data.tags ?? [];
let Account = {
connect: {
id_userId: {
userId: data.userId,
id: data.accountId ?? defaultAccount?.id
}
}
};
if (data.type === 'ITEM') { if (data.type === 'ITEM') {
const assetClass = data.assetClass; const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
@ -101,7 +99,6 @@ export class OrderService {
const id = uuidv4(); const id = uuidv4();
const name = data.SymbolProfile.connectOrCreate.create.symbol; const name = data.SymbolProfile.connectOrCreate.create.symbol;
Account = undefined;
data.id = id; data.id = id;
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass; data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass; data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
@ -113,9 +110,6 @@ export class OrderService {
dataSource, dataSource,
symbol: id symbol: id
}; };
} else {
data.SymbolProfile.connectOrCreate.create.symbol =
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
} }
await this.dataGatheringService.addJobToQueue( await this.dataGatheringService.addJobToQueue(

View File

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

View File

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

View File

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

View File

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

View File

@ -82,6 +82,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('0'), averagePrice: new Big('0'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big('3.2'),
firstBuyDate: '2021-11-22', firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'), grossPerformancePercentage: new Big('-0.0440867739678096571'),

View File

@ -71,6 +71,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('136.6'), averagePrice: new Big('136.6'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big('1.55'),
firstBuyDate: '2021-11-30', firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'), grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'), grossPerformancePercentage: new Big('0.09004392386530014641'),

View File

@ -82,6 +82,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('320.43'), averagePrice: new Big('320.43'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big('0'),
firstBuyDate: '2015-01-01', firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'), grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'), grossPerformancePercentage: new Big('42.40043067128546016291'),

View File

@ -82,6 +82,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('75.80'), averagePrice: new Big('75.80'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big('4.25'),
firstBuyDate: '2022-03-07', firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'), grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'), grossPerformancePercentage: new Big('0.14465699208443271768'),

View File

@ -86,7 +86,7 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 13.100263852242744, netPerformanceInPercentage: 13.100263852242744,
netPerformance: 19.86, netPerformance: 19.86,
totalInvestment: 0, totalInvestment: 0,
value: 19.86 value: 0
}); });
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
@ -102,6 +102,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('0'), averagePrice: new Big('0'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
fee: new Big('0'),
firstBuyDate: '2022-03-07', firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'), grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'), grossPerformancePercentage: new Big('0.13100263852242744063'),

View File

@ -1,7 +1,11 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface'; import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
ResponseError,
TimelinePosition
} from '@ghostfolio/common/interfaces';
import { GroupBy } from '@ghostfolio/common/types'; import { GroupBy } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client'; import { Type as TypeOfOrder } from '@prisma/client';
@ -45,6 +49,7 @@ export class PortfolioCalculator {
private currency: string; private currency: string;
private currentRateService: CurrentRateService; private currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[];
private orders: PortfolioOrder[]; private orders: PortfolioOrder[];
private transactionPoints: TransactionPoint[]; private transactionPoints: TransactionPoint[];
@ -202,14 +207,17 @@ export class PortfolioCalculator {
symbols[item.symbol] = true; symbols[item.symbol] = true;
} }
const marketSymbols = await this.currentRateService.getValues({ const { dataProviderInfos, values: marketSymbols } =
currencies, await this.currentRateService.getValues({
dataGatheringItems, currencies,
dateQuery: { dataGatheringItems,
in: dates dateQuery: {
}, in: dates
userCurrency: this.currency },
}); userCurrency: this.currency
});
this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: { const marketSymbolMap: {
[date: string]: { [symbol: string]: Big }; [date: string]: { [symbol: string]: Big };
@ -227,7 +235,7 @@ export class PortfolioCalculator {
} }
} }
const netPerformanceValuesBySymbol: { const currentValuesBySymbol: {
[symbol: string]: { [date: string]: Big }; [symbol: string]: { [date: string]: Big };
} = {}; } = {};
@ -239,21 +247,31 @@ export class PortfolioCalculator {
[symbol: string]: { [date: string]: Big }; [symbol: string]: { [date: string]: Big };
} = {}; } = {};
const netPerformanceValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
} = {};
const totalCurrentValues: { [date: string]: Big } = {};
const totalNetPerformanceValues: { [date: string]: Big } = {}; const totalNetPerformanceValues: { [date: string]: Big } = {};
const totalInvestmentValues: { [date: string]: Big } = {}; const totalInvestmentValues: { [date: string]: Big } = {};
const maxTotalInvestmentValues: { [date: string]: Big } = {}; const maxTotalInvestmentValues: { [date: string]: Big } = {};
for (const symbol of Object.keys(symbols)) { for (const symbol of Object.keys(symbols)) {
const { investmentValues, maxInvestmentValues, netPerformanceValues } = const {
this.getSymbolMetrics({ currentValues,
end, investmentValues,
marketSymbolMap, maxInvestmentValues,
start, netPerformanceValues
step, } = this.getSymbolMetrics({
symbol, end,
isChartMode: true marketSymbolMap,
}); start,
step,
symbol,
isChartMode: true
});
currentValuesBySymbol[symbol] = currentValues;
netPerformanceValuesBySymbol[symbol] = netPerformanceValues; netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
investmentValuesBySymbol[symbol] = investmentValues; investmentValuesBySymbol[symbol] = investmentValues;
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues; maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues;
@ -263,6 +281,15 @@ export class PortfolioCalculator {
const dateString = format(currentDate, DATE_FORMAT); const dateString = format(currentDate, DATE_FORMAT);
for (const symbol of Object.keys(netPerformanceValuesBySymbol)) { for (const symbol of Object.keys(netPerformanceValuesBySymbol)) {
totalCurrentValues[dateString] =
totalCurrentValues[dateString] ?? new Big(0);
if (currentValuesBySymbol[symbol]?.[dateString]) {
totalCurrentValues[dateString] = totalCurrentValues[dateString].add(
currentValuesBySymbol[symbol][dateString]
);
}
totalNetPerformanceValues[dateString] = totalNetPerformanceValues[dateString] =
totalNetPerformanceValues[dateString] ?? new Big(0); totalNetPerformanceValues[dateString] ?? new Big(0);
@ -275,15 +302,15 @@ export class PortfolioCalculator {
totalInvestmentValues[dateString] = totalInvestmentValues[dateString] =
totalInvestmentValues[dateString] ?? new Big(0); totalInvestmentValues[dateString] ?? new Big(0);
maxTotalInvestmentValues[dateString] =
maxTotalInvestmentValues[dateString] ?? new Big(0);
if (investmentValuesBySymbol[symbol]?.[dateString]) { if (investmentValuesBySymbol[symbol]?.[dateString]) {
totalInvestmentValues[dateString] = totalInvestmentValues[ totalInvestmentValues[dateString] = totalInvestmentValues[
dateString dateString
].add(investmentValuesBySymbol[symbol][dateString]); ].add(investmentValuesBySymbol[symbol][dateString]);
} }
maxTotalInvestmentValues[dateString] =
maxTotalInvestmentValues[dateString] ?? new Big(0);
if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) { if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) {
maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[ maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[
dateString dateString
@ -305,9 +332,7 @@ export class PortfolioCalculator {
netPerformanceInPercentage, netPerformanceInPercentage,
netPerformance: totalNetPerformanceValues[date].toNumber(), netPerformance: totalNetPerformanceValues[date].toNumber(),
totalInvestment: totalInvestmentValues[date].toNumber(), totalInvestment: totalInvestmentValues[date].toNumber(),
value: totalInvestmentValues[date] value: totalCurrentValues[date].toNumber()
.plus(totalNetPerformanceValues[date])
.toNumber()
}; };
}); });
} }
@ -368,14 +393,17 @@ export class PortfolioCalculator {
dates.push(resetHours(end)); dates.push(resetHours(end));
const marketSymbols = await this.currentRateService.getValues({ const { dataProviderInfos, values: marketSymbols } =
currencies, await this.currentRateService.getValues({
dataGatheringItems, currencies,
dateQuery: { dataGatheringItems,
in: dates dateQuery: {
}, in: dates
userCurrency: this.currency },
}); userCurrency: this.currency
});
this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: { const marketSymbolMap: {
[date: string]: { [symbol: string]: Big }; [date: string]: { [symbol: string]: Big };
@ -431,6 +459,7 @@ export class PortfolioCalculator {
: item.investment.div(item.quantity), : item.investment.div(item.quantity),
currency: item.currency, currency: item.currency,
dataSource: item.dataSource, dataSource: item.dataSource,
fee: item.fee,
firstBuyDate: item.firstBuyDate, firstBuyDate: item.firstBuyDate,
grossPerformance: !hasErrors ? grossPerformance ?? null : null, grossPerformance: !hasErrors ? grossPerformance ?? null : null,
grossPerformancePercentage: !hasErrors grossPerformancePercentage: !hasErrors
@ -447,7 +476,7 @@ export class PortfolioCalculator {
transactionCount: item.transactionCount transactionCount: item.transactionCount
}); });
if (hasErrors) { if (hasErrors && item.investment.gt(0)) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol }); errors.push({ dataSource: item.dataSource, symbol: item.symbol });
} }
} }
@ -462,6 +491,10 @@ export class PortfolioCalculator {
}; };
} }
public getDataProviderInfos() {
return this.dataProviderInfos;
}
public getInvestments(): { date: string; investment: Big }[] { public getInvestments(): { date: string; investment: Big }[] {
if (this.transactionPoints.length === 0) { if (this.transactionPoints.length === 0) {
return []; return [];
@ -747,7 +780,7 @@ export class PortfolioCalculator {
let marketSymbols: GetValueObject[] = []; let marketSymbols: GetValueObject[] = [];
if (dataGatheringItems.length > 0) { if (dataGatheringItems.length > 0) {
try { try {
marketSymbols = await this.currentRateService.getValues({ const { values } = await this.currentRateService.getValues({
currencies, currencies,
dataGatheringItems, dataGatheringItems,
dateQuery: { dateQuery: {
@ -756,6 +789,7 @@ export class PortfolioCalculator {
}, },
userCurrency: this.currency userCurrency: this.currency
}); });
marketSymbols = values;
} catch (error) { } catch (error) {
Logger.error( Logger.error(
`Failed to fetch info for date ${startDate} with exception`, `Failed to fetch info for date ${startDate} with exception`,
@ -889,12 +923,16 @@ export class PortfolioCalculator {
if (orders.length <= 0) { if (orders.length <= 0) {
return { return {
currentValues: {},
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
hasErrors: false, hasErrors: false,
initialValue: new Big(0), initialValue: new Big(0),
investmentValues: {},
maxInvestmentValues: {},
netPerformance: new Big(0), netPerformance: new Big(0),
netPerformancePercentage: new Big(0), netPerformancePercentage: new Big(0),
grossPerformance: new Big(0), netPerformanceValues: {}
grossPerformancePercentage: new Big(0)
}; };
} }
@ -929,6 +967,7 @@ export class PortfolioCalculator {
let grossPerformanceFromSells = new Big(0); let grossPerformanceFromSells = new Big(0);
let initialValue: Big; let initialValue: Big;
let investmentAtStartDate: Big; let investmentAtStartDate: Big;
const currentValues: { [date: string]: Big } = {};
const investmentValues: { [date: string]: Big } = {}; const investmentValues: { [date: string]: Big } = {};
const maxInvestmentValues: { [date: string]: Big } = {}; const maxInvestmentValues: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0); let lastAveragePrice = new Big(0);
@ -1147,6 +1186,7 @@ export class PortfolioCalculator {
} }
if (isChartMode && i > indexOfStartOrder) { if (isChartMode && i > indexOfStartOrder) {
currentValues[order.date] = valueOfInvestment;
netPerformanceValues[order.date] = grossPerformance netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate) .minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate)); .minus(fees.minus(feesAtStartDate));
@ -1244,15 +1284,16 @@ export class PortfolioCalculator {
} }
return { return {
initialValue, currentValues,
grossPerformancePercentage, grossPerformancePercentage,
initialValue,
investmentValues, investmentValues,
maxInvestmentValues, maxInvestmentValues,
netPerformancePercentage, netPerformancePercentage,
netPerformanceValues, netPerformanceValues,
grossPerformance: totalGrossPerformance,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance, netPerformance: totalNetPerformance
grossPerformance: totalGrossPerformance
}; };
} }

View File

@ -10,6 +10,7 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
@ -65,7 +66,7 @@ export class PortfolioController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Headers('impersonation-id') impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@ -131,7 +132,8 @@ export class PortfolioController {
portfolioPosition.investment / totalInvestment; portfolioPosition.investment / totalInvestment;
portfolioPosition.netPerformance = null; portfolioPosition.netPerformance = null;
portfolioPosition.quantity = null; portfolioPosition.quantity = null;
portfolioPosition.value = portfolioPosition.value / totalValue; portfolioPosition.valueInPercentage =
portfolioPosition.value / totalValue;
} }
for (const [name, { current, original }] of Object.entries(accounts)) { for (const [name, { current, original }] of Object.entries(accounts)) {
@ -188,7 +190,7 @@ export class PortfolioController {
@Get('dividends') @Get('dividends')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getDividends( public async getDividends(
@Headers('impersonation-id') impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('groupBy') groupBy?: GroupBy, @Query('groupBy') groupBy?: GroupBy,
@ -238,7 +240,7 @@ export class PortfolioController {
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getInvestments( public async getInvestments(
@Headers('impersonation-id') impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('groupBy') groupBy?: GroupBy, @Query('groupBy') groupBy?: GroupBy,
@ -290,7 +292,7 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2') @Version('2')
public async getPerformanceV2( public async getPerformanceV2(
@Headers('impersonation-id') impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@ -322,7 +324,7 @@ export class PortfolioController {
totalInvestment: new Big(totalInvestment) totalInvestment: new Big(totalInvestment)
.div(performanceInformation.performance.totalInvestment) .div(performanceInformation.performance.totalInvestment)
.toNumber(), .toNumber(),
value: new Big(value) valueInPercentage: new Big(value)
.div(performanceInformation.performance.currentValue) .div(performanceInformation.performance.currentValue)
.toNumber() .toNumber()
}; };
@ -356,9 +358,10 @@ export class PortfolioController {
@Get('positions') @Get('positions')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions( public async getPositions(
@Headers('impersonation-id') impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@ -370,27 +373,11 @@ export class PortfolioController {
filterByTags filterByTags
}); });
const result = await this.portfolioService.getPositions({ return this.portfolioService.getPositions({
dateRange, dateRange,
filters, filters,
impersonationId impersonationId
}); });
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
result.positions = result.positions.map((position) => {
return nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'netPerformance',
'quantity'
]);
});
}
return result;
} }
@Get('public/:accessId') @Get('public/:accessId')
@ -441,7 +428,7 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPublicDetails.holdings[symbol] = { portfolioPublicDetails.holdings[symbol] = {
allocationCurrent: portfolioPosition.value / totalValue, allocationInPercentage: portfolioPosition.value / totalValue,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined, currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource, dataSource: portfolioPosition.dataSource,
@ -452,7 +439,7 @@ export class PortfolioController {
sectors: hasDetails ? portfolioPosition.sectors : [], sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol, symbol: portfolioPosition.symbol,
url: portfolioPosition.url, url: portfolioPosition.url,
value: portfolioPosition.value / totalValue valueInPercentage: portfolioPosition.value / totalValue
}; };
} }
@ -460,35 +447,22 @@ export class PortfolioController {
} }
@Get('position/:dataSource/:symbol') @Get('position/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPosition( public async getPosition(
@Headers('impersonation-id') impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource, @Param('dataSource') dataSource,
@Param('symbol') symbol @Param('symbol') symbol
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
let position = await this.portfolioService.getPosition( const position = await this.portfolioService.getPosition(
dataSource, dataSource,
impersonationId, impersonationId,
symbol symbol
); );
if (position) { if (position) {
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
position = nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'netPerformance',
'orders',
'quantity',
'value'
]);
}
return position; return position;
} }
@ -501,7 +475,7 @@ export class PortfolioController {
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getReport( public async getReport(
@Headers('impersonation-id') impersonationId: string @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<PortfolioReport> { ): Promise<PortfolioReport> {
const report = await this.portfolioService.getReport(impersonationId); const report = await this.portfolioService.getReport(impersonationId);

View File

@ -24,7 +24,7 @@ import {
MAX_CHART_ITEMS, MAX_CHART_ITEMS,
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
import { import {
Accounts, Accounts,
EnhancedSymbolProfile, EnhancedSymbolProfile,
@ -537,12 +537,9 @@ export class PortfolioService {
holdings[item.symbol] = { holdings[item.symbol] = {
markets, markets,
allocationCurrent: filteredValueInBaseCurrency.eq(0) allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0 ? 0
: value.div(filteredValueInBaseCurrency).toNumber(), : value.div(filteredValueInBaseCurrency).toNumber(),
allocationInvestment: item.investment
.div(totalInvestmentInBaseCurrency)
.toNumber(),
assetClass: symbolProfile.assetClass, assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass, assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries, countries: symbolProfile.countries,
@ -576,7 +573,6 @@ export class PortfolioService {
const cashPositions = await this.getCashPositions({ const cashPositions = await this.getCashPositions({
cashDetails, cashDetails,
userCurrency, userCurrency,
investment: totalInvestmentInBaseCurrency,
value: filteredValueInBaseCurrency value: filteredValueInBaseCurrency
}); });
@ -602,7 +598,6 @@ export class PortfolioService {
const cashPositions = await this.getCashPositions({ const cashPositions = await this.getCashPositions({
cashDetails, cashDetails,
userCurrency, userCurrency,
investment: totalInvestmentInBaseCurrency,
value: filteredValueInBaseCurrency value: filteredValueInBaseCurrency
}); });
@ -614,6 +609,8 @@ export class PortfolioService {
) )
.toNumber(); .toNumber();
filteredValueInBaseCurrency = emergencyFund;
accounts[UNKNOWN_KEY] = { accounts[UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: userCurrency, currency: userCurrency,
@ -681,6 +678,9 @@ export class PortfolioService {
return { return {
tags, tags,
averagePrice: undefined, averagePrice: undefined,
dataProviderInfo: undefined,
dividendInBaseCurrency: undefined,
feeInBaseCurrency: undefined,
firstBuyDate: undefined, firstBuyDate: undefined,
grossPerformance: undefined, grossPerformance: undefined,
grossPerformancePercent: undefined, grossPerformancePercent: undefined,
@ -747,12 +747,23 @@ export class PortfolioService {
averagePrice, averagePrice,
currency, currency,
dataSource, dataSource,
fee,
firstBuyDate, firstBuyDate,
marketPrice, marketPrice,
quantity, quantity,
transactionCount transactionCount
} = position; } = position;
const dividendInBaseCurrency = getSum(
orders
.filter(({ type }) => {
return type === 'DIVIDEND';
})
.map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency);
})
);
// Convert investment, gross and net performance to currency of user // Convert investment, gross and net performance to currency of user
const investment = this.exchangeRateDataService.toCurrency( const investment = this.exchangeRateDataService.toCurrency(
position.investment?.toNumber(), position.investment?.toNumber(),
@ -786,8 +797,8 @@ export class PortfolioService {
historicalDataArray.push({ historicalDataArray.push({
averagePrice: orders[0].unitPrice, averagePrice: orders[0].unitPrice,
date: firstBuyDate, date: firstBuyDate,
quantity: orders[0].quantity, marketPrice: orders[0].unitPrice,
value: orders[0].unitPrice quantity: orders[0].quantity
}); });
} }
@ -816,9 +827,9 @@ export class PortfolioService {
historicalDataArray.push({ historicalDataArray.push({
date, date,
marketPrice,
averagePrice: currentAveragePrice, averagePrice: currentAveragePrice,
quantity: currentQuantity, quantity: currentQuantity
value: marketPrice
}); });
maxPrice = Math.max(marketPrice ?? 0, maxPrice); maxPrice = Math.max(marketPrice ?? 0, maxPrice);
@ -839,6 +850,13 @@ export class PortfolioService {
tags, tags,
transactionCount, transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee.toNumber(),
SymbolProfile.currency,
userCurrency
),
grossPerformancePercent: grossPerformancePercent:
position.grossPerformancePercentage?.toNumber(), position.grossPerformancePercentage?.toNumber(),
historicalData: historicalDataArray, historicalData: historicalDataArray,
@ -895,6 +913,9 @@ export class PortfolioService {
SymbolProfile, SymbolProfile,
tags, tags,
averagePrice: 0, averagePrice: 0,
dataProviderInfo: undefined,
dividendInBaseCurrency: 0,
feeInBaseCurrency: 0,
firstBuyDate: undefined, firstBuyDate: undefined,
grossPerformance: undefined, grossPerformance: undefined,
grossPerformancePercent: undefined, grossPerformancePercent: undefined,
@ -1040,29 +1061,21 @@ 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 {
startDate currentValue,
); errors,
grossPerformance,
grossPerformancePercentage,
hasErrors,
netPerformance,
netPerformancePercentage,
totalInvestment
} = await portfolioCalculator.getCurrentPositions(startDate);
const hasErrors = currentPositions.hasErrors; const currentGrossPerformance = grossPerformance;
const currentValue = currentPositions.currentValue.toNumber(); const currentGrossPerformancePercent = grossPerformancePercentage;
const currentGrossPerformance = currentPositions.grossPerformance; let currentNetPerformance = netPerformance;
const currentGrossPerformancePercent = let currentNetPerformancePercent = netPerformancePercentage;
currentPositions.grossPerformancePercentage;
let currentNetPerformance = currentPositions.netPerformance;
let currentNetPerformancePercent =
currentPositions.netPerformancePercentage;
const totalInvestment = currentPositions.totalInvestment;
// if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
// // If algebraic sign is different, harmonize it
// currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
// }
// if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
// // If algebraic sign is different, harmonize it
// currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
// }
const historicalDataContainer = await this.getChart({ const historicalDataContainer = await this.getChart({
dateRange, dateRange,
@ -1084,28 +1097,28 @@ export class PortfolioService {
} }
return { return {
errors,
hasErrors,
chart: historicalDataContainer.items.map( chart: historicalDataContainer.items.map(
({ ({
date, date,
netPerformance, netPerformance: netPerformanceOfItem,
netPerformanceInPercentage, netPerformanceInPercentage,
totalInvestment, totalInvestment: totalInvestmentOfItem,
value value
}) => { }) => {
return { return {
date, date,
netPerformance,
netPerformanceInPercentage, netPerformanceInPercentage,
totalInvestment, value,
value netPerformance: netPerformanceOfItem,
totalInvestment: totalInvestmentOfItem
}; };
} }
), ),
errors: currentPositions.errors,
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date), firstOrderDate: parseDate(historicalDataContainer.items[0]?.date),
hasErrors: currentPositions.hasErrors || hasErrors,
performance: { performance: {
currentValue, currentValue: currentValue.toNumber(),
currentGrossPerformance: currentGrossPerformance.toNumber(), currentGrossPerformance: currentGrossPerformance.toNumber(),
currentGrossPerformancePercent: currentGrossPerformancePercent:
currentGrossPerformancePercent.toNumber(), currentGrossPerformancePercent.toNumber(),
@ -1218,12 +1231,10 @@ export class PortfolioService {
private async getCashPositions({ private async getCashPositions({
cashDetails, cashDetails,
investment,
userCurrency, userCurrency,
value value
}: { }: {
cashDetails: CashDetails; cashDetails: CashDetails;
investment: Big;
userCurrency: string; userCurrency: string;
value: Big; value: Big;
}) { }) {
@ -1258,12 +1269,9 @@ export class PortfolioService {
for (const symbol of Object.keys(cashPositions)) { for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency // Calculate allocations for each currency
cashPositions[symbol].allocationCurrent = value.gt(0) cashPositions[symbol].allocationInPercentage = value.gt(0)
? new Big(cashPositions[symbol].value).div(value).toNumber() ? new Big(cashPositions[symbol].value).div(value).toNumber()
: 0; : 0;
cashPositions[symbol].allocationInvestment = investment.gt(0)
? new Big(cashPositions[symbol].investment).div(investment).toNumber()
: 0;
} }
return cashPositions; return cashPositions;
@ -1430,8 +1438,7 @@ export class PortfolioService {
}): PortfolioPosition { }): PortfolioPosition {
return { return {
currency, currency,
allocationCurrent: 0, allocationInPercentage: 0,
allocationInvestment: 0,
assetClass: AssetClass.CASH, assetClass: AssetClass.CASH,
assetSubClass: AssetClass.CASH, assetSubClass: AssetClass.CASH,
countries: [], countries: [],
@ -1546,7 +1553,10 @@ export class PortfolioService {
userCurrency userCurrency
}).toNumber(); }).toNumber();
const emergencyFund = new Big( const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 Math.max(
emergencyFundPositionsValueInBaseCurrency,
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
)
); );
const fees = this.getFees({ activities, userCurrency }).toNumber(); const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date; const firstOrderDate = activities[0]?.date;
@ -1705,6 +1715,14 @@ export class PortfolioService {
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}) { }) {
const ordersOfTypeItem = await this.orderService.getOrders({
filters,
userCurrency,
userId,
withExcludedAccounts,
types: ['ITEM']
});
const accounts: PortfolioDetails['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
let currentAccounts: (Account & { let currentAccounts: (Account & {
@ -1735,10 +1753,18 @@ export class PortfolioService {
}); });
for (const account of currentAccounts) { for (const account of currentAccounts) {
const ordersByAccount = orders.filter(({ accountId }) => { let ordersByAccount = orders.filter(({ accountId }) => {
return accountId === account.id; return accountId === account.id;
}); });
const ordersOfTypeItemByAccount = ordersOfTypeItem.filter(
({ accountId }) => {
return accountId === account.id;
}
);
ordersByAccount = ordersByAccount.concat(ordersOfTypeItemByAccount);
accounts[account.id] = { accounts[account.id] = {
balance: account.balance, balance: account.balance,
currency: account.currency, currency: account.currency,
@ -1758,7 +1784,9 @@ export class PortfolioService {
for (const order of ordersByAccount) { for (const order of ordersByAccount) {
let currentValueOfSymbolInBaseCurrency = let currentValueOfSymbolInBaseCurrency =
order.quantity * order.quantity *
portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ?? 0; (portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ??
order.unitPrice ??
0);
let originalValueOfSymbolInBaseCurrency = let originalValueOfSymbolInBaseCurrency =
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice, order.quantity * order.unitPrice,

View File

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

View File

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

View File

@ -1,6 +1,8 @@
import { DataSource } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
export interface LookupItem { export interface LookupItem {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;
name: string; name: string;

View File

@ -1,15 +1,18 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
HttpException, HttpException,
Inject,
Param, Param,
Query, Query,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -21,7 +24,10 @@ import { SymbolService } from './symbol.service';
@Controller('symbol') @Controller('symbol')
export class SymbolController { export class SymbolController {
public constructor(private readonly symbolService: SymbolService) {} public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolService: SymbolService
) {}
/** /**
* Must be before /:symbol * Must be before /:symbol
@ -33,7 +39,10 @@ export class SymbolController {
@Query() { query = '' } @Query() { query = '' }
): Promise<{ items: LookupItem[] }> { ): Promise<{ items: LookupItem[] }> {
try { try {
return this.symbolService.lookup(query.toLowerCase()); return this.symbolService.lookup({
query: query.toLowerCase(),
user: this.request.user
});
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),

View File

@ -5,7 +5,10 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; import {
HistoricalDataItem,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
@ -79,15 +82,24 @@ export class SymbolService {
}; };
} }
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> { public async lookup({
query,
user
}: {
query: string;
user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> {
const results: { items: LookupItem[] } = { items: [] }; const results: { items: LookupItem[] } = { items: [] };
if (!aQuery) { if (!query) {
return results; return results;
} }
try { try {
const { items } = await this.dataProviderService.search(aQuery); const { items } = await this.dataProviderService.search({
query,
user
});
results.items = items; results.items = items;
return results; return results;
} catch (error) { } catch (error) {

View File

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

View File

@ -1,6 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config';
import { User, UserSettings } from '@ghostfolio/common/interfaces'; import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -31,7 +29,6 @@ import { UserService } from './user.service';
@Controller('user') @Controller('user')
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
@ -82,7 +79,7 @@ export class UserController {
const hasAdmin = await this.userService.hasAdmin(); const hasAdmin = await this.userService.hasAdmin();
const { accessToken, id, role } = await this.userService.createUser({ const { accessToken, id, role } = await this.userService.createUser({
role: hasAdmin ? 'USER' : 'ADMIN' data: { role: hasAdmin ? 'USER' : 'ADMIN' }
}); });
return { return {

View File

@ -97,6 +97,7 @@ export class UserService {
const { const {
accessToken, accessToken,
Account, Account,
Analytics,
authChallenge, authChallenge,
createdAt, createdAt,
id, id,
@ -107,7 +108,12 @@ export class UserService {
thirdPartyId, thirdPartyId,
updatedAt updatedAt
} = await this.prismaService.user.findUnique({ } = await this.prismaService.user.findUnique({
include: { Account: true, Settings: true, Subscription: true }, include: {
Account: true,
Analytics: true,
Settings: true,
Subscription: true
},
where: userWhereUniqueInput where: userWhereUniqueInput
}); });
@ -121,7 +127,8 @@ export class UserService {
role, role,
Settings, Settings,
thirdPartyId, thirdPartyId,
updatedAt updatedAt,
activityCount: Analytics?.activityCount
}; };
if (user?.Settings) { if (user?.Settings) {
@ -154,15 +161,22 @@ export class UserService {
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT'; (user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
} }
let currentPermissions = getPermissions(user.role);
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);
}
let currentPermissions = getPermissions(user.role); if (
Analytics?.activityCount % 25 === 0 &&
user.subscription?.type === 'Basic'
) {
currentPermissions.push(permissions.enableSubscriptionInterstitial);
}
if (user.subscription?.type === 'Premium') { if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch); currentPermissions.push(permissions.reportDataGlitch);
}
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
@ -217,7 +231,11 @@ export class UserService {
return hash.digest('hex'); return hash.digest('hex');
} }
public async createUser(data: Prisma.UserCreateInput): Promise<User> { public async createUser({
data
}: {
data: Prisma.UserCreateInput;
}): Promise<User> {
if (!data?.provider) { if (!data?.provider) {
data.provider = 'ANONYMOUS'; data.provider = 'ANONYMOUS';
} }
@ -242,6 +260,14 @@ export class UserService {
} }
}); });
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
await this.prismaService.analytics.create({
data: {
User: { connect: { id: user.id } }
}
});
}
if (data.provider === 'ANONYMOUS') { if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken( const accessToken = this.createAccessToken(
user.id, user.id,

View File

@ -1,4 +1,5 @@
import { cloneDeep, isObject } from 'lodash'; import Big from 'big.js';
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) {
@ -43,15 +44,26 @@ export function redactAttributes({
for (const option of options) { for (const option of options) {
if (redactedObject.hasOwnProperty(option.attribute)) { if (redactedObject.hasOwnProperty(option.attribute)) {
redactedObject[option.attribute] = if (option.valueMap['*'] || option.valueMap['*'] === null) {
option.valueMap[redactedObject[option.attribute]] ?? redactedObject[option.attribute] = option.valueMap['*'];
option.valueMap['*'] ?? } else if (option.valueMap[redactedObject[option.attribute]]) {
redactedObject[option.attribute]; redactedObject[option.attribute] =
option.valueMap[redactedObject[option.attribute]];
}
} else { } else {
// If the attribute is not present on the current object, // If the attribute is not present on the current object,
// check if it exists on any nested objects // check if it exists on any nested objects
for (const property in redactedObject) { for (const property in redactedObject) {
if (typeof redactedObject[property] === 'object') { if (isArray(redactedObject[property])) {
redactedObject[property] = redactedObject[property].map(
(currentObject) => {
return redactAttributes({ options, object: currentObject });
}
);
} else if (
isObject(redactedObject[property]) &&
!(redactedObject[property] instanceof Big)
) {
// Recursively call the function on the nested object // Recursively call the function on the nested object
redactedObject[property] = redactAttributes({ redactedObject[property] = redactAttributes({
options, options,

View File

@ -1,5 +1,6 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {
CallHandler, CallHandler,
ExecutionContext, ExecutionContext,
@ -22,65 +23,43 @@ export class RedactValuesInResponseInterceptor<T>
return next.handle().pipe( return next.handle().pipe(
map((data: any) => { map((data: any) => {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const hasImpersonationId = !!request.headers?.['impersonation-id']; const hasImpersonationId =
!!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
if ( if (
hasImpersonationId || hasImpersonationId ||
this.userService.isRestrictedView(request.user) this.userService.isRestrictedView(request.user)
) { ) {
if (data.accounts) { data = redactAttributes({
for (const accountId of Object.keys(data.accounts)) { object: data,
if (data.accounts[accountId]?.balance !== undefined) { options: [
data.accounts[accountId].balance = null; 'balance',
} 'balanceInBaseCurrency',
} 'comment',
} 'convertedBalance',
'dividendInBaseCurrency',
if (data.activities) { 'fee',
data.activities = data.activities.map((activity: Activity) => { 'feeInBaseCurrency',
if (activity.Account?.balance !== undefined) { 'filteredValueInBaseCurrency',
activity.Account.balance = null; 'grossPerformance',
} 'investment',
'netPerformance',
if (activity.comment !== undefined) { 'quantity',
activity.comment = null; 'symbolMapping',
} 'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency',
if (activity.fee !== undefined) { 'unitPrice',
activity.fee = null; 'value',
} 'valueInBaseCurrency'
].map((attribute) => {
if (activity.feeInBaseCurrency !== undefined) { return {
activity.feeInBaseCurrency = null; attribute,
} valueMap: {
'*': null
if (activity.quantity !== undefined) { }
activity.quantity = null; };
} })
});
if (activity.unitPrice !== undefined) {
activity.unitPrice = null;
}
if (activity.value !== undefined) {
activity.value = null;
}
if (activity.valueInBaseCurrency !== undefined) {
activity.valueInBaseCurrency = null;
}
return activity;
});
}
if (data.filteredValueInBaseCurrency) {
data.filteredValueInBaseCurrency = null;
}
if (data.totalValueInBaseCurrency) {
data.totalValueInBaseCurrency = null;
}
} }
return data; return data;

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common'; import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import * as bodyParser from 'body-parser';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
@ -9,15 +10,10 @@ async function bootstrap() {
const configApp = await NestFactory.create(AppModule); const configApp = await NestFactory.create(AppModule);
const configService = configApp.get<ConfigService>(ConfigService); const configService = configApp.get<ConfigService>(ConfigService);
const NODE_ENV =
configService.get<'development' | 'production'>('NODE_ENV') ??
'development';
const app = await NestFactory.create(AppModule, { const app = await NestFactory.create(AppModule, {
logger: logger: environment.production
NODE_ENV === 'production' ? ['error', 'log', 'warn']
? ['error', 'log', 'warn'] : ['debug', 'error', 'log', 'verbose', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn']
}); });
app.enableCors(); app.enableCors();
app.enableVersioning({ app.enableVersioning({
@ -33,6 +29,9 @@ async function bootstrap() {
}) })
); );
// Support 10mb csv/json files for importing activities
app.use(bodyParser.json({ limit: '10mb' }));
const HOST = configService.get<string>('HOST') || '0.0.0.0'; const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333; const PORT = configService.get<number>('PORT') || 3333;
await app.listen(PORT, HOST, () => { await app.listen(PORT, HOST, () => {

View File

@ -19,12 +19,10 @@ export class ConfigurationService {
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }), DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ DATA_SOURCES: json({
default: [DataSource.GHOSTFOLIO, DataSource.MANUAL, DataSource.YAHOO] default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO]
}), }),
ENABLE_FEATURE_BLOG: bool({ default: false }), ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_IMPORT: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }),
@ -42,7 +40,7 @@ export class ConfigurationService {
MAX_ITEM_IN_CACHE: num({ default: 9999 }), MAX_ITEM_IN_CACHE: num({ default: 9999 }),
PORT: port({ default: 3333 }), PORT: port({ default: 3333 }),
RAPID_API_API_KEY: str({ default: '' }), RAPID_API_API_KEY: str({ default: '' }),
REDIS_HOST: host({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }), REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }), ROOT_URL: str({ default: 'http://localhost:4200' }),

View File

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

View File

@ -0,0 +1,198 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
SymbolProfile
} from '@prisma/client';
import bent from 'bent';
import { format, fromUnixTime, getUnixTime } from 'date-fns';
@Injectable()
export class CoinGeckoService implements DataProviderInterface {
private baseCurrency: string;
private readonly URL = 'https://api.coingecko.com/api/v3';
public constructor(
private readonly configurationService: ConfigurationService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) {
return true;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {
assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: this.baseCurrency,
dataSource: this.getName(),
symbol: aSymbol
};
try {
const get = bent(`${this.URL}/coins/${aSymbol}`, 'GET', 'json', 200);
const { name } = await get();
response.name = name;
} catch (error) {
Logger.error(error, 'CoinGeckoService');
}
return response;
}
public async getDividends({
from,
granularity = 'day',
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
return {};
}
public async getHistorical(
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
try {
const get = bent(
`${
this.URL
}/coins/${aSymbol}/market_chart/range?vs_currency=${this.baseCurrency.toLowerCase()}&from=${getUnixTime(
from
)}&to=${getUnixTime(to)}`,
'GET',
'json',
200
);
const { prices } = await get();
const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[aSymbol]: {}
};
for (const [timestamp, marketPrice] of prices) {
result[aSymbol][format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = {
marketPrice
};
}
return result;
} catch (error) {
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getMaxNumberOfSymbolsPerRequest() {
return 50;
}
public getName(): DataSource {
return DataSource.COINGECKO;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
const results: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) {
return {};
}
try {
const get = bent(
`${this.URL}/simple/price?ids=${aSymbols.join(
','
)}&vs_currencies=${this.baseCurrency.toLowerCase()}`,
'GET',
'json',
200
);
const response = await get();
for (const symbol in response) {
if (Object.prototype.hasOwnProperty.call(response, symbol)) {
results[symbol] = {
currency: this.baseCurrency,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.COINGECKO,
marketPrice: response[symbol][this.baseCurrency.toLowerCase()],
marketState: 'open'
};
}
}
} catch (error) {
Logger.error(error, 'CoinGeckoService');
}
return results;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = [];
try {
const get = bent(
`${this.URL}/search?query=${aQuery}`,
'GET',
'json',
200
);
const { coins } = await get();
items = coins.map(({ id: symbol, name }) => {
return {
name,
symbol,
assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: this.baseCurrency,
dataSource: this.getName()
};
});
} catch (error) {
Logger.error(error, 'CoinGeckoService');
}
return { items };
}
private getDataProviderInfo(): DataProviderInfo {
return {
name: 'CoinGecko',
url: 'https://coingecko.com'
};
}
}

View File

@ -7,7 +7,7 @@ import bent from 'bent';
const getJSON = bent('json'); const getJSON = bent('json');
export class TrackinsightDataEnhancerService implements DataEnhancerInterface { export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://data.trackinsight.com/holdings'; private static baseUrl = 'https://data.trackinsight.com';
private static countries = require('countries-list/dist/countries.json'); private static countries = require('countries-list/dist/countries.json');
private static countriesMapping = { private static countriesMapping = {
'Russian Federation': 'Russia' 'Russian Federation': 'Russia'
@ -32,17 +32,29 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response; return response;
} }
const result = await getJSON( const profile = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json` `${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${symbol}.json`
).catch(() => {
return {};
});
const isin = profile.isin?.split(';')?.[0];
if (isin) {
response.isin = isin;
}
const holdings = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`
).catch(() => { ).catch(() => {
return getJSON( return getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${ `${TrackinsightDataEnhancerService.baseUrl}/holdings/${
symbol.split('.')[0] symbol.split('.')?.[0]
}.json` }.json`
); );
}); });
if (result.weight < 0.95) { if (holdings?.weight < 0.95) {
// Skip if data is inaccurate // Skip if data is inaccurate
return response; return response;
} }
@ -52,7 +64,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.countries as unknown as Country[]).length === 0 (response.countries as unknown as Country[]).length === 0
) { ) {
response.countries = []; response.countries = [];
for (const [name, value] of Object.entries<any>(result.countries)) { for (const [name, value] of Object.entries<any>(
holdings?.countries ?? {}
)) {
let countryCode: string; let countryCode: string;
for (const [key, country] of Object.entries<any>( for (const [key, country] of Object.entries<any>(
@ -80,7 +94,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.sectors as unknown as Sector[]).length === 0 (response.sectors as unknown as Sector[]).length === 0
) { ) {
response.sectors = []; response.sectors = [];
for (const [name, value] of Object.entries<any>(result.sectors)) { for (const [name, value] of Object.entries<any>(
holdings?.sectors ?? {}
)) {
response.sectors.push({ response.sectors.push({
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name, name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
weight: value.weight weight: value.weight

View File

@ -1,8 +1,8 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service'; import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
@ -22,9 +22,9 @@ import { DataProviderService } from './data-provider.service';
], ],
providers: [ providers: [
AlphaVantageService, AlphaVantageService,
CoinGeckoService,
DataProviderService, DataProviderService,
EodHistoricalDataService, EodHistoricalDataService,
GhostfolioScraperApiService,
GoogleSheetsService, GoogleSheetsService,
ManualService, ManualService,
RapidApiService, RapidApiService,
@ -32,8 +32,8 @@ import { DataProviderService } from './data-provider.service';
{ {
inject: [ inject: [
AlphaVantageService, AlphaVantageService,
CoinGeckoService,
EodHistoricalDataService, EodHistoricalDataService,
GhostfolioScraperApiService,
GoogleSheetsService, GoogleSheetsService,
ManualService, ManualService,
RapidApiService, RapidApiService,
@ -42,16 +42,16 @@ import { DataProviderService } from './data-provider.service';
provide: 'DataProviderInterfaces', provide: 'DataProviderInterfaces',
useFactory: ( useFactory: (
alphaVantageService, alphaVantageService,
coinGeckoService,
eodHistoricalDataService, eodHistoricalDataService,
ghostfolioScraperApiService,
googleSheetsService, googleSheetsService,
manualService, manualService,
rapidApiService, rapidApiService,
yahooFinanceService yahooFinanceService
) => [ ) => [
alphaVantageService, alphaVantageService,
coinGeckoService,
eodHistoricalDataService, eodHistoricalDataService,
ghostfolioScraperApiService,
googleSheetsService, googleSheetsService,
manualService, manualService,
rapidApiService, rapidApiService,
@ -59,10 +59,6 @@ import { DataProviderService } from './data-provider.service';
] ]
} }
], ],
exports: [ exports: [DataProviderService, YahooFinanceService]
DataProviderService,
GhostfolioScraperApiService,
YahooFinanceService
]
}) })
export class DataProviderModule {} export class DataProviderModule {}

View File

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

View File

@ -5,13 +5,17 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import {
AssetClass,
AssetSubClass,
DataSource,
SymbolProfile
} from '@prisma/client';
import bent from 'bent'; import bent from 'bent';
import { format } from 'date-fns'; import { format, isToday } from 'date-fns';
@Injectable() @Injectable()
export class EodHistoricalDataService implements DataProviderInterface { export class EodHistoricalDataService implements DataProviderInterface {
@ -19,8 +23,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
private readonly URL = 'https://eodhistoricaldata.com/api'; private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService
private readonly symbolProfileService: SymbolProfileService
) { ) {
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY'); this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
} }
@ -32,8 +35,15 @@ export class EodHistoricalDataService implements DataProviderInterface {
public async getAssetProfile( public async getAssetProfile(
aSymbol: string aSymbol: string
): Promise<Partial<SymbolProfile>> { ): Promise<Partial<SymbolProfile>> {
const [searchResult] = await this.getSearchResult(aSymbol);
return { return {
dataSource: this.getName() assetClass: searchResult?.assetClass,
assetSubClass: searchResult?.assetSubClass,
currency: searchResult?.currency,
dataSource: this.getName(),
isin: searchResult?.isin,
name: searchResult?.name
}; };
} }
@ -122,32 +132,30 @@ export class EodHistoricalDataService implements DataProviderInterface {
200 200
); );
const [response, symbolProfiles] = await Promise.all([ const [realTimeResponse, searchResponse] = await Promise.all([
get(), get(),
this.symbolProfileService.getSymbolProfiles( this.search(aSymbols[0])
aSymbols.map((symbol) => {
return {
symbol,
dataSource: DataSource.EOD_HISTORICAL_DATA
};
})
)
]); ]);
const quotes = aSymbols.length === 1 ? [response] : response; const quotes =
aSymbols.length === 1 ? [realTimeResponse] : realTimeResponse;
return quotes.reduce((result, item, index, array) => { return quotes.reduce(
result[item.code] = { (
currency: symbolProfiles.find((symbolProfile) => { result: { [symbol: string]: IDataProviderResponse },
return symbolProfile.symbol === item.code; { close, code, timestamp }
})?.currency, ) => {
dataSource: DataSource.EOD_HISTORICAL_DATA, result[code] = {
marketPrice: item.close, currency: searchResponse?.items[0]?.currency,
marketState: 'delayed' dataSource: DataSource.EOD_HISTORICAL_DATA,
}; marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
};
return result; return result;
}, {}); },
{}
);
} catch (error) { } catch (error) {
Logger.error(error, 'EodHistoricalDataService'); Logger.error(error, 'EodHistoricalDataService');
} }
@ -156,6 +164,117 @@ export class EodHistoricalDataService implements DataProviderInterface {
} }
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; const searchResult = await this.getSearchResult(aQuery);
return {
items: searchResult
.filter(({ symbol }) => {
return !symbol.toLowerCase().endsWith('forex');
})
.map(
({
assetClass,
assetSubClass,
currency,
dataSource,
name,
symbol
}) => {
return {
assetClass,
assetSubClass,
currency,
dataSource,
name,
symbol
};
}
)
};
}
private async getSearchResult(aQuery: string): Promise<
(LookupItem & {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
isin: string;
})[]
> {
let searchResult = [];
try {
const get = bent(
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
'GET',
'json',
200
);
const response = await get();
searchResult = response.map(
({
Code,
Currency: currency,
Exchange,
ISIN: isin,
Name: name,
Type
}) => {
const { assetClass, assetSubClass } = this.parseAssetClass({
Exchange,
Type
});
return {
assetClass,
assetSubClass,
currency,
isin,
name,
dataSource: this.getName(),
symbol: `${Code}.${Exchange}`
};
}
);
} catch (error) {
Logger.error(error, 'EodHistoricalDataService');
}
return searchResult;
}
private parseAssetClass({
Exchange,
Type
}: {
Exchange: string;
Type: string;
}): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
} {
let assetClass: AssetClass;
let assetSubClass: AssetSubClass;
switch (Type?.toLowerCase()) {
case 'common stock':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.STOCK;
break;
case 'currency':
assetClass = AssetClass.CASH;
if (Exchange?.toLowerCase() === 'cc') {
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
}
break;
case 'etf':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break;
}
return { assetClass, assetSubClass };
} }
} }

View File

@ -1,194 +0,0 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATE_FORMAT,
extractNumberFromString,
getYesterday
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import * as cheerio from 'cheerio';
import { addDays, format, isBefore } from 'date-fns';
@Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface {
public constructor(
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public canHandle(symbol: string) {
return true;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
};
}
public async getDividends({
from,
granularity = 'day',
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
return {};
}
public async getHistorical(
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
try {
const symbol = aSymbol;
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration;
if (defaultMarketPrice) {
const historical: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[symbol]: {}
};
let date = from;
while (isBefore(date, to)) {
historical[symbol][format(date, DATE_FORMAT)] = {
marketPrice: defaultMarketPrice
};
date = addDays(date, 1);
}
return historical;
} else if (selector === undefined || url === undefined) {
return {};
}
const get = bent(url, 'GET', 'string', 200, {});
const html = await get();
const $ = cheerio.load(html);
const value = extractNumberFromString($(selector).text());
return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: value
}
}
};
} catch (error) {
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getName(): DataSource {
return DataSource.GHOSTFOLIO;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) {
return response;
}
try {
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
const marketData = await this.prismaService.marketData.findMany({
distinct: ['symbol'],
orderBy: {
date: 'desc'
},
take: aSymbols.length,
where: {
symbol: {
in: aSymbols
}
}
});
for (const symbolProfile of symbolProfiles) {
response[symbolProfile.symbol] = {
currency: symbolProfile.currency,
dataSource: this.getName(),
marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
}).marketPrice,
marketState: 'delayed'
};
}
return response;
} catch (error) {
Logger.error(error, 'GhostfolioScraperApiService');
}
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
}
}

View File

@ -145,6 +145,8 @@ export class GoogleSheetsService implements DataProviderInterface {
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({ const items = await this.prismaService.symbolProfile.findMany({
select: { select: {
assetClass: true,
assetSubClass: true,
currency: true, currency: true,
dataSource: true, dataSource: true,
name: true, name: true,

View File

@ -6,9 +6,18 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATE_FORMAT,
extractNumberFromString,
getYesterday
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import * as cheerio from 'cheerio';
import { isUUID } from 'class-validator';
import { addDays, format, isBefore } from 'date-fns';
@Injectable() @Injectable()
export class ManualService implements DataProviderInterface { export class ManualService implements DataProviderInterface {
@ -18,7 +27,7 @@ export class ManualService implements DataProviderInterface {
) {} ) {}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return false; return true;
} }
public async getAssetProfile( public async getAssetProfile(
@ -51,7 +60,57 @@ export class ManualService implements DataProviderInterface {
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
return {}; try {
const symbol = aSymbol;
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration ?? {};
if (defaultMarketPrice) {
const historical: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[symbol]: {}
};
let date = from;
while (isBefore(date, to)) {
historical[symbol][format(date, DATE_FORMAT)] = {
marketPrice: defaultMarketPrice
};
date = addDays(date, 1);
}
return historical;
} else if (selector === undefined || url === undefined) {
return {};
}
const get = bent(url, 'GET', 'string', 200, {});
const html = await get();
const $ = cheerio.load(html);
const value = extractNumberFromString($(selector).text());
return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: value
}
}
};
} catch (error) {
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
} }
public getName(): DataSource { public getName(): DataSource {
@ -88,10 +147,9 @@ export class ManualService implements DataProviderInterface {
response[symbolProfile.symbol] = { response[symbolProfile.symbol] = {
currency: symbolProfile.currency, currency: symbolProfile.currency,
dataSource: this.getName(), dataSource: this.getName(),
marketPrice: marketPrice: marketData.find((marketDataItem) => {
marketData.find((marketDataItem) => { return marketDataItem.symbol === symbolProfile.symbol;
return marketDataItem.symbol === symbolProfile.symbol; })?.marketPrice,
})?.marketPrice ?? 0,
marketState: 'delayed' marketState: 'delayed'
}; };
} }
@ -105,8 +163,10 @@ export class ManualService implements DataProviderInterface {
} }
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({ let items = await this.prismaService.symbolProfile.findMany({
select: { select: {
assetClass: true,
assetSubClass: true,
currency: true, currency: true,
dataSource: true, dataSource: true,
name: true, name: true,
@ -132,6 +192,11 @@ export class ManualService implements DataProviderInterface {
} }
}); });
items = items.filter(({ symbol }) => {
// Remove UUID symbols (activities of type ITEM)
return !isUUID(symbol);
});
return { items }; return { items };
} }
} }

View File

@ -101,9 +101,10 @@ export class YahooFinanceService implements DataProviderInterface {
modules: ['price', 'summaryProfile', 'topHoldings'] modules: ['price', 'summaryProfile', 'topHoldings']
}); });
const { assetClass, assetSubClass } = this.parseAssetClass( const { assetClass, assetSubClass } = this.parseAssetClass({
assetProfile.price quoteType: assetProfile.price.quoteType,
); shortName: assetProfile.price.shortName
});
response.assetClass = assetClass; response.assetClass = assetClass;
response.assetSubClass = assetSubClass; response.assetSubClass = assetSubClass;
@ -408,7 +409,14 @@ export class YahooFinanceService implements DataProviderInterface {
marketDataItem.symbol marketDataItem.symbol
); );
const { assetClass, assetSubClass } = this.parseAssetClass({
quoteType: quote.quoteType,
shortName: quote.shortname
});
items.push({ items.push({
assetClass,
assetSubClass,
symbol, symbol,
currency: marketDataItem.currency, currency: marketDataItem.currency,
dataSource: this.getName(), dataSource: this.getName(),
@ -441,8 +449,10 @@ export class YahooFinanceService implements DataProviderInterface {
let name = longName; let name = longName;
if (name) { if (name) {
name = name.replace('Amundi Index Solutions - ', '');
name = name.replace('iShares ETF (CH) - ', ''); name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', ''); name = name.replace('iShares III Public Limited Company - ', '');
name = name.replace('iShares V PLC - ', '');
name = name.replace('iShares VI Public Limited Company - ', ''); name = name.replace('iShares VI Public Limited Company - ', '');
name = name.replace('iShares VII PLC - ', ''); name = name.replace('iShares VII PLC - ', '');
name = name.replace('Multi Units Luxembourg - ', ''); name = name.replace('Multi Units Luxembourg - ', '');
@ -482,14 +492,20 @@ export class YahooFinanceService implements DataProviderInterface {
return value; return value;
} }
private parseAssetClass(aPrice: Price): { private parseAssetClass({
quoteType,
shortName
}: {
quoteType: string;
shortName: string;
}): {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;
} { } {
let assetClass: AssetClass; let assetClass: AssetClass;
let assetSubClass: AssetSubClass; let assetSubClass: AssetSubClass;
switch (aPrice?.quoteType?.toLowerCase()) { switch (quoteType?.toLowerCase()) {
case 'cryptocurrency': case 'cryptocurrency':
assetClass = AssetClass.CASH; assetClass = AssetClass.CASH;
assetSubClass = AssetSubClass.CRYPTOCURRENCY; assetSubClass = AssetSubClass.CRYPTOCURRENCY;
@ -507,10 +523,10 @@ export class YahooFinanceService implements DataProviderInterface {
assetSubClass = AssetSubClass.COMMODITY; assetSubClass = AssetSubClass.COMMODITY;
if ( if (
aPrice?.shortName?.toLowerCase()?.startsWith('gold') || shortName?.toLowerCase()?.startsWith('gold') ||
aPrice?.shortName?.toLowerCase()?.startsWith('palladium') || shortName?.toLowerCase()?.startsWith('palladium') ||
aPrice?.shortName?.toLowerCase()?.startsWith('platinum') || shortName?.toLowerCase()?.startsWith('platinum') ||
aPrice?.shortName?.toLowerCase()?.startsWith('silver') shortName?.toLowerCase()?.startsWith('silver')
) { ) {
assetSubClass = AssetSubClass.PRECIOUS_METAL; assetSubClass = AssetSubClass.PRECIOUS_METAL;
} }

View File

@ -168,7 +168,7 @@ export class ExchangeRateDataService {
return this.toCurrency(aValue, aFromCurrency, aToCurrency); return this.toCurrency(aValue, aFromCurrency, aToCurrency);
} }
let factor = 1; let factor: number;
if (aFromCurrency !== aToCurrency) { if (aFromCurrency !== aToCurrency) {
const dataSource = this.dataProviderService.getPrimaryDataSource(); const dataSource = this.dataProviderService.getPrimaryDataSource();
@ -183,9 +183,29 @@ export class ExchangeRateDataService {
if (marketData?.marketPrice) { if (marketData?.marketPrice) {
factor = marketData?.marketPrice; factor = marketData?.marketPrice;
} else { } else {
// TODO: Get from data provider service or calculate indirectly via base currency // Calculate indirectly via base currency
// and market data try {
return this.toCurrency(aValue, aFromCurrency, aToCurrency); const [
{ marketPrice: marketPriceBaseCurrencyFromCurrency },
{ marketPrice: marketPriceBaseCurrencyToCurrency }
] = await Promise.all([
this.marketDataService.get({
dataSource,
date: aDate,
symbol: `${this.baseCurrency}${aFromCurrency}`
}),
this.marketDataService.get({
dataSource,
date: aDate,
symbol: `${this.baseCurrency}${aToCurrency}`
})
]);
// Calculate the opposite direction
factor =
(1 / marketPriceBaseCurrencyFromCurrency) *
marketPriceBaseCurrencyToCurrency;
} catch {}
} }
} }
@ -193,12 +213,15 @@ export class ExchangeRateDataService {
return factor * aValue; return factor * aValue;
} }
// Fallback with error, if currencies are not available
Logger.error( Logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`, `No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format(
aDate,
DATE_FORMAT
)}`,
'ExchangeRateDataService' 'ExchangeRateDataService'
); );
return aValue;
return undefined;
} }
private async prepareCurrencies(): Promise<string[]> { private async prepareCurrencies(): Promise<string[]> {

View File

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

View File

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

View File

@ -1,18 +0,0 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.io/guide/browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
last 1 Chrome version
last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR
not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line.
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.

View File

@ -65,7 +65,10 @@
"output": "./../assets/" "output": "./../assets/"
} }
], ],
"styles": ["apps/client/src/styles.scss"], "styles": [
"apps/client/src/styles/theme.scss",
"apps/client/src/styles.scss"
],
"scripts": ["node_modules/marked/marked.min.js"], "scripts": ["node_modules/marked/marked.min.js"],
"vendorChunk": true, "vendorChunk": true,
"extractLicenses": false, "extractLicenses": false,

View File

@ -116,6 +116,20 @@ const routes: Routes = [
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module' './pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule) ).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule)
}, },
{
path: 'blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt',
loadChildren: () =>
import(
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
},
{
path: 'blog/2023/02/ghostfolio-meets-umbrel',
loadChildren: () =>
import(
'./pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.module'
).then((m) => m.GhostfolioMeetsUmbrelPageModule)
},
{ {
path: 'demo', path: 'demo',
loadChildren: () => loadChildren: () =>
@ -215,9 +229,8 @@ const routes: Routes = [
// Preload all lazy loaded modules with the attribute preload === true // Preload all lazy loaded modules with the attribute preload === true
{ {
anchorScrolling: 'enabled', anchorScrolling: 'enabled',
preloadingStrategy: ModulePreloadService, preloadingStrategy: ModulePreloadService
// enableTracing: true // <-- debugging purposes only // enableTracing: true // <-- debugging purposes only
relativeLinkResolution: 'legacy'
} }
) )
], ],

View File

@ -1,26 +1,17 @@
import { DOCUMENT } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
Inject,
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
ActivatedRoute,
NavigationEnd,
PRIMARY_OUTLET,
Router
} from '@angular/router';
import {
primaryColorHex,
secondaryColorHex,
warnColorHex
} from '@ghostfolio/common/config';
import { InfoItem, User } from '@ghostfolio/common/interfaces'; import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { MaterialCssVarsService } from 'angular-material-css-vars';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators'; import { filter, takeUntil } from 'rxjs/operators';
@ -52,7 +43,7 @@ export class AppComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private materialCssVarsService: MaterialCssVarsService, @Inject(DOCUMENT) private document: Document,
private router: Router, private router: Router,
private title: Title, private title: Title,
private tokenStorageService: TokenStorageService, private tokenStorageService: TokenStorageService,
@ -113,7 +104,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.tokenStorageService.signOut(); this.tokenStorageService.signOut();
this.userService.remove(); this.userService.remove();
document.location.href = '/'; document.location.href = `/${document.documentElement.lang}`;
} }
public ngOnDestroy() { public ngOnDestroy() {
@ -126,16 +117,20 @@ export class AppComponent implements OnDestroy, OnInit {
? userPreferredColorScheme === 'DARK' ? userPreferredColorScheme === 'DARK'
: window.matchMedia('(prefers-color-scheme: dark)').matches; : window.matchMedia('(prefers-color-scheme: dark)').matches;
this.materialCssVarsService.setDarkTheme(isDarkTheme); this.toggleThemeStyleClass(isDarkTheme);
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => { window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
if (!this.user?.settings.colorScheme) { if (!this.user?.settings.colorScheme) {
this.materialCssVarsService.setDarkTheme(event.matches); this.toggleThemeStyleClass(event.matches);
} }
}); });
}
this.materialCssVarsService.setPrimaryColor(primaryColorHex); private toggleThemeStyleClass(isDarkTheme: boolean) {
this.materialCssVarsService.setAccentColor(secondaryColorHex); if (isDarkTheme) {
this.materialCssVarsService.setWarnColor(warnColorHex); this.document.body.classList.add('is-dark-theme');
} else {
this.document.body.classList.remove('is-dark-theme');
}
} }
} }

View File

@ -1,20 +1,19 @@
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 { NgModule } from '@angular/core';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
import { import {
DateAdapter, DateAdapter,
MAT_DATE_FORMATS, MAT_DATE_FORMATS,
MAT_DATE_LOCALE, MAT_DATE_LOCALE,
MatNativeDateModule MatNativeDateModule
} from '@angular/material/core'; } from '@angular/material/core';
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatLegacyAutocompleteModule as MatAutocompleteModule } from '@angular/material/legacy-autocomplete';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatLegacyChipsModule as MatChipsModule } from '@angular/material/legacy-chips';
import { MatLegacySnackBarModule as MatSnackBarModule } from '@angular/material/legacy-snack-bar';
import { MatLegacyTooltipModule as MatTooltipModule } from '@angular/material/legacy-tooltip';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ServiceWorkerModule } from '@angular/service-worker'; import { ServiceWorkerModule } from '@angular/service-worker';
import { MaterialCssVarsModule } from 'angular-material-css-vars';
import { MarkdownModule } from 'ngx-markdown'; import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe'; import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe';
@ -25,6 +24,7 @@ import { DateFormats } from './adapter/date-formats';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { GfHeaderModule } from './components/header/header.module'; import { GfHeaderModule } from './components/header/header.module';
import { GfSubscriptionInterstitialDialogModule } from './components/subscription-interstitial-dialog/subscription-interstitial-dialog.module';
import { authInterceptorProviders } from './core/auth.interceptor'; import { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor'; import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
import { LanguageService } from './core/language.service'; import { LanguageService } from './core/language.service';
@ -40,15 +40,11 @@ export function NgxStripeFactory(): string {
BrowserAnimationsModule, BrowserAnimationsModule,
BrowserModule, BrowserModule,
GfHeaderModule, GfHeaderModule,
GfSubscriptionInterstitialDialogModule,
HttpClientModule, HttpClientModule,
MarkdownModule.forRoot(), MarkdownModule.forRoot(),
MatAutocompleteModule, MatAutocompleteModule,
MatChipsModule, MatChipsModule,
MaterialCssVarsModule.forRoot({
darkThemeClass: 'is-dark-theme',
isAutoContrast: true,
lightThemeClass: 'is-light-theme'
}),
MatNativeDateModule, MatNativeDateModule,
MatSnackBarModule, MatSnackBarModule,
MatTooltipModule, MatTooltipModule,

View File

@ -7,7 +7,7 @@ import {
OnInit, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table'; import { MatLegacyTableDataSource as MatTableDataSource } from '@angular/material/legacy-table';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';

View File

@ -1,8 +1,8 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatMenuModule } from '@angular/material/menu'; import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { MatTableModule } from '@angular/material/table'; import { MatLegacyTableModule as MatTableModule } from '@angular/material/legacy-table';
import { AccessTableComponent } from './access-table.component'; import { AccessTableComponent } from './access-table.component';

View File

@ -6,7 +6,10 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import {
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
MatLegacyDialogRef as MatDialogRef
} from '@angular/material/legacy-dialog';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper'; import { downloadAsFile } from '@ghostfolio/common/helper';

View File

@ -40,7 +40,6 @@
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="true" [hasPermissionToExportActivities]="true"
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]="false" [showActions]="false"

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-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 { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module'; import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';

View File

@ -9,8 +9,8 @@ import {
Output, Output,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatLegacyTableDataSource as MatTableDataSource } from '@angular/material/legacy-table';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Account as AccountModel } from '@prisma/client'; import { Account as AccountModel } from '@prisma/client';
import { get } from 'lodash'; import { get } from 'lodash';

View File

@ -1,10 +1,9 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatInputModule } from '@angular/material/input'; import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { MatMenuModule } from '@angular/material/menu'; import { MatLegacyTableModule as MatTableModule } from '@angular/material/legacy-table';
import { MatSortModule } from '@angular/material/sort'; import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module'; import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -20,7 +19,6 @@ import { AccountsTableComponent } from './accounts-table.component';
GfSymbolIconModule, GfSymbolIconModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatInputModule,
MatMenuModule, MatMenuModule,
MatSortModule, MatSortModule,
MatTableModule, MatTableModule,

View File

@ -1,9 +1,9 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatMenuModule } from '@angular/material/menu'; import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { MatSelectModule } from '@angular/material/select'; import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
import { AdminJobsComponent } from './admin-jobs.component'; import { AdminJobsComponent } from './admin-jobs.component';

View File

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

View File

@ -7,7 +7,7 @@ import {
OnInit, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DATE_FORMAT, DATE_FORMAT,

View File

@ -6,7 +6,10 @@ import {
OnDestroy OnDestroy
} from '@angular/core'; } from '@angular/core';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import {
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
MatLegacyDialogRef as MatDialogRef
} from '@angular/material/legacy-dialog';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@ -36,7 +39,7 @@ export class MarketDataDetailDialog implements OnDestroy {
this.dateAdapter.setLocale(this.locale); this.dateAdapter.setLocale(this.locale);
} }
public onCancel(): void { public onCancel() {
this.dialogRef.close({ withRefresh: false }); this.dialogRef.close({ withRefresh: false });
} }

View File

@ -1,6 +1,6 @@
<form class="d-flex flex-column h-100"> <form class="d-flex flex-column h-100">
<h1 i18n mat-dialog-title>Details for {{ data.symbol }}</h1> <h1 i18n mat-dialog-title>Details for {{ data.symbol }}</h1>
<div class="flex-grow-1" mat-dialog-content> <div class="flex-grow-1 pt-3" mat-dialog-content>
<div class="mb-3"> <div class="mb-3">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Date</mat-label> <mat-label i18n>Date</mat-label>
@ -11,7 +11,7 @@
[matDatepicker]="date" [matDatepicker]="date"
[(ngModel)]="data.date" [(ngModel)]="data.date"
/> />
<mat-datepicker-toggle matSuffix [for]="date"> <mat-datepicker-toggle class="mr-2" matSuffix [for]="date">
<ion-icon <ion-icon
class="text-muted" class="text-muted"
matDatepickerToggleIcon matDatepickerToggleIcon
@ -21,7 +21,7 @@
<mat-datepicker #date disabled="true"></mat-datepicker> <mat-datepicker #date disabled="true"></mat-datepicker>
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div class="align-items-start d-flex">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Market Price</mat-label> <mat-label i18n>Market Price</mat-label>
<input <input
@ -30,16 +30,16 @@
type="number" type="number"
[(ngModel)]="data.marketPrice" [(ngModel)]="data.marketPrice"
/> />
<span class="ml-2" matSuffix>{{ data.currency }}</span> <span class="ml-2" matTextSuffix>{{ data.currency }}</span>
<button
mat-icon-button
matSuffix
title="Fetch market price"
(click)="onFetchSymbolForDate()"
>
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
</button>
</mat-form-field> </mat-form-field>
<button
class="apply-current-market-price ml-2 no-min-width"
mat-button
title="Fetch market price"
(click)="onFetchSymbolForDate()"
>
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
</button>
</div> </div>
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>

View File

@ -3,9 +3,9 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
import { MarketDataDetailDialog } from './market-data-detail-dialog.component'; import { MarketDataDetailDialog } from './market-data-detail-dialog.component';

View File

@ -4,19 +4,9 @@
.mat-dialog-content { .mat-dialog-content {
max-height: unset; max-height: unset;
.mat-form-field-appearance-outline { .mat-mdc-button {
::ng-deep { &.apply-current-market-price {
.mat-form-field-suffix { height: 56px;
top: -0.3rem;
}
.mat-form-field-wrapper {
padding-bottom: 0;
}
}
ion-icon {
font-size: 130%;
} }
} }
} }

View File

@ -6,9 +6,9 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
import { MatLegacyTableDataSource as MatTableDataSource } from '@angular/material/legacy-table';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';

View File

@ -152,7 +152,7 @@
mat-menu-item mat-menu-item
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})" (click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
> >
<ng-container i18n>Gather Data</ng-container> <ng-container i18n>Gather Historical Data</ng-container>
</button> </button>
<button <button
mat-menu-item mat-menu-item

View File

@ -1,13 +1,13 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatMenuModule } from '@angular/material/menu'; import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { MatLegacyTableModule as MatTableModule } from '@angular/material/legacy-table';
import { MatSortModule } from '@angular/material/sort'; import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module'; import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { AdminMarketDataComponent } from './admin-market-data.component'; import { AdminMarketDataComponent } from './admin-market-data.component';
import { GfAssetProfileDialogModule } from './asset-profile-dialog/assset-profile-dialog.module'; import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
@NgModule({ @NgModule({
declarations: [AdminMarketDataComponent], declarations: [AdminMarketDataComponent],

View File

@ -7,7 +7,10 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import {
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
MatLegacyDialogRef as MatDialogRef
} from '@angular/material/legacy-dialog';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { import {

View File

@ -27,7 +27,7 @@
[disabled]="assetProfileForm.dirty" [disabled]="assetProfileForm.dirty"
(click)="onGatherSymbol({dataSource: data.dataSource, symbol: data.symbol})" (click)="onGatherSymbol({dataSource: data.dataSource, symbol: data.symbol})"
> >
<ng-container i18n>Gather Data</ng-container> <ng-container i18n>Gather Historical Data</ng-container>
</button> </button>
<button <button
mat-menu-item mat-menu-item

View File

@ -2,10 +2,10 @@ import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
import { MatInputModule } from '@angular/material/input'; import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
import { MatMenuModule } from '@angular/material/menu'; import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module'; import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module'; import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';

View File

@ -1,5 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatSlideToggleChange } from '@angular/material/slide-toggle'; import { MatLegacySlideToggleChange as MatSlideToggleChange } from '@angular/material/legacy-slide-toggle';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -29,7 +29,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-overview.html' templateUrl: './admin-overview.html'
}) })
export class AdminOverviewComponent implements OnDestroy, OnInit { export class AdminOverviewComponent implements OnDestroy, OnInit {
public couponDuration: StringValue = '30 days'; public couponDuration: StringValue = '14 days';
public coupons: Coupon[]; public coupons: Coupon[];
public customCurrencies: string[]; public customCurrencies: string[];
public exchangeRates: { label1: string; label2: string; value: number }[]; public exchangeRates: { label1: string; label2: string; value: number }[];

View File

@ -151,16 +151,23 @@
> >
<div class="w-50" i18n>Coupons</div> <div class="w-50" i18n>Coupons</div>
<div class="w-50"> <div class="w-50">
<div *ngFor="let coupon of coupons"> <table>
<span>{{ coupon.code }} ({{ coupon.duration }})</span> <tr *ngFor="let coupon of coupons">
<button <td class="text-monospace">{{ coupon.code }}</td>
class="mini-icon mx-1 no-min-width px-2" <td class="d-flex justify-content-end pl-2">
mat-button {{ coupon.duration }}
(click)="onDeleteCoupon(coupon.code)" </td>
> <td>
<ion-icon name="trash-outline"></ion-icon> <button
</button> class="mini-icon mx-1 no-min-width px-2"
</div> mat-button
(click)="onDeleteCoupon(coupon.code)"
>
<ion-icon name="trash-outline"></ion-icon>
</button>
</td>
</tr>
</table>
<div class="mt-2"> <div class="mt-2">
<form #couponForm="ngForm" class="align-items-center d-flex"> <form #couponForm="ngForm" class="align-items-center d-flex">
<mat-form-field <mat-form-field

View File

@ -1,10 +1,10 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatCardModule } from '@angular/material/card'; import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { MatSelectModule } from '@angular/material/select'; import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatLegacySlideToggleModule as MatSlideToggleModule } from '@angular/material/legacy-slide-toggle';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';

View File

@ -1,7 +1,9 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { AdminData, User } from '@ghostfolio/common/interfaces'; import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper';
import { AdminData, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import {
differenceInSeconds, differenceInSeconds,
formatDistanceToNowStrict, formatDistanceToNowStrict,
@ -16,6 +18,10 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html' templateUrl: './admin-users.html'
}) })
export class AdminUsersComponent implements OnDestroy, OnInit { export class AdminUsersComponent implements OnDestroy, OnInit {
public defaultDateFormat: string;
public getEmojiFlag = getEmojiFlag;
public hasPermissionForSubscription: boolean;
public info: InfoItem;
public user: User; public user: User;
public users: AdminData['users']; public users: AdminData['users'];
@ -26,11 +32,22 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private userService: UserService private userService: UserService
) { ) {
this.info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user.settings.locale
);
} }
}); });
} }

View File

@ -7,7 +7,13 @@
<tr class="mat-header-row"> <tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right">#</th> <th class="mat-header-cell px-1 py-2 text-right">#</th>
<th class="mat-header-cell px-1 py-2" i18n>User</th> <th class="mat-header-cell px-1 py-2" i18n>User</th>
<th class="mat-header-cell px-1 py-2 text-right"> <th
*ngIf="hasPermissionForSubscription"
class="mat-header-cell px-1 py-2"
>
<ng-container i18n>Country</ng-container>
</th>
<th class="mat-header-cell px-1 py-2">
<ng-container i18n>Registration</ng-container> <ng-container i18n>Registration</ng-container>
</th> </th>
<th class="mat-header-cell px-1 py-2 text-right"> <th class="mat-header-cell px-1 py-2 text-right">
@ -16,10 +22,19 @@
<th class="mat-header-cell px-1 py-2 text-right"> <th class="mat-header-cell px-1 py-2 text-right">
<ng-container i18n>Activities</ng-container> <ng-container i18n>Activities</ng-container>
</th> </th>
<th class="mat-header-cell px-1 py-2 text-right"> <th
*ngIf="hasPermissionForSubscription"
class="mat-header-cell px-1 py-2 text-right"
>
<ng-container i18n>Engagement per Day</ng-container> <ng-container i18n>Engagement per Day</ng-container>
</th> </th>
<th class="mat-header-cell px-1 py-2" i18n>Last Request</th> <th
*ngIf="hasPermissionForSubscription"
class="mat-header-cell px-1 py-2"
i18n
>
Last Request
</th>
<th class="mat-header-cell px-1 py-2"></th> <th class="mat-header-cell px-1 py-2"></th>
</tr> </tr>
</thead> </thead>
@ -28,20 +43,29 @@
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td> <td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block" <span class="d-none d-sm-inline-block text-monospace"
>{{ userItem.id }}</span >{{ userItem.id }}</span
> >
<span class="d-inline-block d-sm-none" <span class="d-inline-block d-sm-none text-monospace"
>{{ (userItem.id | slice:0:5) + '...' }}</span >{{ (userItem.id | slice:0:5) + '...' }}</span
> >
<gf-premium-indicator <gf-premium-indicator
*ngIf="userItem?.subscription?.type === 'Premium'" *ngIf="userItem?.subscription?.type === 'Premium'"
class="ml-1" class="ml-1"
[enableLink]="false" [enableLink]="false"
[title]="userItem.subscription.expiresAt | date: defaultDateFormat"
></gf-premium-indicator> ></gf-premium-indicator>
</div> </div>
</td> </td>
<td class="mat-cell px-1 py-2 text-right"> <td
*ngIf="hasPermissionForSubscription"
class="mat-cell px-1 py-2"
>
<span class="h5" [title]="userItem.country"
>{{ getEmojiFlag(userItem.country) }}</span
>
</td>
<td class="mat-cell px-1 py-2">
{{ formatDistanceToNow(userItem.createdAt) }} {{ formatDistanceToNow(userItem.createdAt) }}
</td> </td>
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2 text-right">
@ -58,7 +82,10 @@
[value]="userItem.transactionCount" [value]="userItem.transactionCount"
></gf-value> ></gf-value>
</td> </td>
<td class="mat-cell px-1 py-2 text-right"> <td
*ngIf="hasPermissionForSubscription"
class="mat-cell px-1 py-2 text-right"
>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
@ -66,7 +93,10 @@
[value]="userItem.engagement" [value]="userItem.engagement"
></gf-value> ></gf-value>
</td> </td>
<td class="mat-cell px-1 py-2"> <td
*ngIf="hasPermissionForSubscription"
class="mat-cell px-1 py-2"
>
{{ formatDistanceToNow(userItem.lastActivity) }} {{ formatDistanceToNow(userItem.lastActivity) }}
</td> </td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatMenuModule } from '@angular/material/menu'; import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';

View File

@ -27,6 +27,7 @@ import { ColorScheme } from '@ghostfolio/common/types';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { import {
Chart, Chart,
ChartData,
LineController, LineController,
LineElement, LineElement,
LinearScale, LinearScale,
@ -57,7 +58,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
public chart: Chart<any>; public chart: Chart<'line'>;
public constructor() { public constructor() {
Chart.register( Chart.register(
@ -89,14 +90,14 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
} }
private initialize() { private initialize() {
const data = { const data: ChartData<'line'> = {
datasets: [ datasets: [
{ {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2, borderWidth: 2,
data: this.performanceDataItems.map(({ date, value }) => { data: this.performanceDataItems.map(({ date, value }) => {
return { x: parseDate(date), y: value }; return { x: parseDate(date).getTime(), y: value };
}), }),
label: $localize`Portfolio` label: $localize`Portfolio`
}, },
@ -105,7 +106,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: 2, borderWidth: 2,
data: this.benchmarkDataItems.map(({ date, value }) => { data: this.benchmarkDataItems.map(({ date, value }) => {
return { x: parseDate(date), y: value }; return { x: parseDate(date).getTime(), y: value };
}), }),
label: $localize`Benchmark` label: $localize`Benchmark`
} }
@ -165,7 +166,6 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
}, },
display: true, display: true,
grid: { grid: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
display: false display: false
}, },
type: 'time', type: 'time',
@ -176,13 +176,21 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
}, },
y: { y: {
border: { border: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`, width: 0
display: false
}, },
display: true, display: true,
grid: { grid: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`, color: ({ scale, tick }) => {
display: false if (
tick.value === 0 ||
tick.value === scale.max ||
tick.value === scale.min
) {
return `rgba(${getTextColor(this.colorScheme)}, 0.1)`;
}
return 'transparent';
}
}, },
position: 'right', position: 'right',
ticks: { ticks: {

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select'; import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { DialogFooterComponent } from './dialog-footer.component'; import { DialogFooterComponent } from './dialog-footer.component';

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { DialogHeaderComponent } from './dialog-header.component'; import { DialogHeaderComponent } from './dialog-header.component';

View File

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

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