Compare commits

..

272 Commits

Author SHA1 Message Date
e975f92a96 Release 1.252.0 (#1848) 2023-04-10 18:03:02 +02:00
739cb4242d Feature/decrease density of theme (#1846)
* Decrease density

* Update changelog
2023-04-10 17:59:29 +02:00
a37eebc9f1 Feature/upgrade nestjs from version 9.1.4 to 9.4.0 (#1843)
* Upgrade nestjs from version 9.1.4 to 9.4.0

* Update changelog
2023-04-10 13:50:27 +02:00
e92730879e Feature/migrate dialog components to angular material 15 (#1844)
* Migrate MatDialog

* Update changelog
2023-04-10 13:49:53 +02:00
464973f9b0 Feature/migrate form components to angular material 15 (#1842)
* Upgrade @angular/cdk

* Upgrade form components to Angular Material 15

* Update changelog
2023-04-10 10:59:44 +02:00
f6228c099f Migrate MatRadio (#1841) 2023-04-10 09:23:16 +02:00
a57fdfb2bb Feature/migrate slide toggle components to angular material 15 (#1840)
* Upgrade @angular/material

* Change MatSlideToggle to MatCheckbox

* Update changelog
2023-04-09 09:33:36 +02:00
24716f0561 Migrate checkbox and chips components to Angular Material 15 (#1839) 2023-04-09 08:55:09 +02:00
3453100afd Feature/migrate various components to angular material 15 part 2 (#1838)
* Migrate tooltips to Angular Material 15

* Migrate tabs to Angular Material 15
2023-04-09 08:54:32 +02:00
84de2c0c68 Migrate card components to Angular Material 15 (#1837) 2023-04-08 17:08:30 +02:00
1b7b082003 Feature/migrate various components to angular material 15 (#1836)
* Migrate components to Angular Material 15

* Update changelog
2023-04-08 15:33:27 +02:00
1928c2c2cc Release 1.251.0 (#1834) 2023-04-07 19:33:26 +02:00
52e7a7886d Feature/migrate libs components to angular material 15 (#1833)
* Migrate to Angular Material 15

* Update changelog
2023-04-07 17:10:03 +02:00
36298b217e Feature/improve tick abbreviation function (#1828)
* Keep the value if value smaller than 1000

* Update changelog
2023-04-07 17:09:34 +02:00
9bce57894e Feature/increase historical market data gathering to 10 years (#1830)
* Increase historical market data gathering of currency pairs to 10+ years

* Update changelog
2023-04-07 16:35:37 +02:00
9d6bb325cd Improve initialization (#1832) 2023-04-07 16:33:55 +02:00
a5f833c612 Feature/upgrade angular and nx 20230405 (#1826)
* Upgrade angular and Nx

* Update changelog
2023-04-06 19:18:23 +02:00
732b14c6ab Feature/improve activities import for csv files of ibkr (#1824)
* Improve import for csv files by Interactive Brokers

* Update changelog
2023-04-05 20:09:00 +02:00
b74a042da8 Feature/change auth endpoint from get to post (#1823)
* Change auth endpoint from GET to POST
  * Login with security token
  * Login with Internet Identity

* Update changelog
2023-04-05 18:10:29 +02:00
d55c052f57 Feature/improve content of pricing and faq pages (#1822)
* Improve content

* Update changelog
2023-04-03 17:39:32 +02:00
864f585efa Release/1.250.0 (#1821) 2023-04-02 09:47:13 +02:00
6d56146054 Feature/add support for multiple subscription offers (#1818)
* Setup for multiple subscription offers

* Update changelog
2023-04-02 09:44:13 +02:00
2c9f29a3c6 Feature/improve handling of platforms in accounts import (#1820)
* Improve handling of platforms

* Fix issue with pagination

* Update changelog
2023-04-01 10:51:55 +02:00
9bef2e960c Feature/ignore first item in portfolio evolution chart (#1816)
* Ignore first item in portfolio evolution chart

* Update changelog
2023-04-01 10:29:39 +02:00
17b8c41673 Add test file (#1810) 2023-03-30 08:24:44 +02:00
f0afbd7346 Release 1.249.0 (#1815) 2023-03-27 20:38:46 +02:00
5dc7429f6a Feature/add testimonials (#1814)
* Add testimonials

* Update changelog
2023-03-27 20:36:49 +02:00
7b39b32293 Feature/improve allocations page (#1813)
* Improve loading state

* Update changelog
2023-03-26 17:18:48 +02:00
e5b5a9e7e9 Feature/improve language localization for german (#1809)
* Improve language localization

* Update changelog
2023-03-26 17:17:48 +02:00
1f3511368a Feature/always show label in value component (#1812)
* Always show label while loading

* Update changelog
2023-03-26 16:59:52 +02:00
b37df2c84f Bugfix/fix algebraic sign in value component (#1811)
* Fix algebraic sign by resetting member variables

* Update changelog
2023-03-26 16:16:24 +02:00
f92ba54060 Release/1.248.0 (#1808) 2023-03-25 17:45:51 +01:00
a3bbd4030e Improve blog post and add images (#1807) 2023-03-25 17:42:41 +01:00
4b30da2d92 Feature/conditionally hide platform selector (#1805)
* Hide platform selector conditionally

* Update changelog
2023-03-25 16:46:46 +01:00
93d082afbb Feature/add blog post 1000 stars on GitHub (#1804)
* Add blog post: 1000 Stars on GitHub

* Add breadcrumb navigation

* Update changelog
2023-03-25 14:28:06 +01:00
0c85380dbf Feature/refactor portfolio calculator (#1803)
* Refactor chart calculation in portfolio calculator

* Update changelog
2023-03-25 12:20:42 +01:00
fb576376dc Feature/upgrade ng extract i18n merge (#1802)
* Upgrade ng-extract-i18n-merge

* Extract locales

* Update changelog
2023-03-24 17:34:27 +01:00
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
a834ef6b4c Release 1.228.1 (#1617) 2023-01-18 22:01:59 +01:00
e5bd0d1bfa Release 1.228.0 (#1616) 2023-01-18 21:39:11 +01:00
7fa6eda45d Feature/remove emergency fund as asset class (#1615)
* Remove emergency fund as asset class

* Update changelog
2023-01-18 21:37:22 +01:00
f47e4d3b04 Clean up imports (#1613) 2023-01-17 20:10:08 +01:00
0300c6f3b7 Feature/extend hints on account page (#1609)
* Extend hints

* Update changelog
2023-01-17 20:09:50 +01:00
4865c45fd4 Feature/reduce data gathering interval to every four hours (#1611)
* Reduce execution interval

* Update changelog
2023-01-17 10:04:03 +01:00
2beceb36cf Overriding tooltip title for graphs where grouping is defined (#1605)
* Overriding tooltip title for graphs where grouping is defined

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-01-16 10:46:48 +01:00
cd64601482 Release 1.227.1 (#1608) 2023-01-14 19:12:18 +01:00
efac39eb51 Feature/extract locales 20230114 (#1607)
* Extract locales

* Update changelog
2023-01-14 19:11:14 +01:00
4da8a547ca Bugfix/fix create activity dialog caused by missing tags (#1606)
* Add guard

* Update changelog
2023-01-14 19:10:28 +01:00
9e8a9e4670 Release 1.227.0 (#1603) 2023-01-14 13:37:06 +01:00
bb99141e9c Fix group by month/year not working for YTD (#1598)
* Set time to 00:00:00 when getting current timestamp

* Update changelog
2023-01-14 13:30:51 +01:00
d147c2313f Feature/support assets in emergency fund (#1601)
* Support assets in emergency fund

* Update changelog
2023-01-14 13:13:26 +01:00
0878941c4f Feature/support translated tags (#1600)
* Support translated tags

* Update changelog
2023-01-14 12:36:30 +01:00
69a9e77820 Feature/improve logo alignment (#1599)
* Improve logo alignment

* Update changelog
2023-01-14 12:22:52 +01:00
104cca069f New Translations (#1597) 2023-01-13 16:19:48 +01:00
7ad58b1a62 Add i18n (#1594) 2023-01-13 08:58:13 +01:00
e88dbb0181 Fix wording (#1593) 2023-01-12 20:14:00 +01:00
152fd4fdf8 Release 1.226.0 (#1592) 2023-01-11 20:12:45 +01:00
6b022b8de8 Extract locales (#1591) 2023-01-11 20:10:57 +01:00
7ab699e5fe Feature/uncover french translation feature (#1590)
* Uncover Français

* Update changelog
2023-01-11 19:59:41 +01:00
a7e5a316be Bugfix/fix big.js exception in report endpoint (#1586)
* Fix exception with missing marketPrice

* Update changelog
2023-01-11 19:59:13 +01:00
3f2d3a2da9 Minor improvements (#1589) 2023-01-11 19:45:44 +01:00
0208bd0923 Feature/French translation (#1583)
* Finished the French translation
2023-01-11 19:38:46 +01:00
aeba6e1f03 Feature/configure thousand separator in world map chart (#1588)
* Set thousandSeparator

* Update changelog
2023-01-11 07:47:44 +01:00
1b899da9ff Minor tweaks to README.md (#1585)
* Minor tweaks to README.md

- Reduced reliance on HTML in the README.md file.
- Added alt text on images for improved accessibility
- Very minor formatting tweaks

Signed-off-by: Martin Vandenbussche <vandenbusschemartin@gmail.com>
2023-01-10 20:59:32 +01:00
90a7a84ac5 Feature/add global heat map to landing page (#1584)
* Add global heat map

* Update changelog
2023-01-10 20:52:05 +01:00
fc8e23a9c8 Feature/improve form of import dividends dialog (#1582)
* Disable while loading

* Update changelog
2023-01-10 20:43:48 +01:00
f3c8ec27cb Feature/improve deprecated sass imports (#1581)
* Improve deprecated Sass imports

* Update changelog
2023-01-10 20:06:42 +01:00
38474f54b0 Release 1.225.0 (#1580) 2023-01-07 18:26:38 +01:00
18d25fb6c2 Feature/extend faq page (#1577)
* Extend FAQ page

* Update changelog
2023-01-07 18:21:09 +01:00
a850e8ca22 Import dividend (#1560)
* Import dividend

* Update changelog
2023-01-07 18:20:02 +01:00
b5f565c054 Simplify data source transformation (#1578) 2023-01-07 17:06:42 +01:00
aa6d0a4533 Update dates (#1575) 2023-01-07 08:48:06 +01:00
25e9028a41 Release 1.224.0 (#1573) 2023-01-04 20:15:29 +01:00
925d38703e Feature/add group by year option on analysis page (#1568)
* Add group by year option
2023-01-04 20:13:13 +01:00
158bb00b8a Add missing dutch translations (#1572)
* Add missing dutch translations

* Update changelog
2023-01-03 21:12:01 +01:00
b17111e6f1 Feature/setup fr (#1571)
* Setup fr

* Update changelog
2023-01-02 21:19:19 +01:00
c4765e31cd Feature/setup pt (#1439)
* Setup pt

* Update changelog
2023-01-02 20:52:48 +01:00
d321d56dee Release 1.223.0 (#1566) 2023-01-01 18:53:10 +01:00
07dd22f7fe Feature/extend asset profile details dialog by currency and symbol (#1565)
* Extend asset profile details dialog

* Update changelog
2023-01-01 12:05:28 +01:00
eb4d088a80 Feature/optimize page title for mobile (#1564)
* Optimize page title for mobile

* Update changelog
2023-01-01 09:57:27 +01:00
0509f0101f Feature/add student discount (#1563)
* Add student discount

* Update changelog
2023-01-01 09:22:38 +01:00
8818e09be8 Feature/add prefix to coupon codes (#1562)
* Add prefix

* Update changelog
2022-12-31 17:06:15 +01:00
d97fe4da9c Release 1.222.0 (#1561) 2022-12-29 18:11:26 +01:00
b20fa55b79 Feature/add filters to analytics page (#1559)
* Add filters to analysis page

* Update changelog
2022-12-29 10:31:21 +01:00
dd7a6f1562 Bugfix/fix i18n for account type (#1554)
* Translate account type

* Update changelog
2022-12-29 10:20:23 +01:00
15357bd5b5 Feature/add asset profile details to activities import (#1552)
* Add asset profile details

* Update changelog
2022-12-29 10:19:30 +01:00
52c7adc266 Feature/upgrade internet identity dependencies to version 0.15.1 (#1549)
* Upgrade Internet Identity dependencies to version 0.15.1

* Update changelog
2022-12-28 20:08:30 +01:00
1ae8970045 Feature/add price to subscription (#1551)
* Add price

* Update changelog
2022-12-28 13:57:15 +01:00
7c4c047140 Remove .gitkeep (#1553) 2022-12-28 13:45:30 +01:00
527f7e4faf Feature/upgrade observable store to version 2.2.15 (#1550)
* Upgrade observable-store to version 2.2.15

* Update changelog
2022-12-28 13:43:28 +01:00
50160eb9dc Feature/upgrade countup.js to version 2.3.2 (#1548)
* Upgrade countup.js to version 2.3.2

* Update changelog
2022-12-28 13:22:19 +01:00
58dff8a1e0 Feature/upgrade prisma to version 4.8.0 (#1547)
* Upgrade prisma to version 4.8.0

* Update changelog
2022-12-28 10:40:34 +01:00
2cd41615b2 Feature/change execution time of asset profile data gathering (#1544)
* Change execution time

* Update changelog
2022-12-28 09:31:46 +01:00
66d5793528 Refactoring (#1545) 2022-12-27 10:05:26 +01:00
e8d65e1c85 Feature/upgrade bull to version 4.10.2 (#1542)
* Upgrade bull to version 4.10.2

* Update changelog
2022-12-27 09:12:44 +01:00
da827a08f5 Release 1.221.0 (#1541) 2022-12-26 17:09:51 +01:00
d545e4877c Feature/improve activities import by preview step (#1540)
* Improve activities import

* Update changelog
2022-12-26 17:07:51 +01:00
1918dee9c5 Format code 2022-12-26 16:50:50 +01:00
a08610b603 Feature/improve activities import (#1531) 2022-12-26 16:26:51 +01:00
c22733db56 Feature/resolve blog post titles (#1539)
* Resolve blog post titles

* Update changelog
2022-12-26 16:23:21 +01:00
ee4866eb7d Feature/add blog post the importance of tracking your personal finances (#1537)
* Add blog post: The importance of tracking your personal finances

* Update changelog
2022-12-26 12:23:43 +01:00
327b1fa0d7 Feature/refresh cryptocurrencies list 20221225 (#1536)
* Update cryptocurrencies.json

* Update changelog
2022-12-25 18:17:35 +01:00
b155666d21 Feature/improve label based on type in create or edit activity dialog (#1535)
* Improve label

* Update changelog
2022-12-25 12:41:48 +01:00
c5ee3237ed Refactoring (#1533) 2022-12-25 12:40:29 +01:00
16118d635c Bugfix/fix date conversion of two digit year (#1529)
* Fix date conversion of two digit year

* Update changelog
2022-12-25 12:38:40 +01:00
49ce4803ce Feature/add support to manage tags in create or edit activity dialog (#1532)
* Add support to manage tags

* Update changelog
2022-12-25 12:23:52 +01:00
0b65d05013 Feature/remove rakuten from data source type (#1534)
* Remove RAKUTEN

* Update changelog
2022-12-25 12:20:09 +01:00
8793284e75 Feature/add tags to admin control panel (#1530)
* Add tags

* Update changelog
2022-12-24 12:28:17 +01:00
1c5e4050a8 Release 1.220.0 (#1528) 2022-12-23 11:08:22 +01:00
4f187e1a9f Feature/increase fear and greed index to 365 days (#1519)
* Increase fear and greed index to 365 days

* Update changelog
2022-12-23 11:06:52 +01:00
b56111ae85 Feature/add dry run to import api endpoint (#1526)
* Add dry run to import API endpoint

* Update changelog
2022-12-23 10:51:49 +01:00
61dfc1f819 Feature/upgrade prettier to version 2.8.1 (#1523)
* Upgrade prettier to version 2.8.1

* Update changelog
2022-12-23 10:50:57 +01:00
6137f228a8 Upgrade @types/lodash to version 4.14.191 (#1527) 2022-12-23 10:50:38 +01:00
5293de14cd Bugfix/fix rounding of y axis ticks in benchmark comparator (#1521)
* Fix rounding

* Update changelog
2022-12-22 12:27:34 +01:00
7340a674b5 Feature/upgrade color to version 4.2.3 (#1524)
* Upgrade color to version 4.2.3

* Update changelog
2022-12-21 19:40:29 +01:00
42cb3e2c73 Harmonize style of symbol (#1520) 2022-12-20 13:29:57 +01:00
e8a4a53c9f Feature/support position detail dialog in top performers of analysis page (#1522)
* Add support for position detail dialog

* Update changelog
2022-12-19 09:36:34 +01:00
629f002074 Release 1.219.0 (#1518) 2022-12-17 21:22:26 +01:00
7c65cf6ddd Update locales (#1517) 2022-12-17 21:20:51 +01:00
c38ebec3be Feature/add name to activities table (#1516)
* Add name to symbol column

* Update changelog
2022-12-17 21:14:58 +01:00
2b8ab26e7e Feature/combine name and symbol column in holdings table (#1514)
* Combine name and symbol column

* Update changelog
2022-12-17 20:50:39 +01:00
60f52bb209 Handle user signup for OAuth and Internet Identity (#1515)
Co-Authored-By: gobdevel <99349192+gobdevel@users.noreply.github.com>
2022-12-17 16:18:00 +01:00
616d168a7c Feature/add signup permission (#1512)
* Added support to disable user sign up in the admin control panel

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2022-12-17 12:39:21 +01:00
b13e4425d3 Feature/extend glossary by deflation inflation and stagflation (#1511)
* Extend glossary by deflation, inflation and stagflation

* Update changelog
2022-12-16 17:38:19 +01:00
1424236c48 Add "Buy me a coffee" badge (#1509) 2022-12-15 17:26:30 +01:00
2a605f850d Refactor requests and responses (#1507) 2022-12-14 08:03:38 +01:00
88ffbfead0 Add purpose (#1508) 2022-12-13 12:56:10 +01:00
5f4a8d505b Release 1.218.0 (#1510) 2022-12-12 20:15:32 +01:00
e87b93f19c Feature/add logo endpoint (#1506)
* Add logo endpoint

* Update changelog
2022-12-12 20:13:45 +01:00
49dcade964 Feature/add date of first activity to holdings (#1505)
* Add date of first activity

* Update changelog
2022-12-11 10:19:35 +01:00
7cd65eed39 Feature/improve asset profile details dialog (#1504)
* Improve asset profile details dialog

* Update changelog
2022-12-11 09:37:07 +01:00
a51b210f79 Feature/upgrade chart.js to version 4.0.1 (#1503)
* Upgrade chart.js to version 4.0.1 (including plugins)

* Migrate border attributes

* Update changelog
2022-12-10 16:47:09 +01:00
285f2220f3 Release 1.217.0 (#1502) 2022-12-10 14:32:28 +01:00
d72123246d Feature/upgrade cheerio to version 1.0.0 rc.12 (#1501)
* Upgrade cheerio to version 1.0.0-rc.12

* Update changelog
2022-12-10 12:19:45 +01:00
3a78d6c3f1 Feature/add dividend timeline (#1498)
* Add dividend timeline

* Update changelog

Co-Authored-By: João Pereira <joao@jpereira.me>
2022-12-10 11:54:49 +01:00
d5e3ff5717 Add locale (#1500) 2022-12-09 15:06:15 +01:00
2efb331370 Feature/improve value redaction interceptor (#1495)
* Improve value redaction interceptor

* Update changelog
2022-12-07 17:48:46 +01:00
f521fe99c5 Feature/upgrade prisma to version 4.7.1 (#1493)
* Upgrade prisma to version 4.7.1

* Update changelog
2022-12-06 09:03:28 +01:00
42306530b8 New strings translated to Spanish (#1496)
* Update messages.es.xlf

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2022-12-04 20:02:02 +01:00
68c9d1b266 Bugfix/fix activities sorting in account detail dialog (#1494)
* Fix activities sorting

* Update changelog
2022-12-04 18:54:41 +01:00
393 changed files with 30944 additions and 9026 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 }}

3
.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
@ -38,4 +39,4 @@ yarn-error.log
# System Files # System Files
.DS_Store .DS_Store
Thumbs.db Thumbs.db

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,549 @@ 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.252.0 - 2023-04-10
### Changed
- Changed the slide toggles to checkboxes on the account page
- Changed the slide toggles to checkboxes in the admin control panel
- Decreased the density of the theme
- Migrated the style of various components to `@angular/material` `15` (mdc)
- Upgraded `@angular/cdk` and `@angular/material` from version `15.2.5` to `15.2.6`
## 1.251.0 - 2023-04-07
### Changed
- Improved the activities import for `csv` files exported by _Interactive Brokers_
- Improved the rendering of the chart ticks (`0.5K``500`)
- Increased the historical market data gathering of currency pairs to 10+ years
- Improved the content of the Frequently Asked Questions (FAQ) page
- Improved the content of the pricing page
- Changed the `auth` endpoint of the login with _Security Token_ from `GET` to `POST`
- Changed the `auth` endpoint of the _Internet Identity_ login provider from `GET` to `POST`
- Migrated the style of the `libs` components to `@angular/material` `15` (mdc)
- `ActivitiesFilterComponent`
- `ActivitiesTableComponent`
- `BenchmarkComponent`
- `HoldingsTableComponent`
- Upgraded `angular` from version `15.1.5` to `15.2.5`
- Upgraded `nestjs` from version `9.1.4` to `9.4.0`
- Upgraded `Nx` from version `15.7.2` to `15.9.2`
## 1.250.0 - 2023-04-02
### Added
- Added support for multiple subscription offers
### Changed
- Improved the portfolio evolution chart (ignore first item)
- Improved the accounts import by handling the platform
### Fixed
- Fixed an issue with more than 50 activities in the activities import (`dryRun`)
## 1.249.0 - 2023-03-27
### Added
- Extended the testimonial section on the landing page
### Changed
- Improved the loading state of the value component on the allocations page
- Improved the value component by always showing the label (also while loading)
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue with the algebraic sign in the value component
## 1.248.0 - 2023-03-25
### Added
- Added a blog post: _Ghostfolio reaches 1000 Stars on GitHub_
- Added a breadcrumb navigation to the blog post pages
### Changed
- Refactored the calculation of the chart
- Hid the platform selector if no platforms are available in the create or update account dialog
- Upgraded `ng-extract-i18n-merge` from version `2.5.0` to `2.6.0`
## 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 support 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
### Added
- Extended the hints in user settings
### Changed
- Improved the date formatting in the tooltip of the dividend timeline grouped by month / year
- Improved the date formatting in the tooltip of the investment timeline grouped by month / year
- Reduced the execution interval of the data gathering to every 4 hours
- Removed emergency fund as an asset class
## 1.227.1 - 2023-01-14
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed the create or edit activity dialog
## 1.227.0 - 2023-01-14
### Added
- Added support for assets other than cash in emergency fund (affecting buying power)
- Added support for translated tags
### Changed
- Improved the logo alignment
### Fixed
- Fixed the grouping by month / year of the dividend and investment timeline
## 1.226.0 - 2023-01-11
### Added
- Added the language localization for Français (`fr`)
- Extended the landing page by a global heat map of subscribers
- Added support for the thousand separator in the global heat map component
### Changed
- Improved the form of the import dividends dialog (disable while loading)
- Removed the deprecated `~` in _Sass_ imports
### Fixed
- Fixed an exception in the _X-ray_ section
## 1.225.0 - 2023-01-07
### Added
- Added support for importing dividends from a data provider
### Changed
- Extended the Frequently Asked Questions (FAQ) page
## 1.224.0 - 2023-01-04
### Added
- Added support for the dividend timeline grouped by year
- Added support for the investment timeline grouped by year
- Set up the language localization for Français (`fr`)
- Set up the language localization for Português (`pt`)
### Changed
- Improved the language localization for Dutch (`nl`)
## 1.223.0 - 2023-01-01
### Added
- Added a student discount to the pricing page
- Added a prefix to the codes of the coupon system
### Changed
- Optimized the page titles in the header for mobile
- Extended the asset profile details dialog in the admin control panel
## 1.222.0 - 2022-12-29
### Added
- Added support for filtering on the analysis page
- Added the price to the `Subscription` database schema
### Changed
- Changed the execution time of the asset profile data gathering to every Sunday at lunch time
- Improved the activities import by providing asset profile details
- Upgraded `@codewithdan/observable-store` from version `2.2.11` to `2.2.15`
- Upgraded `bull` from version `4.8.5` to `4.10.2`
- Upgraded `countup.js` from version `2.0.7` to `2.3.2`
- Upgraded the _Internet Identity_ dependencies from version `0.12.1` to `0.15.1`
- Upgraded `prisma` from version `4.7.1` to `4.8.0`
### Fixed
- Fixed the language localization of the account type
## 1.221.0 - 2022-12-26
### Added
- Added support to manage the tags in the create or edit activity dialog
- Added the tags to the admin control panel
- Added a blog post: _The importance of tracking your personal finances_
- Resolved the title of the blog post
### Changed
- Improved the activities import by a preview step
- Improved the labels based on the type in the create or edit activity dialog
- Refreshed the cryptocurrencies list
- Removed the data source type `RAKUTEN`
### Fixed
- Fixed the date conversion for years with only two digits
## 1.220.0 - 2022-12-23
### Added
- Added the position detail dialog to the _Top 3_ and _Bottom 3_ performers of the analysis page
- Added the `dryRun` option to the import activities endpoint
### Changed
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 365 days
- Upgraded `color` from version `4.0.1` to `4.2.3`
- Upgraded `prettier` from version `2.7.1` to `2.8.1`
### Fixed
- Fixed the rounding of the y-axis ticks in the benchmark comparator
## 1.219.0 - 2022-12-17
### Added
- Added support to disable user sign up in the admin control panel
- Extended the glossary of the resources page by _Deflation_, _Inflation_ and _Stagflation_
### Changed
- Added the name to the symbol column in the activities table
- Combined the name and symbol column in the holdings table (former positions table)
## 1.218.0 - 2022-12-12
### Added
- Added the date of the first activity to the positions table
- Added an endpoint to fetch the logo of an asset or a platform
### Changed
- Improved the asset profile details dialog in the admin control panel
- Upgraded `chart.js` from version `3.8.0` to `4.0.1`
## 1.217.0 - 2022-12-10
### Added
- Added the dividend timeline grouped by month
### Changed
- Improved the value redaction interceptor (including `comment`)
- Improved the language localization for Español (`es`)
- Upgraded `cheerio` from version `1.0.0-rc.6` to `1.0.0-rc.12`
- Upgraded `prisma` from version `4.6.1` to `4.7.1`
### Fixed
- Fixed the activities sorting in the account detail dialog
## 1.216.0 - 2022-12-03 ## 1.216.0 - 2022-12-03
### Added ### Added
@ -415,7 +958,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
@ -1028,7 +1571,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
@ -1453,7 +1996,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" ]

128
README.md
View File

@ -1,32 +1,26 @@
<div align="center"> <div align="center">
<a href="https://ghostfol.io">
<img
alt="Ghostfolio Logo"
src="https://avatars.githubusercontent.com/u/82473144?s=200"
width="100"
/>
</a>
<h1>Ghostfolio</h1> [<img src="https://avatars.githubusercontent.com/u/82473144?s=200" width="100" alt="Ghostfolio logo">](https://ghostfol.io)
<p>
<strong>Open Source Wealth Management Software</strong> # Ghostfolio
</p>
<p> **Open Source Wealth Management Software**
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/en/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/en/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/en/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p> [**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
<p> [**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
<a href="#contributing">
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a> [![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio)
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow"> [![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing)
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a> [![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
</p>
</div> </div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. **Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
<div align="center">
[<img src="./apps/client/src/assets/images/video-preview.jpg" width="600" alt="Preview image of the Ghostfolio video trailer">](https://www.youtube.com/watch?v=yY6ObSQVJZk)
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
</div> </div>
## Ghostfolio Premium ## Ghostfolio Premium
@ -46,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 2022 - 🙅 saying no to spreadsheets
- 😎 still reading this list - 😎 still reading this list
## Features ## Features
@ -61,8 +55,10 @@ Ghostfolio is for you if you are...
- ✅ Zen Mode - ✅ Zen Mode
- ✅ Progressive Web App (PWA) with a mobile-first design - ✅ Progressive Web App (PWA) with a mobile-first design
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;"> <div align="center">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
<img src="./apps/client/src/assets/images/screenshot.png" width="300" alt="Image of a phone showing the Ghostfolio app open">
</div> </div>
## Technology Stack ## Technology Stack
@ -79,34 +75,29 @@ 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">
<a href="https://www.buymeacoffee.com/ghostfolio">
<img [<img src="./apps/client/src/assets/images/button-buy-me-a-coffee.png" width="150" alt="Buy me a coffee button"/>](https://www.buymeacoffee.com/ghostfolio)
alt="Buy me a coffee button"
src="./apps/client/src/assets/images/button-buy-me-a-coffee.png"
width="150"
/>
</a>
</div> </div>
### 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
@ -114,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
@ -133,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
@ -156,31 +145,34 @@ 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
<ol type="a"> #### Debug
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
<li>Serve: Run <code>yarn start:server</code></li> Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
</ol>
#### Serve
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_
@ -274,12 +266,12 @@ 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).
## License ## License
© 2022 [Ghostfolio](https://ghostfol.io) © 2023 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html). Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).

View File

@ -2,13 +2,14 @@
export default { export default {
displayName: 'api', displayName: 'api',
globals: { globals: {},
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json'
}
},
transform: { transform: {
'^.+\\.[tj]s$': 'ts-jest' '^.+\\.[tj]s$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.spec.json'
}
]
}, },
moduleFileExtensions: ['ts', 'js', 'html'], moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api', coverageDirectory: '../../coverage/apps/api',

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

@ -100,6 +100,7 @@ export class AdminService {
dataSource, dataSource,
marketDataItemCount, marketDataItemCount,
symbol, symbol,
assetClass: 'CASH',
countriesCount: 0, countriesCount: 0,
sectorsCount: 0 sectorsCount: 0
}; };
@ -186,8 +187,11 @@ export class AdminService {
]); ]);
return { return {
assetProfile, marketData,
marketData assetProfile: assetProfile ?? {
symbol,
currency: '-'
}
}; };
} }
@ -231,12 +235,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 +263,7 @@ export class AdminService {
Analytics: { Analytics: {
select: { select: {
activityCount: true, activityCount: true,
country: true,
updatedAt: true updatedAt: true
} }
}, },
@ -251,19 +271,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 +294,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';
@ -27,8 +26,10 @@ import { ExportModule } from './export/export.module';
import { FrontendMiddleware } from './frontend.middleware'; import { FrontendMiddleware } from './frontend.middleware';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.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';
@ -44,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
} }
}), }),
@ -58,6 +59,7 @@ import { UserModule } from './user/user.module';
ExportModule, ExportModule,
ImportModule, ImportModule,
InfoModule, InfoModule,
LogoModule,
OrderModule, OrderModule,
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,

View File

@ -7,7 +7,6 @@ import {
Controller, Controller,
Get, Get,
HttpException, HttpException,
Param,
Post, Post,
Req, Req,
Res, Res,
@ -16,6 +15,7 @@ import {
Version Version
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@ -32,13 +32,13 @@ export class AuthController {
private readonly webAuthService: WebAuthService private readonly webAuthService: WebAuthService
) {} ) {}
@Get('anonymous/:accessToken') @Post('anonymous')
public async accessTokenLogin( public async accessTokenLogin(
@Param('accessToken') accessToken: string @Body() body: { accessToken: string }
): Promise<OAuthResponse> { ): Promise<OAuthResponse> {
try { try {
const authToken = await this.authService.validateAnonymousLogin( const authToken = await this.authService.validateAnonymousLogin(
accessToken body.accessToken
); );
return { authToken }; return { authToken };
} catch { } catch {
@ -58,18 +58,21 @@ export class AuthController {
@Get('google/callback') @Get('google/callback')
@UseGuards(AuthGuard('google')) @UseGuards(AuthGuard('google'))
@Version(VERSION_NEUTRAL) @Version(VERSION_NEUTRAL)
public googleLoginCallback(@Req() req, @Res() res) { public googleLoginCallback(
@Req() request: Request,
@Res() response: Response
) {
// Handles the Google OAuth2 callback // Handles the Google OAuth2 callback
const jwt: string = req.user.jwt; const jwt: string = (<any>request.user).jwt;
if (jwt) { if (jwt) {
res.redirect( response.redirect(
`${this.configurationService.get( `${this.configurationService.get(
'ROOT_URL' 'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}` )}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
); );
} else { } else {
res.redirect( response.redirect(
`${this.configurationService.get( `${this.configurationService.get(
'ROOT_URL' 'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth` )}/${DEFAULT_LANGUAGE_CODE}/auth`
@ -77,13 +80,13 @@ export class AuthController {
} }
} }
@Get('internet-identity/:principalId') @Post('internet-identity')
public async internetIdentityLogin( public async internetIdentityLogin(
@Param('principalId') principalId: string @Body() body: { principalId: string }
): Promise<OAuthResponse> { ): Promise<OAuthResponse> {
try { try {
const authToken = await this.authService.validateInternetIdentityLogin( const authToken = await this.authService.validateInternetIdentityLogin(
principalId body.principalId
); );
return { authToken }; return { authToken };
} catch { } catch {

View File

@ -4,6 +4,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -21,6 +22,7 @@ import { JwtStrategy } from './jwt.strategy';
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
}), }),
PrismaModule, PrismaModule,
PropertyModule,
SubscriptionModule, SubscriptionModule,
UserModule UserModule
], ],

View File

@ -1,5 +1,6 @@
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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
@ -11,6 +12,7 @@ export class AuthService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly propertyService: PropertyService,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -50,10 +52,19 @@ export class AuthService {
}); });
if (!user) { if (!user) {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled) {
throw new Error('Sign up forbidden');
}
// 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
}
}); });
} }
@ -78,10 +89,19 @@ export class AuthService {
}); });
if (!user) { if (!user) {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled) {
throw new Error('Sign up forbidden');
}
// 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

@ -30,8 +30,8 @@ export class BenchmarkController {
} }
@Get(':dataSource/:symbol/:startDateString') @Get(':dataSource/:symbol/:startDateString')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol( public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,

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,10 +1,12 @@
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 { Injectable, NestMiddleware } from '@nestjs/common'; import { Injectable, NestMiddleware } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
@Injectable() @Injectable()
@ -12,20 +14,14 @@ export class FrontendMiddleware implements NestMiddleware {
public indexHtmlDe = ''; public indexHtmlDe = '';
public indexHtmlEn = ''; public indexHtmlEn = '';
public indexHtmlEs = ''; public indexHtmlEs = '';
public indexHtmlFr = '';
public indexHtmlIt = ''; public indexHtmlIt = '';
public indexHtmlNl = ''; public indexHtmlNl = '';
public isProduction: boolean; public indexHtmlPt = '';
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'),
@ -39,6 +35,10 @@ export class FrontendMiddleware implements NestMiddleware {
this.getPathOfIndexHtmlFile('es'), this.getPathOfIndexHtmlFile('es'),
'utf8' 'utf8'
); );
this.indexHtmlFr = fs.readFileSync(
this.getPathOfIndexHtmlFile('fr'),
'utf8'
);
this.indexHtmlIt = fs.readFileSync( this.indexHtmlIt = fs.readFileSync(
this.getPathOfIndexHtmlFile('it'), this.getPathOfIndexHtmlFile('it'),
'utf8' 'utf8'
@ -47,69 +47,132 @@ export class FrontendMiddleware implements NestMiddleware {
this.getPathOfIndexHtmlFile('nl'), this.getPathOfIndexHtmlFile('nl'),
'utf8' 'utf8'
); );
this.indexHtmlPt = fs.readFileSync(
this.getPathOfIndexHtmlFile('pt'),
'utf8'
);
} catch {} } catch {}
} }
public use(req: Request, res: Response, next: NextFunction) { public use(request: Request, response: Response, next: NextFunction) {
const currentDate = format(new Date(), DATE_FORMAT);
let featureGraphicPath = 'assets/cover.png'; let featureGraphicPath = 'assets/cover.png';
let title = 'Ghostfolio Open Source Wealth Management Software';
if (req.path.startsWith('/en/blog/2022/08/500-stars-on-github')) { if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg'; featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
} else if (req.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) { title = `500 Stars - ${title}`;
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png'; featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
} else if (req.path.startsWith('/en/blog/2022/11/black-friday-2022')) { title = `Hacktoberfest 2022 - ${title}`;
} else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg'; featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
title = `Black Friday 2022 - ${title}`;
} else if (
request.path.startsWith(
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances'
)
) {
featureGraphicPath = 'assets/images/blog/20221226.jpg';
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}`;
} else if (
request.path.startsWith(
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github'
)
) {
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
title = `Ghostfolio reaches 1000 Stars on GitHub - ${title}`;
} }
if ( if (
req.path.startsWith('/api/') || request.path.startsWith('/api/') ||
this.isFileRequest(req.url) || this.isFileRequest(request.url) ||
!this.isProduction !environment.production
) { ) {
// Skip // Skip
next(); next();
} else if (req.path === '/de' || req.path.startsWith('/de/')) { } else if (request.path === '/de' || request.path.startsWith('/de/')) {
res.send( response.send(
this.interpolate(this.indexHtmlDe, { this.interpolate(this.indexHtmlDe, {
currentDate,
featureGraphicPath, featureGraphicPath,
title,
languageCode: 'de', languageCode: 'de',
path: req.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
}) })
); );
} else if (req.path === '/es' || req.path.startsWith('/es/')) { } else if (request.path === '/es' || request.path.startsWith('/es/')) {
res.send( response.send(
this.interpolate(this.indexHtmlEs, { this.interpolate(this.indexHtmlEs, {
currentDate,
featureGraphicPath, featureGraphicPath,
title,
languageCode: 'es', languageCode: 'es',
path: req.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
}) })
); );
} else if (req.path === '/it' || req.path.startsWith('/it/')) { } else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
res.send( response.send(
this.interpolate(this.indexHtmlFr, {
featureGraphicPath,
languageCode: 'fr',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
response.send(
this.interpolate(this.indexHtmlIt, { this.interpolate(this.indexHtmlIt, {
currentDate,
featureGraphicPath, featureGraphicPath,
title,
languageCode: 'it', languageCode: 'it',
path: req.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
}) })
); );
} else if (req.path === '/nl' || req.path.startsWith('/nl/')) { } else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
res.send( response.send(
this.interpolate(this.indexHtmlNl, { this.interpolate(this.indexHtmlNl, {
currentDate,
featureGraphicPath, featureGraphicPath,
title,
languageCode: 'nl', languageCode: 'nl',
path: req.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
response.send(
this.interpolate(this.indexHtmlPt, {
featureGraphicPath,
languageCode: 'pt',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
}) })
); );
} else { } else {
res.send( response.send(
this.interpolate(this.indexHtmlEn, { this.interpolate(this.indexHtmlEn, {
currentDate,
featureGraphicPath, featureGraphicPath,
title,
languageCode: DEFAULT_LANGUAGE_CODE, languageCode: DEFAULT_LANGUAGE_CODE,
path: req.path, path: request.path,
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
}) })
); );

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

@ -1,16 +1,25 @@
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 { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
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,
Controller, Controller,
Get,
HttpException, HttpException,
Inject, Inject,
Logger, Logger,
Param,
Post, Post,
UseGuards Query,
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';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ImportDataDto } from './import-data.dto'; import { ImportDataDto } from './import-data.dto';
@ -26,8 +35,17 @@ export class ImportController {
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async import(@Body() importData: ImportDataDto): Promise<void> { public async import(
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) { @Body() importData: ImportDataDto,
@Query('dryRun') isDryRun?: boolean
): Promise<ImportResponse> {
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
@ -45,12 +63,19 @@ export class ImportController {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER; maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
} }
const userCurrency = this.request.user.Settings.settings.baseCurrency;
try { try {
return await this.importService.import({ const activities = await this.importService.import({
isDryRun,
maxActivitiesToImport, maxActivitiesToImport,
activities: importData.activities, userCurrency,
accountsDto: importData.accounts ?? [],
activitiesDto: importData.activities,
userId: this.request.user.id userId: this.request.user.id
}); });
return { activities };
} catch (error) { } catch (error) {
Logger.error(error, ImportController); Logger.error(error, ImportController);
@ -63,4 +88,23 @@ export class ImportController {
); );
} }
} }
@Get('dividends/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async gatherDividends(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<ImportResponse> {
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const activities = await this.importService.getDividends({
dataSource,
symbol,
userCurrency
});
return { activities };
}
} }

View File

@ -1,15 +1,19 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module'; import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ImportController } from './import.controller'; import { ImportController } from './import.controller';
import { ImportService } from './import.service'; import { ImportService } from './import.service';
import { PlatformModule } from '@ghostfolio/api/services/platform/platform.module';
@Module({ @Module({
controllers: [ImportController], controllers: [ImportController],
@ -19,9 +23,13 @@ import { ImportService } from './import.service';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule,
OrderModule, OrderModule,
PlatformModule,
PortfolioModule,
PrismaModule, PrismaModule,
RedisCacheModule RedisCacheModule,
SymbolProfileModule
], ],
providers: [ImportService] providers: [ImportService]
}) })

View File

@ -1,30 +1,186 @@
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 { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PlatformService } from '@ghostfolio/api/services/platform/platform.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { parseDate } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import {
AccountWithPlatform,
OrderWithAccount
} from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { isSameDay, parseISO } from 'date-fns'; import { Prisma, SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
import { v4 as uuidv4 } from 'uuid';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly orderService: OrderService private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService,
private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async getDividends({
dataSource,
symbol,
userCurrency
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
try {
const { firstBuyDate, historicalData, orders } =
await this.portfolioService.getPosition(dataSource, undefined, symbol);
const [[assetProfile], dividends] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]),
await this.dataProviderService.getDividends({
dataSource,
symbol,
from: parseDate(firstBuyDate),
granularity: 'day',
to: new Date()
})
]);
const accounts = orders.map((order) => {
return order.Account;
});
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
return Object.entries(dividends).map(([dateString, { marketPrice }]) => {
const quantity =
historicalData.find((historicalDataItem) => {
return historicalDataItem.date === dateString;
})?.quantity ?? 0;
const value = new Big(quantity).mul(marketPrice).toNumber();
return {
Account,
quantity,
value,
accountId: Account?.id,
accountUserId: undefined,
comment: undefined,
createdAt: undefined,
date: parseDate(dateString),
fee: 0,
feeInBaseCurrency: 0,
id: assetProfile.id,
isDraft: false,
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
symbolProfileId: assetProfile.id,
type: 'DIVIDEND',
unitPrice: marketPrice,
updatedAt: undefined,
userId: Account?.userId,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
assetProfile.currency,
userCurrency
)
};
});
} catch {
return [];
}
}
public async import({ public async import({
activities, accountsDto,
activitiesDto,
isDryRun = false,
maxActivitiesToImport, maxActivitiesToImport,
userCurrency,
userId userId
}: { }: {
activities: Partial<CreateOrderDto>[]; accountsDto: Partial<CreateAccountDto>[];
activitiesDto: Partial<CreateOrderDto>[];
isDryRun?: boolean;
maxActivitiesToImport: number; maxActivitiesToImport: number;
userCurrency: string;
userId: string; userId: string;
}): Promise<void> { }): Promise<Activity[]> {
for (const activity of activities) { const accountIdMapping: { [oldAccountId: string]: string } = {};
if (!isDryRun && accountsDto?.length) {
const [existingAccounts, existingPlatforms] = await Promise.all([
this.accountService.accounts({
where: {
id: {
in: accountsDto.map(({ id }) => {
return id;
})
}
}
}),
this.platformService.get()
]);
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;
}
let accountObject: Prisma.AccountCreateInput = {
...account,
User: { connect: { id: userId } }
};
if (
existingPlatforms.some(({ id }) => {
return id === platformId;
})
) {
accountObject = {
...accountObject,
Platform: { connect: { id: platformId } }
};
}
const newAccount = await this.accountService.createAccount(
accountObject,
userId
);
// Store the new to old account ID mappings for updating activities
if (accountWithSameId && oldAccountId) {
accountIdMapping[oldAccountId] = newAccount.id;
}
}
}
}
for (const activity of activitiesDto) {
if (!activity.dataSource) { if (!activity.dataSource) {
if (activity.type === 'ITEM') { if (activity.type === 'ITEM') {
activity.dataSource = 'MANUAL'; activity.dataSource = 'MANUAL';
@ -32,74 +188,172 @@ 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];
}
}
} }
await this.validateActivities({ const assetProfiles = await this.validateActivities({
activities, activitiesDto,
maxActivitiesToImport, maxActivitiesToImport,
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[] = [];
for (const { for (const {
accountId, accountId,
comment, comment,
currency, currency,
dataSource, dataSource,
date, date: dateString,
fee, fee,
quantity, quantity,
symbol, symbol,
type, type,
unitPrice unitPrice
} of activities) { } of activitiesDto) {
await this.orderService.createOrder({ const date = parseISO(<string>(<unknown>dateString));
comment, const validatedAccount = accounts.find(({ id }) => {
fee, return id === accountId;
quantity, });
type,
unitPrice, let order:
userId, | OrderWithAccount
accountId: accountIds.includes(accountId) ? accountId : undefined, | (Omit<OrderWithAccount, 'Account'> & {
date: parseISO(<string>(<unknown>date)), Account?: { id: string; name: string };
SymbolProfile: { });
connectOrCreate: {
create: { if (isDryRun) {
currency, order = {
dataSource, comment,
symbol date,
}, fee,
where: { quantity,
dataSource_symbol: { type,
unitPrice,
userId,
accountId: validatedAccount?.id,
accountUserId: undefined,
createdAt: new Date(),
id: uuidv4(),
isDraft: isAfter(date, endOfToday()),
SymbolProfile: {
currency,
dataSource,
symbol,
assetClass: null,
assetSubClass: null,
comment: null,
countries: null,
createdAt: undefined,
id: undefined,
isin: null,
name: null,
scraperConfiguration: null,
sectors: null,
symbolMapping: null,
updatedAt: undefined,
url: null,
...assetProfiles[symbol]
},
Account: validatedAccount,
symbolProfileId: undefined,
updatedAt: new Date()
};
} else {
order = await this.orderService.createOrder({
comment,
date,
fee,
quantity,
type,
unitPrice,
userId,
accountId: validatedAccount?.id,
SymbolProfile: {
connectOrCreate: {
create: {
currency,
dataSource, dataSource,
symbol symbol
},
where: {
dataSource_symbol: {
dataSource,
symbol
}
} }
} }
} },
}, User: { connect: { id: userId } }
User: { connect: { id: userId } } });
}
const value = new Big(quantity).mul(unitPrice).toNumber();
//@ts-ignore
activities.push({
...order,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee,
currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
currency,
userCurrency
)
}); });
} }
return activities;
}
private isUniqueAccount(accounts: AccountWithPlatform[]) {
const uniqueAccountIds = new Set<string>();
for (const account of accounts) {
uniqueAccountIds.add(account.id);
}
return uniqueAccountIds.size === 1;
} }
private async validateActivities({ private async validateActivities({
activities, activitiesDto,
maxActivitiesToImport, maxActivitiesToImport,
userId userId
}: { }: {
activities: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
maxActivitiesToImport: number; maxActivitiesToImport: number;
userId: string; userId: string;
}) { }) {
if (activities?.length > maxActivitiesToImport) { if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
} }
const assetProfiles: {
[symbol: string]: Partial<SymbolProfile>;
} = {};
const existingActivities = await this.orderService.orders({ const existingActivities = await this.orderService.orders({
include: { SymbolProfile: true }, include: { SymbolProfile: true },
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
@ -109,7 +363,7 @@ export class ImportService {
for (const [ for (const [
index, index,
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice } { currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
] of activities.entries()) { ] of activitiesDto.entries()) {
const duplicateActivity = existingActivities.find((activity) => { const duplicateActivity = existingActivities.find((activity) => {
return ( return (
activity.SymbolProfile.currency === currency && activity.SymbolProfile.currency === currency &&
@ -128,22 +382,28 @@ export class ImportService {
} }
if (dataSource !== 'MANUAL') { if (dataSource !== 'MANUAL') {
const quotes = await this.dataProviderService.getQuotes([ const assetProfile = (
{ dataSource, symbol } await this.dataProviderService.getAssetProfiles([
]); { dataSource, symbol }
])
)?.[symbol];
if (quotes[symbol] === undefined) { if (assetProfile === undefined) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
} }
if (quotes[symbol].currency !== currency) { if (assetProfile.currency !== currency) {
throw new Error( throw new Error(
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"` `activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"`
); );
} }
assetProfiles[symbol] = assetProfile;
} }
} }
return assetProfiles;
} }
} }

View File

@ -6,7 +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_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,
@ -21,6 +22,7 @@ import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface'; import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface'; import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOffer } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bent from 'bent'; import * as bent from 'bent';
@ -58,9 +60,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
); );
@ -71,10 +71,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
@ -92,6 +88,10 @@ export class InfoService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
globalPermissions.push(permissions.enableSubscription); globalPermissions.push(permissions.enableSubscription);
info.countriesOfSubscribers =
((await this.propertyService.getByKey(
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
)) as string[]) ?? [];
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY'); info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
} }
@ -103,6 +103,13 @@ export class InfoService {
)) as string; )) as string;
} }
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (isUserSignupEnabled) {
globalPermissions.push(permissions.createUserAccount);
}
return { return {
...info, ...info,
globalPermissions, globalPermissions,
@ -112,7 +119,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()
@ -240,10 +247,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() {
@ -290,19 +305,17 @@ export class InfoService {
return statistics; return statistics;
} }
private async getSubscriptions(): Promise<Subscription[]> { private async getSubscriptions(): Promise<{
[offer in SubscriptionOffer]: Subscription;
}> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined; return undefined;
} }
const stripeConfig = await this.prismaService.property.findUnique({ const stripeConfig = (await this.prismaService.property.findUnique({
where: { key: PROPERTY_STRIPE_CONFIG } where: { key: PROPERTY_STRIPE_CONFIG }
}); })) ?? { value: '{}' };
if (stripeConfig) { return JSON.parse(stripeConfig.value);
return [JSON.parse(stripeConfig.value)];
}
return [];
} }
} }

View File

@ -0,0 +1,54 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import {
Controller,
Get,
HttpStatus,
Param,
Query,
Res,
UseInterceptors
} from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { Response } from 'express';
import { LogoService } from './logo.service';
@Controller('logo')
export class LogoController {
public constructor(private readonly logoService: LogoService) {}
@Get(':dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getLogoByDataSourceAndSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string,
@Res() response: Response
) {
try {
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({
dataSource,
symbol
});
response.contentType('image/png');
response.send(buffer);
} catch {
response.status(HttpStatus.NOT_FOUND).send();
}
}
@Get()
public async getLogoByUrl(
@Query('url') url: string,
@Res() response: Response
) {
try {
const buffer = await this.logoService.getLogoByUrl(url);
response.contentType('image/png');
response.send(buffer);
} catch {
response.status(HttpStatus.NOT_FOUND).send();
}
}
}

View File

@ -0,0 +1,13 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { LogoController } from './logo.controller';
import { LogoService } from './logo.service';
@Module({
controllers: [LogoController],
imports: [ConfigurationModule, SymbolProfileModule],
providers: [LogoService]
})
export class LogoModule {}

View File

@ -0,0 +1,55 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
export class LogoService {
public constructor(
private readonly symbolProfileService: SymbolProfileService
) {}
public async getLogoByDataSourceAndSymbol({
dataSource,
symbol
}: UniqueAsset) {
if (!DataSource[dataSource]) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (!assetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return this.getBuffer(assetProfile.url);
}
public async getLogoByUrl(aUrl: string) {
return this.getBuffer(aUrl);
}
private getBuffer(aUrl: string) {
const get = bent(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
'GET',
'buffer',
200,
{
'User-Agent': 'request'
}
);
return get();
}
}

View File

@ -6,5 +6,6 @@ export interface Activities {
export interface Activity extends OrderWithAccount { export interface Activity extends OrderWithAccount {
feeInBaseCurrency: number; feeInBaseCurrency: number;
value: number;
valueInBaseCurrency: number; valueInBaseCurrency: number;
} }

View File

@ -1,10 +1,9 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
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 { 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 {
@ -39,8 +38,7 @@ export class OrderController {
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser
private readonly userService: UserService
) {} ) {}
@Delete(':id') @Delete(':id')
@ -69,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
@ -87,7 +85,7 @@ export class OrderController {
); );
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
let activities = await this.orderService.getOrders({ const activities = await this.orderService.getOrders({
filters, filters,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
@ -95,20 +93,6 @@ export class OrderController {
withExcludedAccounts: true withExcludedAccounts: true
}); });
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
activities = nullifyValuesInObjects(activities, [
'fee',
'feeInBaseCurrency',
'quantity',
'unitPrice',
'value',
'valueInBaseCurrency'
]);
}
return { activities }; return { activities };
} }

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(
@ -362,6 +356,12 @@ export class OrderService {
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
// Remove existing tags
await this.prismaService.order.update({
data: { tags: { set: [] } },
where
});
return this.prismaService.order.update({ return this.prismaService.order.update({
data: { data: {
...data, ...data,

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

@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore(); spy.mockRestore();
@ -81,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

@ -53,7 +53,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore(); spy.mockRestore();
@ -70,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

@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore(); spy.mockRestore();
@ -81,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

@ -41,7 +41,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore(); spy.mockRestore();

View File

@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore(); spy.mockRestore();
@ -81,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

@ -68,7 +68,8 @@ describe('PortfolioCalculator', () => {
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth(); const investmentsByMonth =
portfolioCalculator.getInvestmentsByGroup('month');
spy.mockRestore(); spy.mockRestore();
@ -85,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({
@ -101,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,12 @@
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 { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client'; import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
@ -44,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[];
@ -176,10 +182,10 @@ export class PortfolioCalculator {
return isBefore(parseDate(transactionPoint.date), end); return isBefore(parseDate(transactionPoint.date), end);
}) ?? []; }) ?? [];
const firstIndex = transactionPointsBeforeEndDate.length; const currencies: { [symbol: string]: string } = {};
const dates: Date[] = []; const dates: Date[] = [];
const dataGatheringItems: IDataGatheringItem[] = []; const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {}; const firstIndex = transactionPointsBeforeEndDate.length;
let day = start; let day = start;
@ -201,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 };
@ -226,87 +235,100 @@ export class PortfolioCalculator {
} }
} }
const netPerformanceValuesBySymbol: { const valuesByDate: {
[symbol: string]: { [date: string]: Big }; [date: string]: {
maxTotalInvestmentValue: Big;
totalCurrentValue: Big;
totalInvestmentValue: Big;
totalNetPerformanceValue: Big;
};
} = {}; } = {};
const investmentValuesBySymbol: { const valuesBySymbol: {
[symbol: string]: { [date: string]: Big }; [symbol: string]: {
currentValues: { [date: string]: Big };
investmentValues: { [date: string]: Big };
maxInvestmentValues: { [date: string]: Big };
netPerformanceValues: { [date: string]: Big };
};
} = {}; } = {};
const maxInvestmentValuesBySymbol: {
[symbol: string]: { [date: string]: Big };
} = {};
const totalNetPerformanceValues: { [date: string]: Big } = {};
const totalInvestmentValues: { [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
});
netPerformanceValuesBySymbol[symbol] = netPerformanceValues; valuesBySymbol[symbol] = {
investmentValuesBySymbol[symbol] = investmentValues; currentValues,
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues; investmentValues,
maxInvestmentValues,
netPerformanceValues
};
} }
for (const currentDate of dates) { for (const currentDate of dates) {
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(valuesBySymbol)) {
totalNetPerformanceValues[dateString] = const symbolValues = valuesBySymbol[symbol];
totalNetPerformanceValues[dateString] ?? new Big(0);
if (netPerformanceValuesBySymbol[symbol]?.[dateString]) { const currentValue =
totalNetPerformanceValues[dateString] = totalNetPerformanceValues[ symbolValues.currentValues?.[dateString] ?? new Big(0);
dateString const investmentValue =
].add(netPerformanceValuesBySymbol[symbol][dateString]); symbolValues.investmentValues?.[dateString] ?? new Big(0);
} const maxInvestmentValue =
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
const netPerformanceValue =
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
totalInvestmentValues[dateString] = valuesByDate[dateString] = {
totalInvestmentValues[dateString] ?? new Big(0); totalCurrentValue: (
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
maxTotalInvestmentValues[dateString] = ).add(currentValue),
maxTotalInvestmentValues[dateString] ?? new Big(0); totalInvestmentValue: (
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
if (investmentValuesBySymbol[symbol]?.[dateString]) { ).add(investmentValue),
totalInvestmentValues[dateString] = totalInvestmentValues[ maxTotalInvestmentValue: (
dateString valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
].add(investmentValuesBySymbol[symbol][dateString]); ).add(maxInvestmentValue),
} totalNetPerformanceValue: (
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) { ).add(netPerformanceValue)
maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[ };
dateString
].add(maxInvestmentValuesBySymbol[symbol][dateString]);
}
} }
} }
return Object.keys(totalNetPerformanceValues).map((date) => { return Object.entries(valuesByDate).map(([date, values]) => {
const netPerformanceInPercentage = maxTotalInvestmentValues[date].eq(0) const {
maxTotalInvestmentValue,
totalCurrentValue,
totalInvestmentValue,
totalNetPerformanceValue
} = values;
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
? 0 ? 0
: totalNetPerformanceValues[date] : totalNetPerformanceValue
.div(maxTotalInvestmentValues[date]) .div(maxTotalInvestmentValue)
.mul(100) .mul(100)
.toNumber(); .toNumber();
return { return {
date, date,
netPerformanceInPercentage, netPerformanceInPercentage,
netPerformance: totalNetPerformanceValues[date].toNumber(), netPerformance: totalNetPerformanceValue.toNumber(),
totalInvestment: totalInvestmentValues[date].toNumber(), totalInvestment: totalInvestmentValue.toNumber(),
value: totalInvestmentValues[date] value: totalCurrentValue.toNumber()
.plus(totalNetPerformanceValues[date])
.toNumber()
}; };
}); });
} }
@ -367,14 +389,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 };
@ -430,6 +455,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
@ -446,7 +472,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 });
} }
} }
@ -461,6 +487,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 [];
@ -478,46 +508,60 @@ export class PortfolioCalculator {
}); });
} }
public getInvestmentsByMonth(): { date: string; investment: Big }[] { public getInvestmentsByGroup(
groupBy: GroupBy
): { date: string; investment: Big }[] {
if (this.orders.length === 0) { if (this.orders.length === 0) {
return []; return [];
} }
const investments = []; const investments = [];
let currentDate: Date; let currentDate: Date;
let investmentByMonth = new Big(0); let investmentByGroup = new Big(0);
for (const [index, order] of this.orders.entries()) { for (const [index, order] of this.orders.entries()) {
if ( if (
isSameMonth(parseDate(order.date), currentDate) && isSameYear(parseDate(order.date), currentDate) &&
isSameYear(parseDate(order.date), currentDate) (groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
) { ) {
// Same month: Add up investments // Same group: Add up investments
investmentByMonth = investmentByMonth.plus( investmentByGroup = investmentByGroup.plus(
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
); );
} else { } else {
// New month: Store previous month and reset // New group: Store previous group and reset
if (currentDate) { if (currentDate) {
investments.push({ investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT), date: format(
investment: investmentByMonth set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup
}); });
} }
currentDate = parseDate(order.date); currentDate = parseDate(order.date);
investmentByMonth = order.quantity investmentByGroup = order.quantity
.mul(order.unitPrice) .mul(order.unitPrice)
.mul(this.getFactor(order.type)); .mul(this.getFactor(order.type));
} }
if (index === this.orders.length - 1) { if (index === this.orders.length - 1) {
// Store current month (latest order) // Store current group (latest order)
investments.push({ investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT), date: format(
investment: investmentByMonth set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup
}); });
} }
} }
@ -732,7 +776,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: {
@ -741,6 +785,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`,
@ -874,12 +919,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)
}; };
} }
@ -914,6 +963,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);
@ -1132,6 +1182,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));
@ -1229,15 +1280,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,14 +10,15 @@ 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,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPublicDetails, PortfolioPublicDetails,
PortfolioReport PortfolioReport
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type { import type {
DateRange, DateRange,
GroupBy, GroupBy,
@ -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)) {
@ -185,27 +187,78 @@ export class PortfolioController {
}; };
} }
@Get('dividends')
@UseGuards(AuthGuard('jwt'))
public async getDividends(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioDividends> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
let dividends = await this.portfolioService.getDividends({
dateRange,
filters,
groupBy,
impersonationId
});
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
const maxDividend = dividends.reduce(
(investment, item) => Math.max(investment, item.investment),
1
);
dividends = dividends.map((item) => ({
date: item.date,
investment: item.investment / maxDividend
}));
}
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
dividends = dividends.map((item) => {
return nullifyValuesInObject(item, ['investment']);
});
}
return { dividends };
}
@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('assetClasses') filterByAssetClasses?: string,
@Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('groupBy') groupBy?: GroupBy @Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> { ): Promise<PortfolioInvestments> {
let investments: InvestmentItem[]; const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
if (groupBy === 'month') { let investments = await this.portfolioService.getInvestments({
investments = await this.portfolioService.getInvestments({ dateRange,
dateRange, filters,
impersonationId, groupBy,
groupBy: 'month' impersonationId
}); });
} else {
investments = await this.portfolioService.getInvestments({
dateRange,
impersonationId
});
}
if ( if (
impersonationId || impersonationId ||
@ -239,11 +292,21 @@ 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('range') dateRange: DateRange = 'max' @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
const performanceInformation = await this.portfolioService.getPerformance({ const performanceInformation = await this.portfolioService.getPerformance({
dateRange, dateRange,
filters,
impersonationId, impersonationId,
userId: this.request.user.id userId: this.request.user.id
}); });
@ -261,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()
}; };
@ -295,34 +358,30 @@ 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('range') dateRange: DateRange = 'max' @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioPositions> { ): Promise<PortfolioPositions> {
const result = await this.portfolioService.getPositions( const filters = this.apiService.buildFiltersFromQueryParams({
impersonationId, filterByAccounts,
dateRange filterByAssetClasses,
); filterByTags
});
if ( return this.portfolioService.getPositions({
impersonationId || dateRange,
this.userService.isRestrictedView(this.request.user) filters,
) { impersonationId
result.positions = result.positions.map((position) => { });
return nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'netPerformance',
'quantity'
]);
});
}
return result;
} }
@Get('public/:accessId') @Get('public/:accessId')
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublic( public async getPublic(
@Param('accessId') accessId @Param('accessId') accessId
): Promise<PortfolioPublicDetails> { ): Promise<PortfolioPublicDetails> {
@ -369,16 +428,18 @@ 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,
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
markets: hasDetails ? portfolioPosition.markets : undefined, markets: hasDetails ? portfolioPosition.markets : undefined,
name: portfolioPosition.name, name: portfolioPosition.name,
netPerformancePercent: portfolioPosition.netPerformancePercent, netPerformancePercent: portfolioPosition.netPerformancePercent,
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
}; };
} }
@ -386,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;
} }
@ -427,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

@ -1,5 +1,6 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.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';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
@ -19,11 +20,11 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
ASSET_SUB_CLASS_EMERGENCY_FUND, EMERGENCY_FUND_TAG_ID,
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,
@ -36,8 +37,7 @@ import {
PortfolioSummary, PortfolioSummary,
Position, Position,
TimelinePosition, TimelinePosition,
UserSettings, UserSettings
UserWithSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type { import type {
@ -46,7 +46,8 @@ import type {
GroupBy, GroupBy,
Market, Market,
OrderWithAccount, OrderWithAccount,
RequestWithUser RequestWithUser,
UserWithSettings
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
@ -67,6 +68,8 @@ import {
format, format,
isAfter, isAfter,
isBefore, isBefore,
isSameMonth,
isSameYear,
max, max,
parseISO, parseISO,
set, set,
@ -206,19 +209,63 @@ export class PortfolioService {
}; };
} }
public async getInvestments({ public async getDividends({
dateRange, dateRange,
impersonationId, filters,
groupBy groupBy,
impersonationId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
impersonationId: string; filters?: Filter[];
groupBy?: GroupBy; groupBy?: GroupBy;
impersonationId: string;
}): Promise<InvestmentItem[]> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const activities = await this.orderService.getOrders({
filters,
userId,
types: ['DIVIDEND'],
userCurrency: this.request.user.Settings.settings.baseCurrency
});
let dividends = activities.map((dividend) => {
return {
date: format(dividend.date, DATE_FORMAT),
investment: dividend.valueInBaseCurrency
};
});
if (groupBy) {
dividends = this.getDividendsByGroup({ dividends, groupBy });
}
const startDate = this.getStartDate(
dateRange,
parseDate(dividends[0]?.date)
);
return dividends.filter(({ date }) => {
return !isBefore(parseDate(date), startDate);
});
}
public async getInvestments({
dateRange,
filters,
groupBy,
impersonationId
}: {
dateRange: DateRange;
filters?: Filter[];
groupBy?: GroupBy;
impersonationId: string;
}): Promise<InvestmentItem[]> { }): Promise<InvestmentItem[]> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters,
userId, userId,
includeDrafts: true includeDrafts: true
}); });
@ -236,26 +283,31 @@ export class PortfolioService {
let investments: InvestmentItem[]; let investments: InvestmentItem[];
if (groupBy === 'month') { if (groupBy) {
investments = portfolioCalculator.getInvestmentsByMonth().map((item) => { investments = portfolioCalculator
return { .getInvestmentsByGroup(groupBy)
date: item.date, .map((item) => {
investment: item.investment.toNumber() return {
}; date: item.date,
}); investment: item.investment.toNumber()
};
});
// Add investment of current month // Add investment of current group
const dateOfCurrentMonth = format( const dateOfCurrentGroup = format(
set(new Date(), { date: 1 }), set(new Date(), {
date: 1,
month: groupBy === 'year' ? 0 : new Date().getMonth()
}),
DATE_FORMAT DATE_FORMAT
); );
const investmentOfCurrentMonth = investments.filter(({ date }) => { const investmentOfCurrentGroup = investments.filter(({ date }) => {
return date === dateOfCurrentMonth; return date === dateOfCurrentGroup;
}); });
if (investmentOfCurrentMonth.length <= 0) { if (investmentOfCurrentGroup.length <= 0) {
investments.push({ investments.push({
date: dateOfCurrentMonth, date: dateOfCurrentGroup,
investment: 0 investment: 0
}); });
} }
@ -303,11 +355,13 @@ export class PortfolioService {
public async getChart({ public async getChart({
dateRange = 'max', dateRange = 'max',
filters,
impersonationId, impersonationId,
userCurrency, userCurrency,
userId userId
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[];
impersonationId: string; impersonationId: string;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
@ -316,6 +370,7 @@ export class PortfolioService {
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters,
userId userId
}); });
@ -357,15 +412,15 @@ export class PortfolioService {
} }
public async getDetails({ public async getDetails({
impersonationId,
dateRange = 'max', dateRange = 'max',
filters, filters,
impersonationId,
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
impersonationId: string;
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<PortfolioDetails & { hasErrors: boolean }> { }): Promise<PortfolioDetails & { hasErrors: boolean }> {
@ -482,17 +537,15 @@ 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,
currency: item.currency, currency: item.currency,
dataSource: symbolProfile.dataSource, dataSource: symbolProfile.dataSource,
dateOfFirstActivity: parseDate(item.firstBuyDate),
grossPerformance: item.grossPerformance?.toNumber() ?? 0, grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent: grossPerformancePercent:
item.grossPerformancePercentage?.toNumber() ?? 0, item.grossPerformancePercentage?.toNumber() ?? 0,
@ -519,9 +572,7 @@ export class PortfolioService {
) { ) {
const cashPositions = await this.getCashPositions({ const cashPositions = await this.getCashPositions({
cashDetails, cashDetails,
emergencyFund,
userCurrency, userCurrency,
investment: totalInvestmentInBaseCurrency,
value: filteredValueInBaseCurrency value: filteredValueInBaseCurrency
}); });
@ -539,10 +590,51 @@ export class PortfolioService {
withExcludedAccounts withExcludedAccounts
}); });
if (
filters?.length === 1 &&
filters[0].id === EMERGENCY_FUND_TAG_ID &&
filters[0].type === 'TAG'
) {
const cashPositions = await this.getCashPositions({
cashDetails,
userCurrency,
value: filteredValueInBaseCurrency
});
const emergencyFundInCash = emergencyFund
.minus(
this.getEmergencyFundPositionsValueInBaseCurrency({
activities: orders
})
)
.toNumber();
filteredValueInBaseCurrency = emergencyFund;
accounts[UNKNOWN_KEY] = {
balance: 0,
currency: userCurrency,
current: emergencyFundInCash,
name: UNKNOWN_KEY,
original: emergencyFundInCash
};
holdings[userCurrency] = {
...cashPositions[userCurrency],
investment: emergencyFundInCash,
value: emergencyFundInCash
};
}
const summary = await this.getSummary({ const summary = await this.getSummary({
impersonationId, impersonationId,
userCurrency, userCurrency,
userId userId,
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency:
this.getEmergencyFundPositionsValueInBaseCurrency({
activities: orders
})
}); });
return { return {
@ -586,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,
@ -605,8 +700,9 @@ export class PortfolioService {
} }
const positionCurrency = orders[0].SymbolProfile.currency; const positionCurrency = orders[0].SymbolProfile.currency;
const [SymbolProfile] = const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]); { dataSource: aDataSource, symbol: aSymbol }
]);
const portfolioOrders: PortfolioOrder[] = orders const portfolioOrders: PortfolioOrder[] = orders
.filter((order) => { .filter((order) => {
@ -651,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(),
@ -690,7 +797,8 @@ export class PortfolioService {
historicalDataArray.push({ historicalDataArray.push({
averagePrice: orders[0].unitPrice, averagePrice: orders[0].unitPrice,
date: firstBuyDate, date: firstBuyDate,
value: orders[0].unitPrice marketPrice: orders[0].unitPrice,
quantity: orders[0].quantity
}); });
} }
@ -706,6 +814,7 @@ export class PortfolioService {
j++; j++;
} }
let currentAveragePrice = 0; let currentAveragePrice = 0;
let currentQuantity = 0;
const currentSymbol = transactionPoints[j].items.find( const currentSymbol = transactionPoints[j].items.find(
(item) => item.symbol === aSymbol (item) => item.symbol === aSymbol
); );
@ -713,12 +822,14 @@ export class PortfolioService {
currentAveragePrice = currentSymbol.quantity.eq(0) currentAveragePrice = currentSymbol.quantity.eq(0)
? 0 ? 0
: currentSymbol.investment.div(currentSymbol.quantity).toNumber(); : currentSymbol.investment.div(currentSymbol.quantity).toNumber();
currentQuantity = currentSymbol.quantity.toNumber();
} }
historicalDataArray.push({ historicalDataArray.push({
date, date,
marketPrice,
averagePrice: currentAveragePrice, averagePrice: currentAveragePrice,
value: marketPrice quantity: currentQuantity
}); });
maxPrice = Math.max(marketPrice ?? 0, maxPrice); maxPrice = Math.max(marketPrice ?? 0, maxPrice);
@ -739,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,
@ -795,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,
@ -809,14 +930,20 @@ export class PortfolioService {
} }
} }
public async getPositions( public async getPositions({
aImpersonationId: string, dateRange = 'max',
aDateRange: DateRange = 'max' filters,
): Promise<{ hasErrors: boolean; positions: Position[] }> { impersonationId
const userId = await this.getUserId(aImpersonationId, this.request.user.id); }: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
}): Promise<{ hasErrors: boolean; positions: Position[] }> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters,
userId userId
}); });
@ -836,7 +963,7 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(aDateRange, portfolioStart); const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate startDate
); );
@ -844,12 +971,14 @@ export class PortfolioService {
const positions = currentPositions.positions.filter( const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0) (item) => !item.quantity.eq(0)
); );
const dataGatheringItem = positions.map((position) => { const dataGatheringItem = positions.map((position) => {
return { return {
dataSource: position.dataSource, dataSource: position.dataSource,
symbol: position.symbol symbol: position.symbol
}; };
}); });
const symbols = positions.map((position) => position.symbol); const symbols = positions.map((position) => position.symbol);
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
@ -887,10 +1016,12 @@ export class PortfolioService {
public async getPerformance({ public async getPerformance({
dateRange = 'max', dateRange = 'max',
filters,
impersonationId, impersonationId,
userId userId
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[];
impersonationId: string; impersonationId: string;
userId: string; userId: string;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
@ -900,6 +1031,7 @@ export class PortfolioService {
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters,
userId userId
}); });
@ -929,32 +1061,25 @@ 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,
filters,
impersonationId, impersonationId,
userCurrency, userCurrency,
userId userId
@ -972,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(),
@ -1033,16 +1158,23 @@ export class PortfolioService {
portfolioStart portfolioStart
); );
const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0)
);
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
for (const position of currentPositions.positions) {
for (const position of positions) {
portfolioItemsNow[position.symbol] = position; portfolioItemsNow[position.symbol] = position;
} }
const accounts = await this.getValueOfAccounts({ const accounts = await this.getValueOfAccounts({
orders, orders,
portfolioItemsNow, portfolioItemsNow,
userId, userCurrency,
userCurrency userId
}); });
return { return {
rules: { rules: {
accountClusterRisk: await this.rulesService.evaluate( accountClusterRisk: await this.rulesService.evaluate(
@ -1066,19 +1198,19 @@ export class PortfolioService {
[ [
new CurrencyClusterRiskBaseCurrencyInitialInvestment( new CurrencyClusterRiskBaseCurrencyInitialInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
currentPositions positions
), ),
new CurrencyClusterRiskBaseCurrencyCurrentInvestment( new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
currentPositions positions
), ),
new CurrencyClusterRiskInitialInvestment( new CurrencyClusterRiskInitialInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
currentPositions positions
), ),
new CurrencyClusterRiskCurrentInvestment( new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
currentPositions positions
) )
], ],
<UserSettings>this.request.user.Settings.settings <UserSettings>this.request.user.Settings.settings
@ -1088,7 +1220,7 @@ export class PortfolioService {
new FeeRatioInitialInvestment( new FeeRatioInitialInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(), currentPositions.totalInvestment.toNumber(),
this.getFees({ orders, userCurrency }).toNumber() this.getFees({ userCurrency, activities: orders }).toNumber()
) )
], ],
<UserSettings>this.request.user.Settings.settings <UserSettings>this.request.user.Settings.settings
@ -1099,16 +1231,12 @@ export class PortfolioService {
private async getCashPositions({ private async getCashPositions({
cashDetails, cashDetails,
emergencyFund,
investment,
userCurrency, userCurrency,
value value
}: { }: {
cashDetails: CashDetails; cashDetails: CashDetails;
emergencyFund: Big;
investment: Big;
value: Big;
userCurrency: string; userCurrency: string;
value: Big;
}) { }) {
const cashPositions: PortfolioDetails['holdings'] = { const cashPositions: PortfolioDetails['holdings'] = {
[userCurrency]: this.getInitialCashPosition({ [userCurrency]: this.getInitialCashPosition({
@ -1139,62 +1267,38 @@ export class PortfolioService {
} }
} }
if (emergencyFund.gt(0)) {
cashPositions[ASSET_SUB_CLASS_EMERGENCY_FUND] = {
...cashPositions[userCurrency],
assetSubClass: ASSET_SUB_CLASS_EMERGENCY_FUND,
investment: emergencyFund.toNumber(),
name: ASSET_SUB_CLASS_EMERGENCY_FUND,
symbol: ASSET_SUB_CLASS_EMERGENCY_FUND,
value: emergencyFund.toNumber()
};
cashPositions[userCurrency].investment = new Big(
cashPositions[userCurrency].investment
)
.minus(emergencyFund)
.toNumber();
cashPositions[userCurrency].value = new Big(
cashPositions[userCurrency].value
)
.minus(emergencyFund)
.toNumber();
}
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;
} }
private getDividend({ private getDividend({
activities,
date = new Date(0), date = new Date(0),
orders,
userCurrency userCurrency
}: { }: {
activities: OrderWithAccount[];
date?: Date; date?: Date;
orders: OrderWithAccount[];
userCurrency: string; userCurrency: string;
}) { }) {
return orders return activities
.filter((order) => { .filter((activity) => {
// Filter out all orders before given date and type dividend // Filter out all activities before given date and type dividend
return ( return (
isBefore(date, new Date(order.date)) && isBefore(date, new Date(activity.date)) &&
order.type === TypeOfOrder.DIVIDEND activity.type === TypeOfOrder.DIVIDEND
); );
}) })
.map((order) => { .map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(), new Big(quantity).mul(unitPrice).toNumber(),
order.SymbolProfile.currency, SymbolProfile.currency,
userCurrency userCurrency
); );
}) })
@ -1204,24 +1308,118 @@ export class PortfolioService {
); );
} }
private getDividendsByGroup({
dividends,
groupBy
}: {
dividends: InvestmentItem[];
groupBy: GroupBy;
}): InvestmentItem[] {
if (dividends.length === 0) {
return [];
}
const dividendsByGroup: InvestmentItem[] = [];
let currentDate: Date;
let investmentByGroup = new Big(0);
for (const [index, dividend] of dividends.entries()) {
if (
isSameYear(parseDate(dividend.date), currentDate) &&
(groupBy === 'year' ||
isSameMonth(parseDate(dividend.date), currentDate))
) {
// Same group: Add up dividends
investmentByGroup = investmentByGroup.plus(dividend.investment);
} else {
// New group: Store previous group and reset
if (currentDate) {
dividendsByGroup.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup.toNumber()
});
}
currentDate = parseDate(dividend.date);
investmentByGroup = new Big(dividend.investment);
}
if (index === dividends.length - 1) {
// Store current month (latest order)
dividendsByGroup.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup.toNumber()
});
}
}
return dividendsByGroup;
}
private getEmergencyFundPositionsValueInBaseCurrency({
activities
}: {
activities: Activity[];
}) {
const emergencyFundOrders = activities.filter((activity) => {
return (
activity.tags?.some(({ id }) => {
return id === EMERGENCY_FUND_TAG_ID;
}) ?? false
);
});
let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0);
for (const order of emergencyFundOrders) {
if (order.type === 'BUY') {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.plus(
order.valueInBaseCurrency
);
} else if (order.type === 'SELL') {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.minus(
order.valueInBaseCurrency
);
}
}
return valueInBaseCurrencyOfEmergencyFundPositions.toNumber();
}
private getFees({ private getFees({
activities,
date = new Date(0), date = new Date(0),
orders,
userCurrency userCurrency
}: { }: {
activities: OrderWithAccount[];
date?: Date; date?: Date;
orders: OrderWithAccount[];
userCurrency: string; userCurrency: string;
}) { }) {
return orders return activities
.filter((order) => { .filter((activity) => {
// Filter out all orders before given date // Filter out all activities before given date
return isBefore(date, new Date(order.date)); return isBefore(date, new Date(activity.date));
}) })
.map((order) => { .map(({ fee, SymbolProfile }) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
order.fee, fee,
order.SymbolProfile.currency, SymbolProfile.currency,
userCurrency userCurrency
); );
}) })
@ -1240,12 +1438,12 @@ 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: [],
dataSource: undefined, dataSource: undefined,
dateOfFirstActivity: undefined,
grossPerformance: 0, grossPerformance: 0,
grossPerformancePercent: 0, grossPerformancePercent: 0,
investment: balance, investment: balance,
@ -1287,26 +1485,42 @@ export class PortfolioService {
private getStartDate(aDateRange: DateRange, portfolioStart: Date) { private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) { switch (aDateRange) {
case '1d': case '1d':
portfolioStart = max([portfolioStart, subDays(new Date(), 1)]); portfolioStart = max([
portfolioStart,
subDays(new Date().setHours(0, 0, 0, 0), 1)
]);
break; break;
case 'ytd': case 'ytd':
portfolioStart = max([portfolioStart, setDayOfYear(new Date(), 1)]); portfolioStart = max([
portfolioStart,
setDayOfYear(new Date().setHours(0, 0, 0, 0), 1)
]);
break; break;
case '1y': case '1y':
portfolioStart = max([portfolioStart, subYears(new Date(), 1)]); portfolioStart = max([
portfolioStart,
subYears(new Date().setHours(0, 0, 0, 0), 1)
]);
break; break;
case '5y': case '5y':
portfolioStart = max([portfolioStart, subYears(new Date(), 5)]); portfolioStart = max([
portfolioStart,
subYears(new Date().setHours(0, 0, 0, 0), 5)
]);
break; break;
} }
return portfolioStart; return portfolioStart;
} }
private async getSummary({ private async getSummary({
balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency,
impersonationId, impersonationId,
userCurrency, userCurrency,
userId userId
}: { }: {
balanceInBaseCurrency: number;
emergencyFundPositionsValueInBaseCurrency: number;
impersonationId: string; impersonationId: string;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
@ -1319,11 +1533,7 @@ export class PortfolioService {
userId userId
}); });
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({ const activities = await this.orderService.getOrders({
userId,
currency: userCurrency
});
const orders = await this.orderService.getOrders({
userCurrency, userCurrency,
userId userId
}); });
@ -1338,18 +1548,27 @@ export class PortfolioService {
return account?.isExcluded ?? false; return account?.isExcluded ?? false;
}); });
const dividend = this.getDividend({ orders, userCurrency }).toNumber(); const dividend = this.getDividend({
activities,
userCurrency
}).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({ orders, userCurrency }).toNumber(); const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = orders[0]?.date; const firstOrderDate = activities[0]?.date;
const items = this.getItems(orders).toNumber(); const items = this.getItems(activities).toNumber();
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY'); const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL'); const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber(); const cash = new Big(balanceInBaseCurrency)
.minus(emergencyFund)
.plus(emergencyFundPositionsValueInBaseCurrency)
.toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell); const committedFunds = new Big(totalBuy).minus(totalSell);
const totalOfExcludedActivities = new Big( const totalOfExcludedActivities = new Big(
this.getTotalByType(excludedActivities, userCurrency, 'BUY') this.getTotalByType(excludedActivities, userCurrency, 'BUY')
@ -1405,8 +1624,8 @@ export class PortfolioService {
totalSell, totalSell,
committedFunds: committedFunds.toNumber(), committedFunds: committedFunds.toNumber(),
emergencyFund: emergencyFund.toNumber(), emergencyFund: emergencyFund.toNumber(),
ordersCount: orders.filter((order) => { ordersCount: activities.filter(({ type }) => {
return order.type === 'BUY' || order.type === 'SELL'; return type === 'BUY' || type === 'SELL';
}).length }).length
}; };
} }
@ -1423,7 +1642,7 @@ export class PortfolioService {
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<{ }): Promise<{
transactionPoints: TransactionPoint[]; transactionPoints: TransactionPoint[];
orders: OrderWithAccount[]; orders: Activity[];
portfolioOrders: PortfolioOrder[]; portfolioOrders: PortfolioOrder[];
}> { }> {
const userCurrency = const userCurrency =
@ -1496,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 & {
@ -1526,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,
@ -1549,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; (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

@ -21,6 +21,7 @@ import {
} 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';
import { Request, Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { SubscriptionService } from './subscription.service'; import { SubscriptionService } from './subscription.service';
@ -62,6 +63,7 @@ export class SubscriptionController {
await this.subscriptionService.createSubscription({ await this.subscriptionService.createSubscription({
duration: coupon.duration, duration: coupon.duration,
price: 0,
userId: this.request.user.id userId: this.request.user.id
}); });
@ -86,9 +88,12 @@ export class SubscriptionController {
} }
@Get('stripe/callback') @Get('stripe/callback')
public async stripeCallback(@Req() req, @Res() res) { public async stripeCallback(
@Req() request: Request,
@Res() response: Response
) {
const userId = await this.subscriptionService.createSubscriptionViaStripe( const userId = await this.subscriptionService.createSubscriptionViaStripe(
req.query.checkoutSessionId <string>request.query.checkoutSessionId
); );
Logger.log( Logger.log(
@ -96,7 +101,7 @@ export class SubscriptionController {
'SubscriptionController' 'SubscriptionController'
); );
res.redirect( response.redirect(
`${this.configurationService.get( `${this.configurationService.get(
'ROOT_URL' 'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account` )}/${DEFAULT_LANGUAGE_CODE}/account`
@ -112,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

@ -1,7 +1,12 @@
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 { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import {
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config';
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
import { UserWithSettings } from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client'; import { Subscription } from '@prisma/client';
import { addMilliseconds, isBefore } from 'date-fns'; import { addMilliseconds, isBefore } from 'date-fns';
@ -19,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'
} }
); );
} }
@ -27,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,
@ -70,13 +75,16 @@ export class SubscriptionService {
public async createSubscription({ public async createSubscription({
duration = '1 year', duration = '1 year',
price,
userId userId
}: { }: {
duration?: StringValue; duration?: StringValue;
price: number;
userId: string; userId: string;
}) { }) {
await this.prismaService.subscription.create({ await this.prismaService.subscription.create({
data: { data: {
price,
expiresAt: addMilliseconds(new Date(), ms(duration)), expiresAt: addMilliseconds(new Date(), ms(duration)),
User: { User: {
connect: { connect: {
@ -93,10 +101,20 @@ export class SubscriptionService {
aCheckoutSessionId aCheckoutSessionId
); );
await this.createSubscription({ userId: session.client_reference_id }); let subscriptions: SubscriptionInterface[] = [];
await this.stripe.customers.update(session.customer as string, { const stripeConfig = (await this.prismaService.property.findUnique({
description: session.client_reference_id where: { key: PROPERTY_STRIPE_CONFIG }
})) ?? { value: '{}' };
subscriptions = [JSON.parse(stripeConfig.value)];
const coupon = subscriptions[0]?.coupon ?? 0;
const price = subscriptions[0]?.price ?? 0;
await this.createSubscription({
price: price - coupon,
userId: session.client_reference_id
}); });
return session.client_reference_id; return session.client_reference_id;
@ -105,7 +123,9 @@ export class SubscriptionService {
} }
} }
public getSubscription(aSubscriptions: Subscription[]) { public getSubscription(
aSubscriptions: Subscription[]
): UserWithSettings['subscription'] {
if (aSubscriptions.length > 0) { if (aSubscriptions.length > 0) {
const latestSubscription = aSubscriptions.reduce((a, b) => { const latestSubscription = aSubscriptions.reduce((a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b; return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
@ -113,12 +133,14 @@ export class SubscriptionService {
return { return {
expiresAt: latestSubscription.expiresAt, expiresAt: latestSubscription.expiresAt,
offer: latestSubscription.price === 0 ? 'default' : 'renewal',
type: isBefore(new Date(), latestSubscription.expiresAt) type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium ? SubscriptionType.Premium
: SubscriptionType.Basic : SubscriptionType.Basic
}; };
} else { } else {
return { return {
offer: 'default',
type: SubscriptionType.Basic type: SubscriptionType.Basic
}; };
} }

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

@ -6,6 +6,7 @@ import {
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 } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
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 +80,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_READ_ONLY_MODE } 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,
@ -69,23 +66,20 @@ export class UserController {
@Post() @Post()
public async signupUser(): Promise<UserItem> { public async signupUser(): Promise<UserItem> {
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { const isUserSignupEnabled =
const isReadOnlyMode = (await this.propertyService.getByKey( await this.propertyService.isUserSignupEnabled();
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
if (isReadOnlyMode) { if (!isUserSignupEnabled) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
); );
}
} }
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

@ -4,16 +4,13 @@ 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 { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config'; import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
import { import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces';
User as IUser,
UserSettings,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { import {
getPermissions, getPermissions,
hasRole, hasRole,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
@ -97,6 +94,7 @@ export class UserService {
const { const {
accessToken, accessToken,
Account, Account,
Analytics,
authChallenge, authChallenge,
createdAt, createdAt,
id, id,
@ -107,7 +105,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 +124,8 @@ export class UserService {
role, role,
Settings, Settings,
thirdPartyId, thirdPartyId,
updatedAt updatedAt,
activityCount: Analytics?.activityCount
}; };
if (user?.Settings) { if (user?.Settings) {
@ -154,15 +158,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 +228,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 +257,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,

File diff suppressed because it is too large Load Diff

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) {
@ -27,3 +28,51 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
return nullifyValuesInObject(object, keys); return nullifyValuesInObject(object, keys);
}); });
} }
export function redactAttributes({
object,
options
}: {
object: any;
options: { attribute: string; valueMap: { [key: string]: any } }[];
}): any {
if (!object || !options || !options.length) {
return object;
}
const redactedObject = cloneDeep(object);
for (const option of options) {
if (redactedObject.hasOwnProperty(option.attribute)) {
if (option.valueMap['*'] || option.valueMap['*'] === null) {
redactedObject[option.attribute] = option.valueMap['*'];
} else if (option.valueMap[redactedObject[option.attribute]]) {
redactedObject[option.attribute] =
option.valueMap[redactedObject[option.attribute]];
}
} else {
// If the attribute is not present on the current object,
// check if it exists on any nested objects
for (const property in redactedObject) {
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
redactedObject[property] = redactAttributes({
options,
object: redactedObject[property]
});
}
}
}
}
return redactedObject;
}

View File

@ -1,4 +1,6 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; 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,
@ -12,7 +14,7 @@ import { map } from 'rxjs/operators';
export class RedactValuesInResponseInterceptor<T> export class RedactValuesInResponseInterceptor<T>
implements NestInterceptor<T, any> implements NestInterceptor<T, any>
{ {
public constructor() {} public constructor(private userService: UserService) {}
public intercept( public intercept(
context: ExecutionContext, context: ExecutionContext,
@ -21,34 +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 (hasImpersonationId) { if (
if (data.accounts) { hasImpersonationId ||
for (const accountId of Object.keys(data.accounts)) { this.userService.isRestrictedView(request.user)
if (data.accounts[accountId]?.balance !== undefined) { ) {
data.accounts[accountId].balance = null; data = redactAttributes({
} object: data,
} options: [
} 'balance',
'balanceInBaseCurrency',
if (data.activities) { 'comment',
data.activities = data.activities.map((activity: Activity) => { 'convertedBalance',
if (activity.Account?.balance !== undefined) { 'dividendInBaseCurrency',
activity.Account.balance = null; 'fee',
} 'feeInBaseCurrency',
'filteredValueInBaseCurrency',
return activity; 'grossPerformance',
}); 'investment',
} 'netPerformance',
'quantity',
if (data.filteredValueInBaseCurrency) { 'symbolMapping',
data.filteredValueInBaseCurrency = null; 'totalBalanceInBaseCurrency',
} 'totalValueInBaseCurrency',
'unitPrice',
if (data.totalValueInBaseCurrency) { 'value',
data.totalValueInBaseCurrency = null; 'valueInBaseCurrency'
} ].map((attribute) => {
return {
attribute,
valueMap: {
'*': 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

@ -1,3 +1,4 @@
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { encodeDataSource } from '@ghostfolio/common/helper'; import { encodeDataSource } from '@ghostfolio/common/helper';
import { import {
CallHandler, CallHandler,
@ -5,7 +6,7 @@ import {
Injectable, Injectable,
NestInterceptor NestInterceptor
} from '@nestjs/common'; } from '@nestjs/common';
import { isArray } from 'lodash'; import { DataSource } from '@prisma/client';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
@ -25,66 +26,24 @@ 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({
) { options: [
if (data.activities) { {
data.activities.map((activity) => { attribute: 'dataSource',
activity.SymbolProfile.dataSource = encodeDataSource( valueMap: Object.keys(DataSource).reduce(
activity.SymbolProfile.dataSource (valueMap, dataSource) => {
); valueMap[dataSource] = encodeDataSource(
return activity; DataSource[dataSource]
}); );
} return valueMap;
},
if (isArray(data.benchmarks)) { {}
data.benchmarks.map((benchmark) => { )
benchmark.dataSource = encodeDataSource(benchmark.dataSource);
return benchmark;
});
}
if (data.dataSource) {
data.dataSource = encodeDataSource(data.dataSource);
}
if (data.errors) {
for (const error of data.errors) {
if (error.dataSource) {
error.dataSource = encodeDataSource(error.dataSource);
} }
} ],
} object: data
});
if (data.holdings) {
for (const symbol of Object.keys(data.holdings)) {
if (data.holdings[symbol].dataSource) {
data.holdings[symbol].dataSource = encodeDataSource(
data.holdings[symbol].dataSource
);
}
}
}
if (data.items) {
data.items.map((item) => {
item.dataSource = encodeDataSource(item.dataSource);
return item;
});
}
if (data.positions) {
data.positions.map((position) => {
position.dataSource = encodeDataSource(position.dataSource);
return position;
});
}
if (data.SymbolProfile) {
data.SymbolProfile.dataSource = encodeDataSource(
data.SymbolProfile.dataSource
);
}
} }
return data; return data;

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

@ -1,14 +1,13 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions private positions: TimelinePosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Current Investment: Base Currency' name: 'Current Investment: Base Currency'
@ -17,7 +16,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute( const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
this.currentPositions.positions, this.positions,
'currency', 'currency',
ruleSettings.baseCurrency ruleSettings.baseCurrency
); );

View File

@ -1,14 +1,13 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> { export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions private positions: TimelinePosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Initial Investment: Base Currency' name: 'Initial Investment: Base Currency'
@ -17,7 +16,7 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Setti
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute( const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
this.currentPositions.positions, this.positions,
'currency', 'currency',
ruleSettings.baseCurrency ruleSettings.baseCurrency
); );

View File

@ -1,14 +1,13 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
public constructor( public constructor(
public exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions private positions: TimelinePosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Current Investment' name: 'Current Investment'
@ -17,7 +16,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute( const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
this.currentPositions.positions, this.positions,
'currency', 'currency',
ruleSettings.baseCurrency ruleSettings.baseCurrency
); );

View File

@ -1,14 +1,13 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> { export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions private positions: TimelinePosition[]
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Initial Investment' name: 'Initial Investment'
@ -17,7 +16,7 @@ export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute( const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
this.currentPositions.positions, this.positions,
'currency', 'currency',
ruleSettings.baseCurrency ruleSettings.baseCurrency
); );

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

@ -11,14 +11,16 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable() @Injectable()
export class CronService { export class CronService {
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
public constructor( public constructor(
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly twitterBotService: TwitterBotService private readonly twitterBotService: TwitterBotService
) {} ) {}
@Cron(CronExpression.EVERY_HOUR) @Cron(CronExpression.EVERY_4_HOURS)
public async runEveryHour() { public async runEveryFourHours() {
await this.dataGatheringService.gather7Days(); await this.dataGatheringService.gather7Days();
} }
@ -28,12 +30,12 @@ export class CronService {
} }
@Cron(CronExpression.EVERY_DAY_AT_5PM) @Cron(CronExpression.EVERY_DAY_AT_5PM)
public async runEveryDayAtFivePM() { public async runEveryDayAtFivePm() {
this.twitterBotService.tweetFearAndGreedIndex(); this.twitterBotService.tweetFearAndGreedIndex();
} }
@Cron(CronExpression.EVERY_WEEKEND) @Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
public async runEveryWeekend() { public async runEverySundayAtTwelvePm() {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) { for (const { dataSource, symbol } of uniqueAssets) {

View File

@ -11,7 +11,7 @@ import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { JobOptions, Queue } from 'bull'; import { JobOptions, Queue } from 'bull';
import { format, subDays } from 'date-fns'; import { format, min, subDays, subYears } from 'date-fns';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
@ -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,
{ {
@ -237,7 +236,7 @@ export class DataGatheringService {
return { return {
dataSource, dataSource,
symbol, symbol,
date: startDate date: min([startDate, subYears(new Date(), 10)])
}; };
}); });
@ -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

@ -37,6 +37,20 @@ export class AlphaVantageService implements DataProviderInterface {
}; };
} }
public async getDividends({
from,
granularity = 'day',
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
return {};
}
public async getHistorical( public async getHistorical(
aSymbol: string, aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',

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,6 +59,6 @@ import { DataProviderService } from './data-provider.service';
] ]
} }
], ],
exports: [DataProviderService, GhostfolioScraperApiService] exports: [DataProviderService, 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/types';
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';
@ -23,6 +24,27 @@ export class DataProviderService {
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async getDividends({
dataSource,
from,
granularity = 'day',
symbol,
to
}: {
dataSource: DataSource;
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
return this.getDataProvider(DataSource[dataSource]).getDividends({
from,
granularity,
symbol,
to
});
}
public async getHistorical( public async getHistorical(
aItems: IDataGatheringItem[], aItems: IDataGatheringItem[],
aGranularity: Granularity = 'month', aGranularity: Granularity = 'month',
@ -239,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
@ -274,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,11 +35,32 @@ 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
}; };
} }
public async getDividends({
from,
granularity = 'day',
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
return {};
}
public async getHistorical( public async getHistorical(
aSymbol: string, aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
@ -108,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');
} }
@ -142,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,180 +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 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

@ -34,6 +34,20 @@ export class GoogleSheetsService implements DataProviderInterface {
}; };
} }
public async getDividends({
from,
granularity = 'day',
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
return {};
}
public async getHistorical( public async getHistorical(
aSymbol: string, aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
@ -131,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

@ -11,6 +11,18 @@ export interface DataProviderInterface {
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>; getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
getDividends({
from,
granularity,
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}): Promise<{ [date: string]: IDataProviderHistoricalResponse }>;
getHistorical( getHistorical(
aSymbol: string, aSymbol: string,
aGranularity: Granularity, aGranularity: Granularity,

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(
@ -29,6 +38,20 @@ export class ManualService implements DataProviderInterface {
}; };
} }
public async getDividends({
from,
granularity = 'day',
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
return {};
}
public async getHistorical( public async getHistorical(
aSymbol: string, aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
@ -37,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 {
@ -74,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'
}; };
} }
@ -91,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,
@ -118,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

@ -5,20 +5,18 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent'; import bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns'; import { format } from 'date-fns';
@Injectable() @Injectable()
export class RapidApiService implements DataProviderInterface { export class RapidApiService implements DataProviderInterface {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService
private readonly prismaService: PrismaService
) {} ) {}
public canHandle(symbol: string) { public canHandle(symbol: string) {
@ -33,6 +31,20 @@ export class RapidApiService implements DataProviderInterface {
}; };
} }
public async getDividends({
from,
granularity = 'day',
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
return {};
}
public async getHistorical( public async getHistorical(
aSymbol: string, aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
@ -47,41 +59,6 @@ export class RapidApiService implements DataProviderInterface {
if (symbol === ghostfolioFearAndGreedIndexSymbol) { if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
try {
// Rebuild historical data
// TODO: can be removed after all data from the last year has been gathered
// (introduced on 27.03.2021)
await this.prismaService.marketData.create({
data: {
symbol,
dataSource: this.getName(),
date: subWeeks(getToday(), 1),
marketPrice: fgi.oneWeekAgo.value
}
});
await this.prismaService.marketData.create({
data: {
symbol,
dataSource: this.getName(),
date: subMonths(getToday(), 1),
marketPrice: fgi.oneMonthAgo.value
}
});
await this.prismaService.marketData.create({
data: {
symbol,
dataSource: this.getName(),
date: subYears(getToday(), 1),
marketPrice: fgi.oneYearAgo.value
}
});
///////////////////////////////////////////////////////////////////////////
} catch {}
return { return {
[ghostfolioFearAndGreedIndexSymbol]: { [ghostfolioFearAndGreedIndexSymbol]: {
[format(getYesterday(), DATE_FORMAT)]: { [format(getYesterday(), DATE_FORMAT)]: {

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;
@ -154,16 +155,65 @@ export class YahooFinanceService implements DataProviderInterface {
response.url = url; response.url = url;
} }
} catch (error) { } catch (error) {
throw new Error( Logger.error(error, 'YahooFinanceService');
`Could not get asset profile for ${aSymbol} (${this.getName()}): [${
error.name
}] ${error.message}`
);
} }
return response; return response;
} }
public async getDividends({
from,
granularity = 'day',
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
if (isSameDay(from, to)) {
to = addDays(to, 1);
}
try {
const historicalResult = await yahooFinance.historical(
this.convertToYahooFinanceSymbol(symbol),
{
events: 'dividends',
interval: granularity === 'month' ? '1mo' : '1d',
period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT)
}
);
const response: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
for (const historicalItem of historicalResult) {
response[format(historicalItem.date, DATE_FORMAT)] = {
marketPrice: this.getConvertedValue({
symbol,
value: historicalItem.dividends
})
};
}
return response;
} catch (error) {
Logger.error(
`Could not get dividends for ${symbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`,
'YahooFinanceService'
);
return {};
}
}
public async getHistorical( public async getHistorical(
aSymbol: string, aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
@ -176,11 +226,9 @@ export class YahooFinanceService implements DataProviderInterface {
to = addDays(to, 1); to = addDays(to, 1);
} }
const yahooFinanceSymbol = this.convertToYahooFinanceSymbol(aSymbol);
try { try {
const historicalResult = await yahooFinance.historical( const historicalResult = await yahooFinance.historical(
yahooFinanceSymbol, this.convertToYahooFinanceSymbol(aSymbol),
{ {
interval: '1d', interval: '1d',
period1: format(from, DATE_FORMAT), period1: format(from, DATE_FORMAT),
@ -192,27 +240,14 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {}; } = {};
// Convert symbol back response[aSymbol] = {};
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
response[symbol] = {};
for (const historicalItem of historicalResult) { for (const historicalItem of historicalResult) {
let marketPrice = historicalItem.close; response[aSymbol][format(historicalItem.date, DATE_FORMAT)] = {
marketPrice: this.getConvertedValue({
if (symbol === `${this.baseCurrency}GBp`) { symbol: aSymbol,
// Convert GPB to GBp (pence) value: historicalItem.close
marketPrice = new Big(marketPrice).mul(100).toNumber(); }),
} else if (symbol === `${this.baseCurrency}ILA`) {
// Convert ILS to ILA
marketPrice = new Big(marketPrice).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ZAc`) {
// Convert ZAR to ZAc (cents)
marketPrice = new Big(marketPrice).mul(100).toNumber();
}
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
marketPrice,
performance: historicalItem.open - historicalItem.close performance: historicalItem.open - historicalItem.close
}; };
} }
@ -374,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(),
@ -407,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 - ', '');
@ -427,14 +471,41 @@ export class YahooFinanceService implements DataProviderInterface {
return name || shortName || symbol; return name || shortName || symbol;
} }
private parseAssetClass(aPrice: Price): { private getConvertedValue({
symbol,
value
}: {
symbol: string;
value: number;
}) {
if (symbol === `${this.baseCurrency}GBp`) {
// Convert GPB to GBp (pence)
return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ILA`) {
// Convert ILS to ILA
return new Big(value).mul(100).toNumber();
} else if (symbol === `${this.baseCurrency}ZAc`) {
// Convert ZAR to ZAc (cents)
return new Big(value).mul(100).toNumber();
}
return value;
}
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;
@ -452,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

@ -0,0 +1,11 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { PlatformService } from './platform.service';
@Module({
exports: [PlatformService],
imports: [PrismaModule],
providers: [PlatformService]
})
export class PlatformModule {}

View File

@ -0,0 +1,11 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class PlatformService {
public constructor(private readonly prismaService: PrismaService) {}
public async get() {
return this.prismaService.platform.findMany();
}
}

View File

@ -1,5 +1,8 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import {
PROPERTY_CURRENCIES,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
@ -39,6 +42,13 @@ export class PropertyService {
return properties?.[aKey]; return properties?.[aKey];
} }
public async isUserSignupEnabled() {
return (
((await this.getByKey(PROPERTY_IS_USER_SIGNUP_ENABLED)) as boolean) ??
true
);
}
public async put({ key, value }: { key: string; value: string }) { public async put({ key, value }: { key: string; value: string }) {
return this.prismaService.property.upsert({ return this.prismaService.property.upsert({
create: { key, value }, create: { key, value },

View File

@ -36,6 +36,13 @@ export class SymbolProfileService {
_count: { _count: {
select: { Order: true } select: { Order: true }
}, },
Order: {
orderBy: {
date: 'asc'
},
select: { date: true },
take: 1
},
SymbolProfileOverrides: true SymbolProfileOverrides: true
}, },
where: { where: {
@ -118,6 +125,9 @@ export class SymbolProfileService {
private getSymbols( private getSymbols(
symbolProfiles: (SymbolProfile & { symbolProfiles: (SymbolProfile & {
_count: { Order: number }; _count: { Order: number };
Order?: {
date: Date;
}[];
SymbolProfileOverrides: SymbolProfileOverrides; SymbolProfileOverrides: SymbolProfileOverrides;
})[] })[]
): EnhancedSymbolProfile[] { ): EnhancedSymbolProfile[] {
@ -128,6 +138,7 @@ export class SymbolProfileService {
countries: this.getCountries( countries: this.getCountries(
symbolProfile?.countries as unknown as Prisma.JsonArray symbolProfile?.countries as unknown as Prisma.JsonArray
), ),
dateOfFirstActivity: <Date>undefined,
scraperConfiguration: this.getScraperConfiguration(symbolProfile), scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile), sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile) symbolMapping: this.getSymbolMapping(symbolProfile)
@ -136,6 +147,9 @@ export class SymbolProfileService {
item.activitiesCount = symbolProfile._count.Order; item.activitiesCount = symbolProfile._count.Order;
delete item._count; delete item._count;
item.dateOfFirstActivity = symbolProfile.Order?.[0]?.date;
delete item.Order;
if (item.SymbolProfileOverrides) { if (item.SymbolProfileOverrides) {
item.assetClass = item.assetClass =
item.SymbolProfileOverrides.assetClass ?? item.assetClass; item.SymbolProfileOverrides.assetClass ?? item.assetClass;

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

@ -3,12 +3,7 @@ export default {
displayName: 'client', displayName: 'client',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'], setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: { globals: {},
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$'
}
},
coverageDirectory: '../../coverage/apps/client', coverageDirectory: '../../coverage/apps/client',
snapshotSerializers: [ snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes', 'jest-preset-angular/build/serializers/no-ng-attributes',
@ -16,7 +11,13 @@ export default {
'jest-preset-angular/build/serializers/html-comment' 'jest-preset-angular/build/serializers/html-comment'
], ],
transform: { transform: {
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular' '^.+.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$'
}
]
}, },
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'], transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
preset: '../../jest.preset.js' preset: '../../jest.preset.js'

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,
@ -89,6 +92,10 @@
"baseHref": "/es/", "baseHref": "/es/",
"localize": ["es"] "localize": ["es"]
}, },
"development-fr": {
"baseHref": "/fr/",
"localize": ["fr"]
},
"development-it": { "development-it": {
"baseHref": "/it/", "baseHref": "/it/",
"localize": ["it"] "localize": ["it"]
@ -97,6 +104,10 @@
"baseHref": "/nl/", "baseHref": "/nl/",
"localize": ["nl"] "localize": ["nl"]
}, },
"development-pt": {
"baseHref": "/pt/",
"localize": ["pt"]
},
"production": { "production": {
"fileReplacements": [ "fileReplacements": [
{ {
@ -144,12 +155,18 @@
"development-es": { "development-es": {
"browserTarget": "client:build:development-es" "browserTarget": "client:build:development-es"
}, },
"development-fr": {
"browserTarget": "client:build:development-fr"
},
"development-it": { "development-it": {
"browserTarget": "client:build:development-it" "browserTarget": "client:build:development-it"
}, },
"development-nl": { "development-nl": {
"browserTarget": "client:build:development-nl" "browserTarget": "client:build:development-nl"
}, },
"development-pt": {
"browserTarget": "client:build:development-pt"
},
"production": { "production": {
"browserTarget": "client:build:production" "browserTarget": "client:build:production"
} }
@ -164,8 +181,10 @@
"targetFiles": [ "targetFiles": [
"messages.de.xlf", "messages.de.xlf",
"messages.es.xlf", "messages.es.xlf",
"messages.fr.xlf",
"messages.it.xlf", "messages.it.xlf",
"messages.nl.xlf" "messages.nl.xlf",
"messages.pt.xlf"
] ]
} }
}, },
@ -194,6 +213,10 @@
"baseHref": "/es/", "baseHref": "/es/",
"translation": "apps/client/src/locales/messages.es.xlf" "translation": "apps/client/src/locales/messages.es.xlf"
}, },
"fr": {
"baseHref": "/fr/",
"translation": "apps/client/src/locales/messages.fr.xlf"
},
"it": { "it": {
"baseHref": "/it/", "baseHref": "/it/",
"translation": "apps/client/src/locales/messages.it.xlf" "translation": "apps/client/src/locales/messages.it.xlf"
@ -201,6 +224,10 @@
"nl": { "nl": {
"baseHref": "/nl/", "baseHref": "/nl/",
"translation": "apps/client/src/locales/messages.nl.xlf" "translation": "apps/client/src/locales/messages.nl.xlf"
},
"pt": {
"baseHref": "/pt/",
"translation": "apps/client/src/locales/messages.pt.xlf"
} }
}, },
"sourceLocale": "en" "sourceLocale": "en"

View File

@ -2,7 +2,7 @@ import { Platform } from '@angular/cdk/platform';
import { Inject, forwardRef } from '@angular/core'; import { Inject, forwardRef } from '@angular/core';
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core'; import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { format, parse } from 'date-fns'; import { addYears, format, getYear, parse } from 'date-fns';
export class CustomDateAdapter extends NativeDateAdapter { export class CustomDateAdapter extends NativeDateAdapter {
public constructor( public constructor(
@ -31,6 +31,16 @@ export class CustomDateAdapter extends NativeDateAdapter {
* Parses a date from a provided value * Parses a date from a provided value
*/ */
public parse(aValue: string): Date { public parse(aValue: string): Date {
return parse(aValue, getDateFormatString(this.locale), new Date()); let date = parse(aValue, getDateFormatString(this.locale), new Date());
if (getYear(date) < 1900) {
if (getYear(date) > Number(format(new Date(), 'yy')) + 1) {
date = addYears(date, 1900);
} else {
date = addYears(date, 2000);
}
}
return date;
} }
} }

View File

@ -109,6 +109,34 @@ const routes: Routes = [
'./pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module' './pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module'
).then((m) => m.BlackFriday2022PageModule) ).then((m) => m.BlackFriday2022PageModule)
}, },
{
path: 'blog/2022/12/the-importance-of-tracking-your-personal-finances',
loadChildren: () =>
import(
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
).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: 'blog/2023/03/ghostfolio-reaches-1000-stars-on-github',
loadChildren: () =>
import(
'./pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.module'
).then((m) => m.ThousandStarsOnGitHubPageModule)
},
{ {
path: 'demo', path: 'demo',
loadChildren: () => loadChildren: () =>
@ -208,9 +236,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

@ -3,6 +3,7 @@
class="position-fixed w-100" class="position-fixed w-100"
[currentRoute]="currentRoute" [currentRoute]="currentRoute"
[info]="info" [info]="info"
[pageTitle]="pageTitle"
[user]="user" [user]="user"
(signOut)="onSignOut()" (signOut)="onSignOut()"
></gf-header> ></gf-header>

View File

@ -1,4 +1,4 @@
@import '~apps/client/src/styles/ghostfolio-style'; @import 'apps/client/src/styles/ghostfolio-style';
:host { :host {
display: block; display: block;

View File

@ -1,20 +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 { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router'; import { 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';
@ -36,6 +33,7 @@ export class AppComponent implements OnDestroy, OnInit {
public currentYear = new Date().getFullYear(); public currentYear = new Date().getFullYear();
public deviceType: string; public deviceType: string;
public info: InfoItem; public info: InfoItem;
public pageTitle: string;
public user: User; public user: User;
public version = environment.version; public version = environment.version;
@ -45,8 +43,9 @@ 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 tokenStorageService: TokenStorageService, private tokenStorageService: TokenStorageService,
private userService: UserService private userService: UserService
) { ) {
@ -66,6 +65,19 @@ export class AppComponent implements OnDestroy, OnInit {
this.currentRoute = urlSegments[0].path; this.currentRoute = urlSegments[0].path;
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
if (this.deviceType === 'mobile') {
setTimeout(() => {
const index = this.title.getTitle().indexOf('');
const title =
index === -1
? ''
: this.title.getTitle().substring(0, index).trim();
this.pageTitle = title.length <= 15 ? title : 'Ghostfolio';
this.changeDetectorRef.markForCheck();
});
}
}); });
this.userService.stateChanged this.userService.stateChanged
@ -92,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() {
@ -105,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 { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/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

@ -1,4 +1,4 @@
@import '~apps/client/src/styles/ghostfolio-style'; @import 'apps/client/src/styles/ghostfolio-style';
:host { :host {
display: block; display: block;

View File

@ -1,7 +1,7 @@
:host { :host {
display: block; display: block;
.mat-dialog-content { .mat-mdc-dialog-content {
max-height: unset; max-height: unset;
} }
} }

View File

@ -12,7 +12,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper'; import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { AccountType } from '@prisma/client'; import { translate } from '@ghostfolio/ui/i18n';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -27,7 +27,7 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./account-detail-dialog.component.scss'] styleUrls: ['./account-detail-dialog.component.scss']
}) })
export class AccountDetailDialog implements OnDestroy, OnInit { export class AccountDetailDialog implements OnDestroy, OnInit {
public accountType: AccountType; public accountType: string;
public name: string; public name: string;
public orders: OrderWithAccount[]; public orders: OrderWithAccount[];
public platformName: string; public platformName: string;
@ -59,7 +59,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.fetchAccount(this.data.accountId) .fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => { .subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
this.accountType = accountType; this.accountType = translate(accountType);
this.name = name; this.name = name;
this.platformName = Platform?.name ?? '-'; this.platformName = Platform?.name ?? '-';
this.valueInBaseCurrency = valueInBaseCurrency; this.valueInBaseCurrency = valueInBaseCurrency;

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