Compare commits

...

274 Commits

Author SHA1 Message Date
4090b03406 Release 1.262.0 (#1906) 2023-04-29 10:41:28 +02:00
431d1d5fec Feature/extract locales 20230429 (#1905)
* Update locales

* Update changelog
2023-04-29 10:39:17 +02:00
d74d79198b Feature/add labels to tabs (#1847)
* Add labels

* Update changelog
2023-04-29 10:15:55 +02:00
623a284ba4 Feature/add and update historical data in bulk (#1904)
* Upsert historical data in bulk

* Update changelog
2023-04-29 10:12:50 +02:00
f79c36edbb Fix import (#1902) 2023-04-29 10:00:04 +02:00
f4c748f67a Feature/extend support for impersonation mode (#1898)
* Support impersonation of all users for local development

* Update changelog
2023-04-28 21:02:24 +02:00
672d8dfab2 Fix/holdings always include cash position (#1897)
* Improved holdings table showing cash position also when the filter contains accounts

* Update changelog
2023-04-28 12:08:45 +02:00
0464adccce Fix script (#1885) 2023-04-26 13:41:05 +02:00
c3df6c3194 Release 1.261.0 (#1896) 2023-04-25 20:24:39 +02:00
29d53c7df4 Feature/add distance to now to the subscription expiration date (#1895)
* Add distance to now

* Update changelog
2023-04-25 20:23:07 +02:00
7b77dc044a Feature/add state to market data database schema (#1893)
* Add state (CLOSE / INTRADAY) to MarketData

* Update changelog
2023-04-25 20:09:12 +02:00
67e758365f feature: allow to delete all activities of a user (#1880)
* Allow to delete all activities of a user

* Update changelog
2023-04-23 19:49:32 +02:00
475231ffd8 Release 1.260.0 (#1892) 2023-04-23 12:04:06 +02:00
513a564e2c Restructure services (#1891) 2023-04-23 12:02:01 +02:00
cddea0401f Feature/add data source as unique constraint to market data schema (#1889)
* Add dataSource as unique constraint to MarketData schema

* Update changelog
2023-04-23 11:13:08 +02:00
3dafbf7fef Add schema synchronization (#1890) 2023-04-23 11:02:12 +02:00
fcd75414be Update date (#1860) 2023-04-23 11:01:53 +02:00
c1b5bfff8c Bugfix/remove sort header in comment column of market data table (#1888)
* Remove sort header

* Update changelog
2023-04-23 10:49:11 +02:00
3c322cca0d Release 1.259.0 (#1887) 2023-04-22 16:05:44 +02:00
e965d12e31 Feature/add health check endpoints (#1886)
* Add health check endpoints

* Update changelog
2023-04-22 16:03:45 +02:00
3daf55a0dd Bugfix/remove sort header in comment column of activities table (#1883)
* Remove sort header

* Update changelog
2023-04-22 14:44:45 +02:00
aafedd5f75 Feature/increase robustness if live data is missing (#1884)
* Continuously persist today's market data

* Add fallback to historical market data if data provider does not provide live data

* Update changelog
2023-04-22 14:43:57 +02:00
32956ae04c Fix: performance column header alignment (#1881)
* Fix: performance column header alignment

* Update changelog
2023-04-21 18:05:51 +02:00
bfd0241b2d update target in proxy to work with api in locahost (#1875)
Co-authored-by: francisco <francisco@innonova.ch>
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2023-04-20 18:51:35 +02:00
5eff8402db Release/1.258.0 (#1878)
* Release 1.258.0
  * Introduce data source mapping

* Update changelog
2023-04-20 09:07:22 +02:00
ffa020ee2a Release 1.257.0 (#1872) 2023-04-18 20:36:22 +02:00
80a3668aa9 Bugfix/fix world map chart component (#1871)
* Clone countries before manipulation

* Update changelog
2023-04-18 20:32:18 +02:00
7378900050 Bugfix/fix currency inconsistency with GBX and GBp in eod historical data service (#1869)
* Fix currency inconsistency (GBX vs. GBp)

* Update changelog
2023-04-18 20:31:33 +02:00
9be457943c Feature/introduce allocations by etf provider (#1870)
* Introduce allocations by etf provider

* Update changelog
2023-04-18 20:13:03 +02:00
93454c6c15 Release 1.256.0 (#1868) 2023-04-17 14:11:59 +02:00
fccbd76993 Bugfix/fix style of apply current market price button (#1866)
* Fix styles

* Update changelog
2023-04-17 14:09:55 +02:00
922876a893 Feature/add yahoo finance data enhancer (#1865)
* Add Yahoo Finance data enhancer

* Update changelog
2023-04-17 14:09:27 +02:00
654446f068 Feature/improve handling of jobs (#1864)
* Improve handling of jobs
  * Remove jobs on complete
  * Refactor jobs removal

* Update changelog
2023-04-16 19:56:19 +02:00
947460abdd Bugfix/fix ids of gather asset profile process jobs (#1863)
* Fix job ids

* Update changelog
2023-04-16 09:49:27 +02:00
c5635b0050 Release 1.255.0 (#1862) 2023-04-15 16:00:47 +02:00
8a3a6308a3 Feature/make system message expandable (#1861)
* Make system message expandable

* Update changelog
2023-04-15 15:59:04 +02:00
290a07fe79 Feature/upgrade prisma to version 4.12.0 (#1845)
* Update prisma to version 4.12.0

* Update changelog
2023-04-15 15:32:02 +02:00
4c907d56f0 Improve message (#1859) 2023-04-15 15:09:15 +02:00
56b437ca74 Bugfix/improve info message style (#1858)
* Improve info message style

* Update changelog
2023-04-15 15:08:56 +02:00
e23ff33e6f Feature/skip job creation for manual data source without scraper configuration (#1857)
* Skip job creation for MANUAL data source without scraper configuration

* Update changelog
2023-04-15 11:54:47 +02:00
f2d206262e Release 1.254.0 (#1856) 2023-04-14 19:58:49 +02:00
1ed5690b33 Feature/improve queue jobs implementation (#1855)
* Improve queue jobs implementation

* Update changelog
2023-04-14 19:57:23 +02:00
4451514ec5 Release 1.253.0 (#1854) 2023-04-14 07:01:34 +02:00
8f73f85276 Bugfix/fix background color of dialogs in dark mode (#1853)
* Fix background color

* Update changelog
2023-04-13 08:47:19 +02:00
9a5d7b664b Release 1.252.2 (#1852) 2023-04-11 18:06:34 +02:00
7d2d1d971a Feature/deprecate get auth endpoint (#1851)
* Deprecate GET auth endpoint

* Update documentation

* Update changelog
2023-04-11 18:04:18 +02:00
d111493eed Release 1.252.1 (#1849) 2023-04-10 20:52:34 +02:00
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
428 changed files with 30044 additions and 8812 deletions

View File

View File

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

1
.gitignore vendored
View File

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

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v16

View File

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

View File

@ -5,6 +5,596 @@ 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.262.0 - 2023-04-29
### Added
- Added the labels to the tabs to increase the usability
- Extended the support of the impersonation mode for local development
### Changed
- Improved the queue jobs implementation by adding / updating historical market data in bulk
- Improved the language localization for German (`de`)
### Fixed
- Improved the holdings table by showing the cash position also when the filter contains the accounts, so that we can see the total allocation for that account
## 1.261.0 - 2023-04-25
### Added
- Introduced a new button to delete all activities from the portfolio activities page
- Added `state` to the `MarketData` database schema to distinguish `CLOSE` and `INTRADAY` in the data gathering
- Added the distance to now to the subscription expiration date in the users table of the admin control panel
## 1.260.0 - 2023-04-23
### Added
- Added `dataSource` as a unique constraint to the `MarketData` database schema
### Fixed
- Removed the unnecessary sort header of the comment column in the historical market data table of the admin control panel
## 1.259.0 - 2023-04-22
### Added
- Added a fallback to historical market data if a data provider does not provide live data
- Added a general health check endpoint
- Added health check endpoints for data providers
### Changed
- Persisted today's market data continuously
### Fixed
- Fixed the alignment of the performance column header in the holdings table
- Removed the unnecessary sort header of the comment column in the activities table
- Fixed the targets in `proxy.conf.json` from `http://localhost:3333` to `http://0.0.0.0:3333` for local development
## 1.258.0 - 2023-04-20
### Added
- Introduced a data source mapping
## 1.257.0 - 2023-04-18
### Added
- Introduced the allocations by ETF provider chart on the allocations page
### Fixed
- Fixed an issue in the global heat map component caused by manipulating an input property
- Fixed an issue with the currency inconsistency in the _EOD Historical Data_ service (convert from `GBX` to `GBp`)
## 1.256.0 - 2023-04-17
### Added
- Added the _Yahoo Finance_ data enhancer for countries, sectors and urls
### Changed
- Enabled the configuration to immediately remove queue jobs on complete
- Refactored the implementation of removing queue jobs
### Fixed
- Fixed the unique job ids of the gather asset profile process
- Fixed the style of the button to fetch the current market price
## 1.255.0 - 2023-04-15
### Added
- Made the system message expandable
### Changed
- Skipped creating queue jobs for asset profiles with `MANUAL` data source not having a scraper configuration
- Reduced the execution interval of the data gathering to every hour
- Upgraded `prisma` from version `4.11.0` to `4.12.0`
### Fixed
- Improved the style of the system message
## 1.254.0 - 2023-04-14
### Changed
- Improved the queue jobs implementation by adding in bulk
- Improved the queue jobs implementation by introducing unique job ids
- Reverted the execution interval of the data gathering from every 12 hours to every 4 hours
## 1.253.0 - 2023-04-14
### Changed
- Reduced the execution interval of the data gathering to every 12 hours
### Fixed
- Fixed the background color of dialogs in dark mode
## 1.252.2 - 2023-04-11
### Changed
- Deprecated the `auth` endpoint of the login with _Security Token_ (`GET`)
## 1.252.1 - 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`
- Upgraded `bull` from version `4.10.2` to `4.10.4`
## 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 `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 ## 1.221.0 - 2022-12-26
### Added ### Added
@ -493,7 +1083,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
@ -1106,7 +1696,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
@ -1531,7 +2121,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

31
DEVELOPMENT.md Normal file
View File

@ -0,0 +1,31 @@
# 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
#### Synchronize schema with database for prototyping
Run `yarn database:push`
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
#### Create schema migration
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" ]

108
README.md
View File

@ -1,34 +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="https://www.buymeacoffee.com/ghostfolio">
<img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee"/></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="#contributing"> [![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing)
<img src="https://img.shields.io/badge/Contributions-Welcome-orange.svg"/></a> [![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
</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. The software is designed for personal use in continuous operation. **Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;"> <div align="center">
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a> [<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> </div>
## Ghostfolio Premium ## Ghostfolio Premium
@ -48,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
@ -63,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
@ -81,24 +75,19 @@ 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) |
@ -116,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
@ -135,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
@ -158,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_
@ -210,7 +200,9 @@ Set the header for each request as follows:
"Authorization": "Bearer eyJh..." "Authorization": "Bearer eyJh..."
``` ```
You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`. You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
### Import Activities ### Import Activities
@ -276,12 +268,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) © 2021 - 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': { transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.spec.json' tsconfig: '<rootDir>/tsconfig.spec.json'
} }
}, ]
transform: {
'^.+\\.[tj]s$': 'ts-jest'
}, },
moduleFileExtensions: ['ts', 'js', 'html'], moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api', coverageDirectory: '../../coverage/apps/api',

View File

@ -1,4 +1,4 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AccessController } from './access.controller'; import { AccessController } from './access.controller';

View File

@ -1,4 +1,4 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccessWithGranteeUser } from '@ghostfolio/common/types'; import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Access, Prisma } from '@prisma/client'; import { Access, Prisma } from '@prisma/client';

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 { import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
nullifyValuesInObject, import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
nullifyValuesInObjects
} from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
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,87 +82,36 @@ 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(impersonationId);
impersonationId,
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 =
await this.impersonationService.validateImpersonationId( await this.impersonationService.validateImpersonationId(impersonationId);
impersonationId,
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

@ -1,11 +1,11 @@
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.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 { 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/configuration.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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AccountController } from './account.controller'; import { AccountController } from './account.controller';

View File

@ -1,5 +1,5 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Account, Order, Platform, Prisma } from '@prisma/client'; import { Account, Order, Platform, Prisma } from '@prisma/client';

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

@ -1,5 +1,5 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { import {
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
@ -100,16 +100,21 @@ export class AdminController {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) { await this.dataGatheringService.addJobsToQueue(
await this.dataGatheringService.addJobToQueue( uniqueAssets.map(({ dataSource, symbol }) => {
GATHER_ASSET_PROFILE_PROCESS, return {
{ data: {
dataSource, dataSource,
symbol symbol
}, },
GATHER_ASSET_PROFILE_PROCESS_OPTIONS name: GATHER_ASSET_PROFILE_PROCESS,
); opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
} }
};
})
);
this.dataGatheringService.gatherMax(); this.dataGatheringService.gatherMax();
} }
@ -131,16 +136,21 @@ export class AdminController {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) { await this.dataGatheringService.addJobsToQueue(
await this.dataGatheringService.addJobToQueue( uniqueAssets.map(({ dataSource, symbol }) => {
GATHER_ASSET_PROFILE_PROCESS, return {
{ data: {
dataSource, dataSource,
symbol symbol
}, },
GATHER_ASSET_PROFILE_PROCESS_OPTIONS name: GATHER_ASSET_PROFILE_PROCESS,
); opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
} }
};
})
);
} }
@Post('gather/profile-data/:dataSource/:symbol') @Post('gather/profile-data/:dataSource/:symbol')
@ -161,14 +171,17 @@ export class AdminController {
); );
} }
await this.dataGatheringService.addJobToQueue( await this.dataGatheringService.addJobToQueue({
GATHER_ASSET_PROFILE_PROCESS, data: {
{
dataSource, dataSource,
symbol symbol
}, },
GATHER_ASSET_PROFILE_PROCESS_OPTIONS name: GATHER_ASSET_PROFILE_PROCESS,
); opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
}
});
} }
@Post('gather/:dataSource/:symbol') @Post('gather/:dataSource/:symbol')
@ -304,9 +317,10 @@ export class AdminController {
const date = new Date(dateString); const date = new Date(dateString);
return this.marketDataService.updateMarketData({ return this.marketDataService.updateMarketData({
data: { ...data, dataSource }, data: { marketPrice: data.marketPrice, state: 'CLOSE' },
where: { where: {
date_symbol: { dataSource_date_symbol: {
dataSource,
date, date,
symbol symbol
} }

View File

@ -1,12 +1,12 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller'; import { AdminController } from './admin.controller';

View File

@ -1,10 +1,10 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
@ -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,4 +1,4 @@
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { QueueController } from './queue.controller'; import { QueueController } from './queue.controller';

View File

@ -4,7 +4,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces'; import { AdminJobs } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JobStatus, Queue } from 'bull'; import { JobStatus, Queue } from 'bull';
@Injectable() @Injectable()
@ -23,14 +23,11 @@ export class QueueService {
}: { }: {
status?: JobStatus[]; status?: JobStatus[];
}) { }) {
const jobs = await this.dataGatheringQueue.getJobs(status); for (const statusItem of status) {
await this.dataGatheringQueue.clean(
for (const job of jobs) { 300,
try { statusItem === 'waiting' ? 'wait' : statusItem
await job.remove(); );
} catch (error) {
Logger.warn(error, 'QueueService');
}
} }
} }
@ -44,7 +41,12 @@ export class QueueService {
const jobs = await this.dataGatheringQueue.getJobs(status); const jobs = await this.dataGatheringQueue.getJobs(status);
const jobsWithState = await Promise.all( const jobsWithState = await Promise.all(
jobs.slice(0, limit).map(async (job) => { jobs
.filter((job) => {
return job;
})
.slice(0, limit)
.map(async (job) => {
return { return {
attemptsMade: job.attemptsMade + 1, attemptsMade: job.attemptsMade + 1,
data: job.data, data: job.data,

View File

@ -1,4 +1,4 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { Controller } from '@nestjs/common'; import { Controller } from '@nestjs/common';
@Controller() @Controller()

View File

@ -1,13 +1,11 @@
import { join } from 'path'; import { join } from 'path';
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.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';
@ -19,17 +17,20 @@ 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';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { FrontendMiddleware } from './frontend.middleware'; import { FrontendMiddleware } from './frontend.middleware';
import { HealthModule } from './health/health.module';
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 { LogoModule } from './logo/logo.module';
import { OrderModule } from './order/order.module'; import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SubscriptionModule } from './subscription/subscription.module'; import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@ -45,7 +46,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
} }
}), }),
@ -57,6 +58,7 @@ import { UserModule } from './user/user.module';
ExchangeRateModule, ExchangeRateModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ExportModule, ExportModule,
HealthModule,
ImportModule, ImportModule,
InfoModule, InfoModule,
LogoModule, LogoModule,

View File

@ -1,7 +1,7 @@
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller'; import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AuthDevice, Prisma } from '@prisma/client'; import { AuthDevice, Prisma } from '@prisma/client';

View File

@ -1,5 +1,5 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces'; import { OAuthResponse } from '@ghostfolio/common/interfaces';
import { import {
@ -33,8 +33,11 @@ export class AuthController {
private readonly webAuthService: WebAuthService private readonly webAuthService: WebAuthService
) {} ) {}
/**
* @deprecated
*/
@Get('anonymous/:accessToken') @Get('anonymous/:accessToken')
public async accessTokenLogin( public async accessTokenLoginGet(
@Param('accessToken') accessToken: string @Param('accessToken') accessToken: string
): Promise<OAuthResponse> { ): Promise<OAuthResponse> {
try { try {
@ -50,6 +53,23 @@ export class AuthController {
} }
} }
@Post('anonymous')
public async accessTokenLogin(
@Body() body: { accessToken: string }
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateAnonymousLogin(
body.accessToken
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
@Get('google') @Get('google')
@UseGuards(AuthGuard('google')) @UseGuards(AuthGuard('google'))
public googleLogin() { public googleLogin() {
@ -81,13 +101,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

@ -2,8 +2,8 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
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/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.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';

View File

@ -1,5 +1,5 @@
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/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.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';
@ -61,8 +61,10 @@ export class AuthService {
// Create new user if not found // Create new user if not found
user = await this.userService.createUser({ user = await this.userService.createUser({
data: {
provider, provider,
thirdPartyId: principalId thirdPartyId: principalId
}
}); });
} }
@ -96,8 +98,10 @@ export class AuthService {
// Create new user if not found // Create new user if not found
user = await this.userService.createUser({ user = await this.userService.createUser({
data: {
provider, provider,
thirdPartyId thirdPartyId
}
}); });
} }

View File

@ -1,4 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';

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/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/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) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
await this.prismaService.analytics.upsert({ await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } }, create: { country, User: { connect: { id: user.id } } },
update: { activityCount: { increment: 1 }, updatedAt: new Date() }, update: {
country,
activityCount: { increment: 1 },
updatedAt: new Date()
},
where: { userId: user.id } where: { userId: user.id }
}); });
}
return user; return user;
} else { } else {

View File

@ -1,7 +1,7 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
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/configuration.service';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Inject, Inject,

View File

@ -1,10 +1,10 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { BenchmarkController } from './benchmark.controller'; import { BenchmarkController } from './benchmark.controller';

View File

@ -1,9 +1,9 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
MAX_CHART_ITEMS, MAX_CHART_ITEMS,
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS

View File

@ -1,10 +1,10 @@
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/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CacheController } from './cache.controller'; import { CacheController } from './cache.controller';

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,4 +1,4 @@
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ExchangeRateController } from './exchange-rate.controller'; import { ExchangeRateController } from './exchange-rate.controller';

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

@ -1,8 +1,8 @@
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/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ExportController } from './export.controller'; import { ExportController } from './export.controller';

View File

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

View File

@ -1,11 +1,11 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Injectable, NestMiddleware } from '@nestjs/common'; import { Injectable, NestMiddleware } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
@ -14,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'),
@ -41,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'
@ -49,6 +47,10 @@ export class FrontendMiddleware implements NestMiddleware {
this.getPathOfIndexHtmlFile('nl'), this.getPathOfIndexHtmlFile('nl'),
'utf8' 'utf8'
); );
this.indexHtmlPt = fs.readFileSync(
this.getPathOfIndexHtmlFile('pt'),
'utf8'
);
} catch {} } catch {}
} }
@ -73,12 +75,31 @@ export class FrontendMiddleware implements NestMiddleware {
) { ) {
featureGraphicPath = 'assets/images/blog/20221226.jpg'; featureGraphicPath = 'assets/images/blog/20221226.jpg';
title = `The importance of tracking your personal finances - ${title}`; title = `The importance of tracking your personal finances - ${title}`;
} else if (
request.path.startsWith(
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt'
)
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
} else if (
request.path.startsWith('/en/blog/2023/02/ghostfolio-meets-umbrel')
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
title = `Ghostfolio meets Umbrel - ${title}`;
} 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 (
request.path.startsWith('/api/') || request.path.startsWith('/api/') ||
this.isFileRequest(request.url) || this.isFileRequest(request.url) ||
!this.isProduction !environment.production
) { ) {
// Skip // Skip
next(); next();
@ -104,6 +125,15 @@ export class FrontendMiddleware implements NestMiddleware {
rootUrl: this.configurationService.get('ROOT_URL') rootUrl: this.configurationService.get('ROOT_URL')
}) })
); );
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
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/')) { } else if (request.path === '/it' || request.path.startsWith('/it/')) {
response.send( response.send(
this.interpolate(this.indexHtmlIt, { this.interpolate(this.indexHtmlIt, {
@ -126,6 +156,15 @@ export class FrontendMiddleware implements NestMiddleware {
rootUrl: this.configurationService.get('ROOT_URL') 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')
})
);
} else { } else {
response.send( response.send(
this.interpolate(this.indexHtmlEn, { this.interpolate(this.indexHtmlEn, {

View File

@ -0,0 +1,44 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import {
Controller,
Get,
HttpException,
Param,
UseInterceptors
} from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { HealthService } from './health.service';
@Controller('health')
export class HealthController {
public constructor(private readonly healthService: HealthService) {}
@Get()
public async getHealth() {}
@Get('data-provider/:dataSource')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getHealthOfDataProvider(
@Param('dataSource') dataSource: DataSource
) {
if (!DataSource[dataSource]) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const hasResponse = await this.healthService.hasResponseFromDataProvider(
dataSource
);
if (hasResponse !== true) {
throw new HttpException(
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
StatusCodes.SERVICE_UNAVAILABLE
);
}
}
}

View File

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

View File

@ -0,0 +1,14 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@Injectable()
export class HealthService {
public constructor(
private readonly dataProviderService: DataProviderService
) {}
public async hasResponseFromDataProvider(aDataSource: DataSource) {
return this.dataProviderService.checkQuote(aDataSource);
}
}

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,18 +1,25 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; 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/configuration.service';
import { ImportResponse } from '@ghostfolio/common/interfaces'; import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Get,
HttpException, HttpException,
Inject, Inject,
Logger, Logger,
Param,
Post, Post,
Query, Query,
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';
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';
@ -32,7 +39,13 @@ export class ImportController {
@Body() importData: ImportDataDto, @Body() importData: ImportDataDto,
@Query('dryRun') isDryRun?: boolean @Query('dryRun') isDryRun?: boolean
): Promise<ImportResponse> { ): Promise<ImportResponse> {
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) { if (
!hasPermission(
this.request.user.permissions,
permissions.createAccount
) ||
!hasPermission(this.request.user.permissions, permissions.createOrder)
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -54,9 +67,10 @@ export class ImportController {
try { try {
const activities = await this.importService.import({ const activities = await this.importService.import({
maxActivitiesToImport,
isDryRun, isDryRun,
maxActivitiesToImport,
userCurrency, userCurrency,
accountsDto: importData.accounts ?? [],
activitiesDto: importData.activities, activitiesDto: importData.activities,
userId: this.request.user.id userId: this.request.user.id
}); });
@ -74,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,12 +1,15 @@
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/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PlatformModule } from '@ghostfolio/api/services/platform/platform.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ImportController } from './import.controller'; import { ImportController } from './import.controller';
@ -22,8 +25,11 @@ import { ImportService } from './import.service';
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
OrderModule, OrderModule,
PlatformModule,
PortfolioModule,
PrismaModule, PrismaModule,
RedisCacheModule RedisCacheModule,
SymbolProfileModule
], ],
providers: [ImportService] providers: [ImportService]
}) })

View File

@ -1,11 +1,21 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { PlatformService } from '@ghostfolio/api/services/platform/platform.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/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 { Prisma, SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns'; import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -16,22 +26,160 @@ export class ImportService {
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService 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({
accountsDto,
activitiesDto, activitiesDto,
isDryRun = false, isDryRun = false,
maxActivitiesToImport, maxActivitiesToImport,
userCurrency, userCurrency,
userId userId
}: { }: {
accountsDto: Partial<CreateAccountDto>[];
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
isDryRun?: boolean; isDryRun?: boolean;
maxActivitiesToImport: number; maxActivitiesToImport: number;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<Activity[]> { }): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {};
if (!isDryRun && accountsDto?.length) {
const [existingAccounts, 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) { for (const activity of activitiesDto) {
if (!activity.dataSource) { if (!activity.dataSource) {
if (activity.type === 'ITEM') { if (activity.type === 'ITEM') {
@ -40,20 +188,33 @@ 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({
activitiesDto, 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[] = []; const activities: Activity[] = [];
for (const { for (const {
@ -69,11 +230,15 @@ export class ImportService {
unitPrice unitPrice
} of activitiesDto) { } of activitiesDto) {
const date = parseISO(<string>(<unknown>dateString)); const date = parseISO(<string>(<unknown>dateString));
const validatedAccountId = accountIds.includes(accountId) const validatedAccount = accounts.find(({ id }) => {
? accountId return id === accountId;
: undefined; });
let order: OrderWithAccount; let order:
| OrderWithAccount
| (Omit<OrderWithAccount, 'Account'> & {
Account?: { id: string; name: string };
});
if (isDryRun) { if (isDryRun) {
order = { order = {
@ -84,7 +249,7 @@ export class ImportService {
type, type,
unitPrice, unitPrice,
userId, userId,
accountId: validatedAccountId, accountId: validatedAccount?.id,
accountUserId: undefined, accountUserId: undefined,
createdAt: new Date(), createdAt: new Date(),
id: uuidv4(), id: uuidv4(),
@ -99,13 +264,16 @@ export class ImportService {
countries: null, countries: null,
createdAt: undefined, createdAt: undefined,
id: undefined, id: undefined,
isin: null,
name: null, name: null,
scraperConfiguration: null, scraperConfiguration: null,
sectors: null, sectors: null,
symbolMapping: null, symbolMapping: null,
updatedAt: undefined, updatedAt: undefined,
url: null url: null,
...assetProfiles[symbol]
}, },
Account: validatedAccount,
symbolProfileId: undefined, symbolProfileId: undefined,
updatedAt: new Date() updatedAt: new Date()
}; };
@ -118,7 +286,7 @@ export class ImportService {
type, type,
unitPrice, unitPrice,
userId, userId,
accountId: validatedAccountId, accountId: validatedAccount?.id,
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: { create: {
@ -140,6 +308,7 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
//@ts-ignore
activities.push({ activities.push({
...order, ...order,
value, value,
@ -159,6 +328,16 @@ export class ImportService {
return activities; 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({
activitiesDto, activitiesDto,
maxActivitiesToImport, maxActivitiesToImport,
@ -172,6 +351,9 @@ export class ImportService {
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' },
@ -200,22 +382,28 @@ export class ImportService {
} }
if (dataSource !== 'MANUAL') { if (dataSource !== 'MANUAL') {
const quotes = await this.dataProviderService.getQuotes([ const assetProfile = (
await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol } { 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

@ -1,12 +1,12 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.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/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';

View File

@ -1,14 +1,14 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { 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_IS_USER_SIGNUP_ENABLED,
PROPERTY_SLACK_COMMUNITY_USERS, PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG, PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
@ -22,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';
@ -59,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
); );
@ -72,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
@ -93,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');
} }
@ -120,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()
@ -248,12 +247,20 @@ export class InfoService {
)) as string; )) as string;
} }
private getDemoAuthToken() { private async getDemoAuthToken() {
const demoUserId = (await this.propertyService.getByKey(
PROPERTY_DEMO_USER_ID
)) as string;
if (demoUserId) {
return this.jwtService.sign({ return this.jwtService.sign({
id: DEMO_USER_ID id: demoUserId
}); });
} }
return undefined;
}
private async getStatistics() { private async getStatistics() {
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) { if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
return undefined; return undefined;
@ -298,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

@ -1,5 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { LogoController } from './logo.controller'; import { LogoController } from './logo.controller';

View File

@ -1,4 +1,4 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';

View File

@ -2,7 +2,8 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
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/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 {
@ -40,6 +41,23 @@ export class OrderController {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Delete()
@UseGuards(AuthGuard('jwt'))
public async deleteOrders(): Promise<number> {
if (
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.orderService.deleteOrders({
userId: this.request.user.id
});
}
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
@ -66,7 +84,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
@ -78,10 +96,7 @@ export class OrderController {
}); });
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId( await this.impersonationService.validateImpersonationId(impersonationId);
impersonationId,
this.request.user.id
);
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
const activities = await this.orderService.getOrders({ const activities = await this.orderService.getOrders({

View File

@ -3,13 +3,13 @@ import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { OrderController } from './order.controller'; import { OrderController } from './order.controller';

View File

@ -1,8 +1,8 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
@ -76,22 +76,20 @@ export class OrderService {
userId: string; userId: string;
} }
): Promise<Order> { ): Promise<Order> {
const defaultAccount = ( let Account;
await this.accountService.getAccounts(data.userId)
).find((account) => {
return account.isDefault === true;
});
const tags = data.tags ?? []; if (data.accountId) {
Account = {
let Account = {
connect: { connect: {
id_userId: { id_userId: {
userId: data.userId, userId: data.userId,
id: data.accountId ?? defaultAccount?.id id: data.accountId
} }
} }
}; };
}
const tags = data.tags ?? [];
if (data.type === 'ITEM') { if (data.type === 'ITEM') {
const assetClass = data.assetClass; const assetClass = data.assetClass;
@ -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,19 +110,19 @@ 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({
GATHER_ASSET_PROFILE_PROCESS, data: {
{
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
}, },
GATHER_ASSET_PROFILE_PROCESS_OPTIONS name: GATHER_ASSET_PROFILE_PROCESS,
); opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}`
}
});
const isDraft = isAfter(data.date as Date, endOfToday()); const isDraft = isAfter(data.date as Date, endOfToday());
@ -184,6 +181,14 @@ export class OrderService {
return order; return order;
} }
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> {
const { count } = await this.prismaService.order.deleteMany({
where
});
return count;
}
public async getOrders({ public async getOrders({
filters, filters,
includeDrafts = false, includeDrafts = false,

View File

@ -2,6 +2,7 @@ import { parseDate, resetHours } from '@ghostfolio/common/helper';
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';
import { GetValuesObject } from './interfaces/get-values-object.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) { function mockGetValue(symbol: string, date: Date) {
@ -48,8 +49,9 @@ export const CurrentRateServiceMock = {
getValues: ({ getValues: ({
dataGatheringItems, dataGatheringItems,
dateQuery dateQuery
}: GetValuesParams): Promise<GetValueObject[]> => { }: GetValuesParams): Promise<GetValuesObject> => {
const result: GetValueObject[] = []; const values: GetValueObject[] = [];
if (dateQuery.lt) { if (dateQuery.lt) {
for ( for (
let date = resetHours(dateQuery.gte); let date = resetHours(dateQuery.gte);
@ -57,7 +59,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 +72,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 +83,7 @@ export const CurrentRateServiceMock = {
} }
} }
} }
return Promise.resolve(result);
return Promise.resolve({ values, dataProviderInfos: [], errors: [] });
} }
}; };

View File

@ -1,12 +1,13 @@
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/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValuesObject } from './interfaces/get-values-object.interface';
jest.mock('@ghostfolio/api/services/market-data.service', () => { jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
return { return {
MarketDataService: jest.fn().mockImplementation(() => { MarketDataService: jest.fn().mockImplementation(() => {
return { return {
@ -17,7 +18,8 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
createdAt: date, createdAt: date,
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584', id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
marketPrice: 1847.839966 marketPrice: 1847.839966,
state: 'CLOSE'
}); });
}, },
getRange: ({ getRange: ({
@ -36,6 +38,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
date: dateRangeStart, date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d', id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902, marketPrice: 1841.823902,
state: 'CLOSE',
symbol: symbols[0] symbol: symbols[0]
}, },
{ {
@ -44,6 +47,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
date: dateRangeEnd, date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56', id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966, marketPrice: 1847.839966,
state: 'CLOSE',
symbol: symbols[0] symbol: symbols[0]
} }
]); ]);
@ -53,7 +57,9 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
}; };
}); });
jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => { jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return { return {
ExchangeRateDataService: jest.fn().mockImplementation(() => { ExchangeRateDataService: jest.fn().mockImplementation(() => {
return { return {
@ -64,6 +70,17 @@ jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
}; };
}) })
}; };
}
);
jest.mock('@ghostfolio/api/services/property/property.service', () => {
return {
PropertyService: jest.fn().mockImplementation(() => {
return {
getByKey: (key: string) => Promise.resolve({})
};
})
};
}); });
describe('CurrentRateService', () => { describe('CurrentRateService', () => {
@ -71,9 +88,18 @@ describe('CurrentRateService', () => {
let dataProviderService: DataProviderService; let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let marketDataService: MarketDataService; let marketDataService: MarketDataService;
let propertyService: PropertyService;
beforeAll(async () => { beforeAll(async () => {
dataProviderService = new DataProviderService(null, [], null); propertyService = new PropertyService(null);
dataProviderService = new DataProviderService(
null,
[],
null,
null,
propertyService
);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,
null, null,
@ -103,17 +129,16 @@ describe('CurrentRateService', () => {
}, },
userCurrency: 'CHF' userCurrency: 'CHF'
}) })
).toMatchObject<GetValueObject[]>([ ).toMatchObject<GetValuesObject>({
dataProviderInfos: [],
errors: [],
values: [
{ {
date: undefined, date: undefined,
marketPriceInBaseCurrency: 1841.823902, marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN' symbol: 'AMZN'
},
{
date: undefined,
marketPriceInBaseCurrency: 1847.839966,
symbol: 'AMZN'
} }
]); ]
});
}); });
}); });

View File

@ -1,12 +1,14 @@
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/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { DataProviderInfo, ResponseError } 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, isEmpty, uniqBy } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface';
@Injectable() @Injectable()
@ -22,34 +24,52 @@ export class CurrentRateService {
dataGatheringItems, dataGatheringItems,
dateQuery, dateQuery,
userCurrency userCurrency
}: GetValuesParams): Promise<GetValueObject[]> { }: GetValuesParams): Promise<GetValuesObject> {
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())) &&
(!dateQuery.in || this.containsToday(dateQuery.in)); (!dateQuery.in || this.containsToday(dateQuery.in));
const promises: Promise<GetValueObject[]>[] = []; const promises: Promise<GetValueObject[]>[] = [];
const quoteErrors: ResponseError['errors'] = [];
const today = resetHours(new Date());
if (includeToday) { if (includeToday) {
const today = resetHours(new Date());
promises.push( promises.push(
this.dataProviderService this.dataProviderService
.getQuotes(dataGatheringItems) .getQuotes(dataGatheringItems)
.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
);
}
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
result.push({ result.push({
date: today, date: today,
marketPriceInBaseCurrency: marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
dataResultProvider?.[dataGatheringItem.symbol] dataResultProvider?.[dataGatheringItem.symbol]
?.marketPrice ?? 0, ?.marketPrice,
dataResultProvider?.[dataGatheringItem.symbol]?.currency, dataResultProvider?.[dataGatheringItem.symbol]?.currency,
userCurrency userCurrency
), ),
symbol: dataGatheringItem.symbol symbol: dataGatheringItem.symbol
}); });
} else {
quoteErrors.push({
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
});
} }
}
return result; return result;
}) })
); );
@ -81,7 +101,60 @@ export class CurrentRateService {
}) })
); );
return flatten(await Promise.all(promises)); const values = flatten(await Promise.all(promises));
const response: GetValuesObject = {
dataProviderInfos,
errors: quoteErrors.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`)
};
if (!isEmpty(quoteErrors)) {
for (const { symbol } of quoteErrors) {
try {
// If missing quote, fallback to the latest available historical market price
let value: GetValueObject = response.values.find((currentValue) => {
return currentValue.symbol === symbol && isToday(currentValue.date);
});
if (!value) {
value = {
symbol,
date: today,
marketPriceInBaseCurrency: 0
};
response.values.push(value);
}
const [latestValue] = response.values
.filter((currentValue) => {
return (
currentValue.symbol === symbol &&
currentValue.marketPriceInBaseCurrency
);
})
.sort((a, b) => {
if (a.date < b.date) {
return 1;
}
if (a.date > b.date) {
return -1;
}
return 0;
});
value.marketPriceInBaseCurrency =
latestValue.marketPriceInBaseCurrency;
} catch {}
}
}
return response;
} }
private containsToday(dates: Date[]): boolean { private containsToday(dates: Date[]): boolean {

View File

@ -0,0 +1,9 @@
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
import { GetValueObject } from './get-value-object.interface';
export interface GetValuesObject {
dataProviderInfos: DataProviderInfo[];
errors: ResponseError['errors'];
values: GetValueObject[];
}

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';
@ -19,9 +24,10 @@ import {
isSameYear, isSameYear,
max, max,
min, min,
set set,
subDays
} from 'date-fns'; } from 'date-fns';
import { first, flatten, isNumber, last, sortBy } from 'lodash'; import { first, flatten, isNumber, last, sortBy, uniq } from 'lodash';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface'; import { CurrentPositions } from './interfaces/current-positions.interface';
@ -44,6 +50,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 +183,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,7 +208,8 @@ export class PortfolioCalculator {
symbols[item.symbol] = true; symbols[item.symbol] = true;
} }
const marketSymbols = await this.currentRateService.getValues({ const { dataProviderInfos, values: marketSymbols } =
await this.currentRateService.getValues({
currencies, currencies,
dataGatheringItems, dataGatheringItems,
dateQuery: { dateQuery: {
@ -210,6 +218,8 @@ export class PortfolioCalculator {
userCurrency: this.currency userCurrency: this.currency
}); });
this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: { const marketSymbolMap: {
[date: string]: { [symbol: string]: Big }; [date: string]: { [symbol: string]: Big };
} = {}; } = {};
@ -226,25 +236,31 @@ 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,
investmentValues,
maxInvestmentValues,
netPerformanceValues
} = this.getSymbolMetrics({
end, end,
marketSymbolMap, marketSymbolMap,
start, start,
@ -253,60 +269,67 @@ export class PortfolioCalculator {
isChartMode: true 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()
}; };
}); });
} }
@ -338,7 +361,7 @@ export class PortfolioCalculator {
let firstTransactionPoint: TransactionPoint = null; let firstTransactionPoint: TransactionPoint = null;
let firstIndex = transactionPointsBeforeEndDate.length; let firstIndex = transactionPointsBeforeEndDate.length;
const dates = []; let dates = [];
const dataGatheringItems: IDataGatheringItem[] = []; const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {}; const currencies: { [symbol: string]: string } = {};
@ -367,7 +390,30 @@ export class PortfolioCalculator {
dates.push(resetHours(end)); dates.push(resetHours(end));
const marketSymbols = await this.currentRateService.getValues({ // Add dates of last week for fallback
dates.push(subDays(resetHours(new Date()), 7));
dates.push(subDays(resetHours(new Date()), 6));
dates.push(subDays(resetHours(new Date()), 5));
dates.push(subDays(resetHours(new Date()), 4));
dates.push(subDays(resetHours(new Date()), 3));
dates.push(subDays(resetHours(new Date()), 2));
dates.push(subDays(resetHours(new Date()), 1));
dates.push(resetHours(new Date()));
dates = uniq(
dates.map((date) => {
return date.getTime();
})
).map((timestamp) => {
return new Date(timestamp);
});
dates.sort((a, b) => a.getTime() - b.getTime());
const {
dataProviderInfos,
errors: currentRateErrors,
values: marketSymbols
} = await this.currentRateService.getValues({
currencies, currencies,
dataGatheringItems, dataGatheringItems,
dateQuery: { dateQuery: {
@ -376,6 +422,8 @@ export class PortfolioCalculator {
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 +478,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 +495,13 @@ export class PortfolioCalculator {
transactionCount: item.transactionCount transactionCount: item.transactionCount
}); });
if (hasErrors) { if (
(hasErrors ||
currentRateErrors.find(({ dataSource, symbol }) => {
return dataSource === item.dataSource && symbol === item.symbol;
})) &&
item.investment.gt(0)
) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol }); errors.push({ dataSource: item.dataSource, symbol: item.symbol });
} }
} }
@ -461,6 +516,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 +537,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
}); });
} }
} }
@ -678,7 +751,7 @@ export class PortfolioCalculator {
); );
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
Logger.warn( Logger.warn(
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`, `Missing historical market data for symbol ${currentPosition.symbol}`,
'PortfolioCalculator' 'PortfolioCalculator'
); );
hasErrors = true; hasErrors = true;
@ -732,7 +805,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 +814,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 +948,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 +992,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 +1211,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 +1309,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

@ -8,8 +8,9 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
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 { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
@ -18,7 +19,6 @@ import {
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,
@ -66,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',
@ -132,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)) {
@ -189,24 +190,25 @@ export class PortfolioController {
@Get('dividends') @Get('dividends')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getDividends( public async getDividends(
@Headers('impersonation-id') impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('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<PortfolioDividends> { ): Promise<PortfolioDividends> {
let dividends: InvestmentItem[]; const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
if (groupBy === 'month') { let dividends = await this.portfolioService.getDividends({
dividends = await this.portfolioService.getDividends({
dateRange, dateRange,
filters,
groupBy, groupBy,
impersonationId impersonationId
}); });
} else {
dividends = await this.portfolioService.getDividends({
dateRange,
impersonationId
});
}
if ( if (
impersonationId || impersonationId ||
@ -238,24 +240,25 @@ export class PortfolioController {
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getInvestments( public async getInvestments(
@Headers('impersonation-id') impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('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,
groupBy, groupBy,
impersonationId impersonationId
}); });
} else {
investments = await this.portfolioService.getInvestments({
dateRange,
impersonationId
});
}
if ( if (
impersonationId || impersonationId ||
@ -289,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
}); });
@ -311,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()
}; };
@ -345,31 +358,26 @@ 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 (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
result.positions = result.positions.map((position) => {
return nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'netPerformance',
'quantity'
]);
}); });
}
return result; return this.portfolioService.getPositions({
dateRange,
filters,
impersonationId
});
} }
@Get('public/:accessId') @Get('public/:accessId')
@ -420,7 +428,7 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPublicDetails.holdings[symbol] = { portfolioPublicDetails.holdings[symbol] = {
allocationCurrent: portfolioPosition.value / totalValue, allocationInPercentage: portfolioPosition.value / totalValue,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined, currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource, dataSource: portfolioPosition.dataSource,
@ -431,7 +439,7 @@ export class PortfolioController {
sectors: hasDetails ? portfolioPosition.sectors : [], sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol, symbol: portfolioPosition.symbol,
url: portfolioPosition.url, url: portfolioPosition.url,
value: portfolioPosition.value / totalValue valueInPercentage: portfolioPosition.value / totalValue
}; };
} }
@ -439,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;
} }
@ -480,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

@ -3,14 +3,14 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';

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';
@ -13,17 +14,17 @@ import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/ap
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment'; import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/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';
@ -210,16 +211,19 @@ export class PortfolioService {
public async getDividends({ 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[]> { }): Promise<InvestmentItem[]> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const activities = await this.orderService.getOrders({ const activities = await this.orderService.getOrders({
filters,
userId, userId,
types: ['DIVIDEND'], types: ['DIVIDEND'],
userCurrency: this.request.user.Settings.settings.baseCurrency userCurrency: this.request.user.Settings.settings.baseCurrency
@ -232,8 +236,8 @@ export class PortfolioService {
}; };
}); });
if (groupBy === 'month') { if (groupBy) {
dividends = this.getDividendsByMonth(dividends); dividends = this.getDividendsByGroup({ dividends, groupBy });
} }
const startDate = this.getStartDate( const startDate = this.getStartDate(
@ -248,17 +252,20 @@ export class PortfolioService {
public async getInvestments({ public async getInvestments({
dateRange, dateRange,
impersonationId, filters,
groupBy groupBy,
impersonationId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
impersonationId: string; filters?: Filter[];
groupBy?: GroupBy; 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
}); });
@ -276,26 +283,31 @@ export class PortfolioService {
let investments: InvestmentItem[]; let investments: InvestmentItem[];
if (groupBy === 'month') { if (groupBy) {
investments = portfolioCalculator.getInvestmentsByMonth().map((item) => { investments = portfolioCalculator
.getInvestmentsByGroup(groupBy)
.map((item) => {
return { return {
date: item.date, date: item.date,
investment: item.investment.toNumber() 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
}); });
} }
@ -343,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;
@ -356,6 +370,7 @@ export class PortfolioService {
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters,
userId userId
}); });
@ -397,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 }> {
@ -447,10 +462,17 @@ export class PortfolioService {
}); });
const holdings: PortfolioDetails['holdings'] = {}; const holdings: PortfolioDetails['holdings'] = {};
const totalInvestmentInBaseCurrency = currentPositions.totalInvestment.plus( const totalValueInBaseCurrency = currentPositions.currentValue.plus(
cashDetails.balanceInBaseCurrency cashDetails.balanceInBaseCurrency
); );
let filteredValueInBaseCurrency = currentPositions.currentValue;
const isFilteredByAccount = filters.some((filter) => {
return filter.type === 'ACCOUNT';
});
let filteredValueInBaseCurrency = isFilteredByAccount
? totalValueInBaseCurrency
: currentPositions.currentValue;
if ( if (
filters?.length === 0 || filters?.length === 0 ||
@ -522,12 +544,9 @@ export class PortfolioService {
holdings[item.symbol] = { holdings[item.symbol] = {
markets, markets,
allocationCurrent: filteredValueInBaseCurrency.eq(0) allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0 ? 0
: value.div(filteredValueInBaseCurrency).toNumber(), : value.div(filteredValueInBaseCurrency).toNumber(),
allocationInvestment: item.investment
.div(totalInvestmentInBaseCurrency)
.toNumber(),
assetClass: symbolProfile.assetClass, assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass, assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries, countries: symbolProfile.countries,
@ -552,17 +571,14 @@ export class PortfolioService {
}; };
} }
if ( const isFilteredByCash = filters.some((filter) => {
filters?.length === 0 || return filter.type === 'ASSET_CLASS' && filter.id === 'CASH';
(filters?.length === 1 && });
filters[0].type === 'ASSET_CLASS' &&
filters[0].id === 'CASH') if (filters.length === 0 || isFilteredByCash || isFilteredByAccount) {
) {
const cashPositions = await this.getCashPositions({ const cashPositions = await this.getCashPositions({
cashDetails, cashDetails,
emergencyFund,
userCurrency, userCurrency,
investment: totalInvestmentInBaseCurrency,
value: filteredValueInBaseCurrency value: filteredValueInBaseCurrency
}); });
@ -580,10 +596,51 @@ export class PortfolioService {
withExcludedAccounts withExcludedAccounts
}); });
if (
filters?.length === 1 &&
filters[0].id === EMERGENCY_FUND_TAG_ID &&
filters[0].type === 'TAG'
) {
const emergencyFundCashPositions = 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] = {
...emergencyFundCashPositions[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 {
@ -627,6 +684,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,
@ -646,8 +706,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) => {
@ -692,12 +753,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(),
@ -731,7 +803,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
}); });
} }
@ -747,6 +820,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
); );
@ -754,12 +828,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);
@ -780,6 +856,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,
@ -836,6 +919,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,
@ -850,14 +936,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
}); });
@ -877,7 +969,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
); );
@ -885,12 +977,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([
@ -928,10 +1022,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> {
@ -941,6 +1037,7 @@ export class PortfolioService {
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters,
userId userId
}); });
@ -970,32 +1067,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
@ -1013,28 +1103,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(),
@ -1074,16 +1164,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(
@ -1107,19 +1204,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
@ -1129,7 +1226,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
@ -1140,16 +1237,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({
@ -1180,62 +1273,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
); );
}) })
@ -1245,67 +1314,118 @@ export class PortfolioService {
); );
} }
private getDividendsByMonth(aDividends: InvestmentItem[]): InvestmentItem[] { private getDividendsByGroup({
if (aDividends.length === 0) { dividends,
groupBy
}: {
dividends: InvestmentItem[];
groupBy: GroupBy;
}): InvestmentItem[] {
if (dividends.length === 0) {
return []; return [];
} }
const dividends = []; const dividendsByGroup: InvestmentItem[] = [];
let currentDate: Date; let currentDate: Date;
let investmentByMonth = new Big(0); let investmentByGroup = new Big(0);
for (const [index, dividend] of aDividends.entries()) { for (const [index, dividend] of dividends.entries()) {
if ( if (
isSameMonth(parseDate(dividend.date), currentDate) && isSameYear(parseDate(dividend.date), currentDate) &&
isSameYear(parseDate(dividend.date), currentDate) (groupBy === 'year' ||
isSameMonth(parseDate(dividend.date), currentDate))
) { ) {
// Same month: Add up divididends // Same group: Add up dividends
investmentByMonth = investmentByMonth.plus(dividend.investment); investmentByGroup = investmentByGroup.plus(dividend.investment);
} else { } else {
// New month: Store previous month and reset // New group: Store previous group and reset
if (currentDate) { if (currentDate) {
dividends.push({ dividendsByGroup.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.toNumber()
}); });
} }
currentDate = parseDate(dividend.date); currentDate = parseDate(dividend.date);
investmentByMonth = new Big(dividend.investment); investmentByGroup = new Big(dividend.investment);
} }
if (index === aDividends.length - 1) { if (index === dividends.length - 1) {
// Store current month (latest order) // Store current month (latest order)
dividends.push({ dividendsByGroup.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.toNumber()
}); });
} }
} }
return dividends; 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
); );
}) })
@ -1324,8 +1444,7 @@ export class PortfolioService {
}): PortfolioPosition { }): PortfolioPosition {
return { return {
currency, currency,
allocationCurrent: 0, allocationInPercentage: 0,
allocationInvestment: 0,
assetClass: AssetClass.CASH, assetClass: AssetClass.CASH,
assetSubClass: AssetClass.CASH, assetSubClass: AssetClass.CASH,
countries: [], countries: [],
@ -1372,26 +1491,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;
@ -1404,11 +1539,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
}); });
@ -1423,18 +1554,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(
Math.max(
emergencyFundPositionsValueInBaseCurrency,
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (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')
@ -1490,8 +1630,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
}; };
} }
@ -1508,7 +1648,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 =
@ -1581,6 +1721,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 & {
@ -1611,10 +1759,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,
@ -1634,7 +1790,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,
@ -1669,10 +1827,7 @@ export class PortfolioService {
private async getUserId(aImpersonationId: string, aUserId: string) { private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId( await this.impersonationService.validateImpersonationId(aImpersonationId);
aImpersonationId,
aUserId
);
return impersonationUserId || aUserId; return impersonationUserId || aUserId;
} }

View File

@ -1,5 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common'; import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
import * as redisStore from 'cache-manager-redis-store'; import * as redisStore from 'cache-manager-redis-store';

View File

@ -1,4 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common'; import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager'; import { Cache } from 'cache-manager';

View File

@ -1,4 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
@ -63,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
}); });
@ -116,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,5 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';

View File

@ -1,7 +1,12 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/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

@ -1,7 +1,7 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SymbolController } from './symbol.controller'; import { SymbolController } from './symbol.controller';

View File

@ -3,9 +3,10 @@ import {
IDataGatheringItem, IDataGatheringItem,
IDataProviderHistoricalResponse IDataProviderHistoricalResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/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_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config';
import { User, UserSettings } from '@ghostfolio/common/interfaces'; import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -31,7 +29,6 @@ import { UserService } from './user.service';
@Controller('user') @Controller('user')
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
@ -82,7 +79,7 @@ export class UserController {
const hasAdmin = await this.userService.hasAdmin(); const hasAdmin = await this.userService.hasAdmin();
const { accessToken, id, role } = await this.userService.createUser({ const { accessToken, id, role } = await this.userService.createUser({
role: hasAdmin ? 'USER' : 'ADMIN' data: { role: hasAdmin ? 'USER' : 'ADMIN' }
}); });
return { return {

View File

@ -1,6 +1,6 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';

View File

@ -1,19 +1,17 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { environment } from '@ghostfolio/api/environments/environment';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { 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 +95,7 @@ export class UserService {
const { const {
accessToken, accessToken,
Account, Account,
Analytics,
authChallenge, authChallenge,
createdAt, createdAt,
id, id,
@ -107,7 +106,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 +125,8 @@ export class UserService {
role, role,
Settings, Settings,
thirdPartyId, thirdPartyId,
updatedAt updatedAt,
activityCount: Analytics?.activityCount
}; };
if (user?.Settings) { if (user?.Settings) {
@ -154,16 +159,23 @@ 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')) {
if (hasRole(user, Role.ADMIN)) { if (hasRole(user, Role.ADMIN)) {
@ -185,6 +197,10 @@ export class UserService {
} }
} }
if (!environment.production && role === 'ADMIN') {
currentPermissions.push(permissions.impersonateAllUsers);
}
user.Account = sortBy(user.Account, (account) => { user.Account = sortBy(user.Account, (account) => {
return account.name; return account.name;
}); });
@ -217,7 +233,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 +262,14 @@ export class UserService {
} }
}); });
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
await this.prismaService.analytics.create({
data: {
User: { connect: { id: user.id } }
}
});
}
if (data.provider === 'ANONYMOUS') { if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken( const accessToken = this.createAccessToken(
user.id, user.id,

View File

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

View File

@ -1,3 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { decodeDataSource } from '@ghostfolio/common/helper'; import { decodeDataSource } from '@ghostfolio/common/helper';
import { import {
CallHandler, CallHandler,
@ -7,8 +8,6 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ConfigurationService } from '../services/configuration.service';
@Injectable() @Injectable()
export class TransformDataSourceInRequestInterceptor<T> export class TransformDataSourceInRequestInterceptor<T>
implements NestInterceptor<T, any> implements NestInterceptor<T, any>
@ -24,7 +23,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,5 @@
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { encodeDataSource } from '@ghostfolio/common/helper'; import { encodeDataSource } from '@ghostfolio/common/helper';
import { import {
CallHandler, CallHandler,
@ -5,12 +7,10 @@ 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';
import { ConfigurationService } from '../services/configuration.service';
@Injectable() @Injectable()
export class TransformDataSourceInResponseInterceptor<T> export class TransformDataSourceInResponseInterceptor<T>
implements NestInterceptor<T, any> implements NestInterceptor<T, any>
@ -25,68 +25,26 @@ 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(
DataSource[dataSource]
); );
return activity; return valueMap;
},
{}
)
}
],
object: data
}); });
} }
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);
}
}
}
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,13 +10,8 @@ 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']
}); });
@ -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,8 +1,7 @@
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client'; import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces';
export class Order { export class Order {
private account: Account; private account: Account;
private currency: string; private currency: string;

View File

@ -1,5 +1,5 @@
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/exchange-rate-data.service';
import { groupBy } from '@ghostfolio/common/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';

View File

@ -1,5 +1,5 @@
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/exchange-rate-data.service';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioPosition, PortfolioPosition,

View File

@ -1,5 +1,5 @@
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/exchange-rate-data.service';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioPosition, PortfolioPosition,

View File

@ -1,5 +1,5 @@
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/exchange-rate-data.service';
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces'; import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

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

@ -1,5 +1,5 @@
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/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@Module({ @Module({

View File

@ -1,9 +1,8 @@
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
import { Environment } from './interfaces/environment.interface';
@Injectable() @Injectable()
export class ConfigurationService { export class ConfigurationService {
private readonly environmentConfiguration: Environment; private readonly environmentConfiguration: Environment;
@ -19,12 +18,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 +39,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' }),

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