Compare commits

..

218 Commits

Author SHA1 Message Date
43f5bb7773 Release 2.102.0 (#3648) 2024-08-07 20:46:57 +02:00
e85cc0fcfc Feature/clone or edit activity from account detail dialog (#3647)
* Clone or edit activity from holding detail dialog

* Update changelog
2024-08-07 20:45:46 +02:00
dc1948016f Feature/clone or edit activity from holding detail dialog (#3644)
* Clone or edit activity from holding detail dialog

* Update changelog
2024-08-07 20:45:03 +02:00
4410040a14 Feature/update angular url in README.md (#3566)
Update Angular url
2024-08-06 16:53:31 +02:00
b2ed0b2c80 Feature/improve caching of benchmarks in markets overview (#3640)
* Improve caching

* Update changelog
2024-08-05 19:44:24 +02:00
42fe653e1e Bugfix/fix cache flush endpoint response (#3641)
* Fix cache flush endpoint response

* Update changelog
2024-08-05 19:43:25 +02:00
8a81fa814f Feature/improve language localization for pl (#3643)
* Update translations
2024-08-04 16:52:32 +02:00
98f3fa9d7c Feature/improve language localization for de 20240804 (#3639)
* Update translations

* Update changelog
2024-08-04 09:24:48 +02:00
202e27fe25 Feature/improve language localization for polish (#3637)
* Improve language localization for Polish

* Update changelog
2024-08-04 08:56:55 +02:00
757ff527d0 Feature/extend personal finance tools 20240803 (#3634)
* Add Capitalyse
2024-08-04 08:35:01 +02:00
41f5801b5e Feature/refactor unique asset type to asset profile identifier (#3636)
* Refactoring
2024-08-04 08:27:05 +02:00
4c7657a90e Feature/upgrade nx to version 19.5.6 (#3633)
* Upgrade Nx to version 19.5.6

* Update changelog
2024-08-04 08:22:32 +02:00
aef650753e Feature/clean up activities page (#3635)
* Clean up
2024-08-04 08:18:54 +02:00
420f331be9 Release 2.101.0 (#3632) 2024-08-03 16:58:08 +02:00
e0068c4d5d Feature/harden container security following OWASP best practices (#3614)
* Harden container security

* Update changelog
2024-08-03 16:55:18 +02:00
85661884a6 Release 2.100.0 (#3631) 2024-08-03 15:47:52 +02:00
8f6203d296 Feature/manage tags of holdings (#3630)
* Manage tags of holdings

* Update changelog
2024-08-03 15:46:01 +02:00
2fa723dc3c Bugfix/fix language selector of user account settings (#3613)
Fix value of Català
2024-08-03 15:42:21 +02:00
a500fb72c5 Feature/refactor Angular Material theme (#3629) 2024-08-02 20:57:03 +02:00
02db0db733 Feature/persist view mode of holdings tab on home page (#3624)
* Persist view mode of holdings in user settings

* Update changelog
2024-08-02 20:27:58 +02:00
c87b08ca8b Feature/improve language localization for es (#3625)
* Update translations

* Update changelog
2024-08-01 20:28:42 +02:00
fcc2ab1a48 Feature/change color assignment by annualized performance in treemap chart (#3617)
* Change color assignment to annualized performance

* Update changelog
2024-07-31 19:18:50 +02:00
7efda2f890 Feature/improve language localization for Catalan (#3598)
* Update translations

* Update changelog
2024-07-30 14:30:08 +02:00
3794a61d2d Release 2.99.0 (#3618) 2024-07-29 20:10:26 +02:00
c1d1ea9dde Feature/migrate from Yarn 1 (Classic) to npm (#3601)
* Migrate from yarn to npm
2024-07-29 20:08:43 +02:00
0d676a46c8 Release 2.98.0 (#3615) 2024-07-27 19:53:44 +02:00
97db144e01 Feature/skip derived currencies in get quotes of data provider service (#3610)
* Skip derived currencies

* Update changelog
2024-07-27 19:47:06 +02:00
cec55127c8 Bugix/fix dividend import from data provider for holdings without account (#3606)
* Fix dividend import for holdings without account

* Update changelog
2024-07-27 19:45:12 +02:00
f3f359bcfb Feature/Improve language localization for spanish (#3612)
* Update messages.es.xlf
2024-07-26 15:55:26 +02:00
601e6f4147 Feature/improve account selector of create or update activity dialog (#3607)
* Improve empty value of account selector

* Update changelog
2024-07-25 19:39:07 +02:00
e228b4925c Feature/update notes of personal finance tools (#3611)
* Update notes
2024-07-25 19:38:52 +02:00
62e3ffe413 Feature/upgrade prisma to version 5.17.0 (#3597)
* Upgrade prisma to version 5.17.0

* Update changelog
2024-07-24 19:28:05 +02:00
6af885fde0 Feature/improve language localization for Spanish (#3605)
* Improve language localization for Spanish

* Update changelog
2024-07-24 11:51:58 +02:00
dd15bba359 Bugfix/fix public page for non existent access (#3604)
* Handle non-existent access

* Update changelog
2024-07-23 21:00:20 +02:00
43fca7ff43 Feature/improve personal finance tools product page (#3599)
* Localize origin
* Localize regions
* Localize tags
2024-07-23 20:59:23 +02:00
faa6af5694 Feature/improve handling of numerical precision in value component (#3595)
* Improve handling of numerical precision in value component

* Update changelog
2024-07-22 19:35:25 +02:00
d2ea7a0bfb Feature/upgrade nx to version 19.5.1 (#3596)
* Upgrade angular and Nx

* Update changelog
2024-07-21 09:41:16 +02:00
3f6319e00b Feature/setup catala (#3593)
* Set up Català

* Update changelog
2024-07-20 17:13:44 +02:00
5601299648 Release 2.97.0 (#3592) 2024-07-20 11:24:47 +02:00
6060c7cfe0 Feature/upgrade prettier to version 3.3.3 (#3586)
* Upgrade prettier to version 3.3.3

* Update changelog
2024-07-20 11:18:00 +02:00
ba78c2783d Feature/improve numerical precision in holding detail dialog (#3584)
* Improve numerical precision in holding detail dialog

* Update changelog
2024-07-20 11:17:36 +02:00
48eee5f865 Feature/upgrade node.js from version 18 to 20 (#3553)
* Upgrade to Node.js 20

* Update changelog
2024-07-20 10:30:05 +02:00
f4a8acdb46 Feature/add selfh.st logo to landing page (#3582)
* Add selfh.st

* Update changelog
2024-07-20 10:13:28 +02:00
1d6ba22598 Feature/improve language localization for de (#3583)
* Update translations
2024-07-20 10:12:49 +02:00
e38be8d710 Feature/upgrade nx to version 19.4.3 (#3581)
* Upgrade Nx to version 19.4.3

* Update changelog
2024-07-19 11:56:18 +02:00
da5be3fb57 Feature/reuse open-color in portfolio proportion chart component (#3562)
* Reuse open-color
2024-07-18 10:14:12 +02:00
b5317a7f95 Feature/improve language localization for de 20240715 (#3574)
* Update translations

* Update changelog
2024-07-17 17:37:56 +02:00
43afb16808 Feature/introduce isUsedByUsersWithSubscription flag (#3573) 2024-07-16 20:51:49 +02:00
d5c56fb16c Feature/optimize 7d data gathering by prioritization (#3575)
* Optimize 7d data gathering by prioritization

* Update changelog
2024-07-16 20:45:34 +02:00
b94c1f280b Bugfix/fix spacing on pricing page (#3571)
* Fix spacing
2024-07-16 20:42:41 +02:00
acc59866a3 Bugfix/fix table sorting of holdings (#3572)
* Hide holdings table to fix sorting

* Update changelog
2024-07-15 15:14:34 +02:00
c9fc3e402d Release 2.96.0 (#3570) 2024-07-13 20:13:53 +02:00
6c1317f978 Bugfix/fix search for holding in assistant (#3569)
* Fix search for holding

* Update changelog
2024-07-13 20:11:40 +02:00
89be438e66 Bugfix/remove show condition of experimental features setting (#3568)
* Remove show condition of experimental feature setting

* Update changelog
2024-07-13 19:02:47 +02:00
9d6214e93a Bugfix/fix fees calculation in portfolio summary (#3567)
* Fix fees calculation

* Update changelog
2024-07-13 18:24:03 +02:00
0640b24290 Feature/improve site.webmanifest (#3564)
* Separate icon purposes

* Update changelog
2024-07-13 11:40:45 +02:00
6eb9d9d973 Feature/extend personal finance tools 20240713 (#3565) 2024-07-13 11:40:29 +02:00
9ecc3176a5 Feature/improve treemap chart for holdings (#3563)
* Various improvements

* Introduce permission: accessHoldingsChart
* Improve style of toggle
* Add border radius

* Update changelog
2024-07-13 10:45:10 +02:00
96434c5a54 Release 2.95.0 (#3561) 2024-07-12 21:04:38 +02:00
4063c62a17 Feature/setup treemap chart for holdings (#3560)
* Setup treemap chart

* Update changelog
2024-07-12 21:02:12 +02:00
890c5b986c Feature/improve formatting of variables in README.md (#3546) 2024-07-10 17:22:47 +02:00
423bd92b89 Release 2.94.0 (#3556) 2024-07-09 18:44:53 +02:00
5dc331e386 Feature/improve language localization for de 20240709 (#3555)
* Update translations

* Update changelog
2024-07-09 18:43:20 +02:00
744dc51dcd Bugfix/fix pagination issue in activities endpoint by adding secondary sort criterion (#3554)
* Add id as secondary sort criterion to ensure consistent ordering

* Update changelog
2024-07-09 18:42:03 +02:00
b0c53d050a Feature/harmonize delete labels in admin market data (#3552) 2024-07-09 18:20:25 +02:00
830569b38e Release 2.93.0 (#3551) 2024-07-07 18:25:33 +02:00
35b4aef06f Feature/improve market state logic for forex in eod historical data service (#3550) 2024-07-07 18:23:51 +02:00
bc2fd9c970 Feature/add WTD and MTD to documentation (#3542) 2024-07-07 09:55:52 +02:00
c42a8aebed Feature/add platforms concept to faq page (#3549)
* Add concept of platforms

* Update changelog
2024-07-07 09:55:12 +02:00
fad1adb91b Feature/improve usability to delete currency asset profile (#3541)
* Improve usability

* Update changelog
2024-07-07 09:54:54 +02:00
9cd37f8de0 Feature/add crypto coins and stock heatmaps to resources page (#3548)
* Add heatmaps

* Crypto Coins Heatmap
* Stock Heatmap

* Update changelog
2024-07-07 09:40:55 +02:00
d49b90d7a5 Feature/refresh cryptocurrencies list 20240706 (#3544)
* Refresh cryptocurrencies list

* Update changelog
2024-07-07 09:39:29 +02:00
130a9ea062 Feature/remove obsolete version from docker compose files (#3543)
* Remove obsolete version

* Update changelog
2024-07-07 09:16:48 +02:00
ffc6309850 Feature/refactor thresholds of x ray rules (#3545)
* Refactor thresholds

* Update changelog
2024-07-07 08:25:51 +02:00
976cc7f243 Feature/upgrade nx to version 19.4.0 (#3540)
* Upgrade Nx to version 19.4.0

* Update changelog
2024-07-06 22:15:33 +02:00
7067aca04b Feature/replace twitter.com with x.com (#3535)
* Replace twitter.com with x.com
2024-07-05 17:26:12 +02:00
1c9805bb96 Feature/improve allocations by etf holding for impersonation mode (#3534)
* Improve allocations by ETF holding for impersonation mode

* Update changelog
2024-07-04 20:25:15 +02:00
8227a2d91a Feature/improve detection of json used via scraper configuration (#3539)
* Improve detection of json

* Update changelog
2024-07-03 18:16:07 +02:00
194aee97db Feature/update development instructions to control flow (#3466) 2024-07-02 11:58:13 +02:00
0f77169952 Fix wording (#3463) 2024-07-01 21:03:15 +02:00
0f8dc62c53 Release 2.92.0 (#3532) 2024-06-30 09:23:03 +02:00
554136cdcd Feature/bulk deletion for asset profiles (#3531)
* Add support for bulk deletion of asset profiles

* Update changelog
2024-06-30 09:21:04 +02:00
83b5cfff1f Feature/upgrade prisma to version 5.16.1 (#3526)
* Upgrade prisma to version 5.16.1

* Update changelog
2024-06-29 17:06:21 +02:00
dcec3accf0 Feature/improve caching of benchmarks (#3530)
* Improve caching

* Update changelog
2024-06-29 16:53:35 +02:00
f08b0b570b Feature/support derived currencies in currency validation (#3529)
* Support derived currencies in currency validation

* Update changelog
2024-06-29 16:30:40 +02:00
8386fec98a Feature/automatic deletion of unused asset profiles (#3525)
* Automatic deletion of unused asset profiles

* Update changelog
2024-06-29 10:53:25 +02:00
4d3dff3e5b Feature/extend personal finance tools 20240629 (#3528)
* Add Anlage.App

* Add Portfoloo

* Add SharesMaster

* Add Merlin

* Add Holistic

* Add AlphaTrackr

* Add Segmio
2024-06-29 10:53:08 +02:00
76890e63fa Bugfix/fix all time high in benchmarks (#3527)
* Fix all time high

* Update changelog
2024-06-29 10:03:45 +02:00
4fb2aebf4f Release 2.91.0 (#3522) 2024-06-26 20:40:29 +02:00
ed5cd3b978 Feature/upgrade angular to version 18.0.4 (#3520)
* Upgrade Angular to version 18.0.4

* Update changelog
2024-06-26 20:38:26 +02:00
469c1936b4 Bugfix/fix horizontal overflow in historical market data table of admin control panel (#3515)
* Fix horizontal overflow

* Update changelog
2024-06-26 20:38:12 +02:00
8b3cc5c11a Bugfix/fix dialog position on mobile (#3521)
* Fix dialog position on mobile

* Update changelog
2024-06-26 20:19:25 +02:00
ee086638f3 Feature/add benchmarks preset to admin control panel (#3513)
* Add benchmarks preset

* Update changelog
2024-06-26 20:18:53 +02:00
58d1abbd38 Feature/clean up imports (#3514)
* Clean up imports
2024-06-25 19:52:07 +02:00
ba979cbae2 Bugfix/fix addition of manual asset without market data (#3516)
* Provide default value

* Update changelog
2024-06-24 21:24:03 +02:00
8cda43bb63 Bugfix/persist intraday market data only if market state is open (#3509)
* Persist INTRADAY data only if market state is open

* Update changelog
2024-06-23 10:23:03 +02:00
c4499df74c Feature/add wealthy tracker (#3510)
* Add Wealthy Tracker
2024-06-23 10:22:35 +02:00
24bcc15b6a Release 2.90.0 (#3508) 2024-06-22 09:58:19 +02:00
ff121243e4 Feature/extend asset profile for currency (#3495)
* Extend asset profile for currency

* Update changelog
2024-06-22 09:54:23 +02:00
70e633b997 Feature/upgrade ngx device detector to version 8.0.0 (#3505)
* Upgrade ngx-device-detector to version 8.0.0

* Update changelog
2024-06-21 11:05:35 +02:00
0780ee4adb Feature/improve language localization for de 20240620 (#3504)
* Update translations

* Update changelog
2024-06-20 20:45:56 +02:00
09613f9324 Feature/extend self hosting faq by mobile app question (#3500)
* Add question about mobile app

* Update changelog
2024-06-20 17:45:31 +02:00
8642b1a7af Feature/upgrade zone.js to version 0.14.7 (#3501)
* Upgrade zone.js to version 0.14.7

* Update changelog
2024-06-19 13:52:05 +02:00
f96f861341 Feature/upgrade ngx markdown to version 18.0.0 (#3498)
* Upgrade ngx-markdown to version 18.0.0

* Update changelog
2024-06-18 20:53:03 +02:00
a201fc7a97 Feature/upgrade stripe dependencies 20240617 (#3499)
* Upgrade Stripe dependencies

* Update changelog
2024-06-18 20:37:02 +02:00
a97110348c Feature/move active filters indicator to general availability (#3485)
* Move to general availability

* Update changelog
2024-06-18 20:11:49 +02:00
a25d5b9dc0 Feature/improve error handling in biometric authentication registration (#3496)
* Improve error handling in biometric authentication registration

* Update changelog
2024-06-17 16:41:12 +02:00
6c2acf2aa6 Feature/set up ssl for local development (#3482)
* Set up SSL for local development

* Update changelog
2024-06-15 10:53:20 +02:00
519827045a Feature/add dialog for benchmarks in markets overview (#3493)
* Add benchmarks dialog in markets overview

* Update changelog
2024-06-15 09:49:54 +02:00
79a7e12a9f Release 2.89.0 (#3491) 2024-06-14 03:43:47 +02:00
bf20a5de82 Feature/improve date validation in activity endpoints (#3489)
* Improve date validation

* Update changelog
2024-06-14 03:40:47 +02:00
0adefe14e1 Feature/improve language localization (#3487)
* Update translations
2024-06-13 12:08:15 +02:00
f24561cc3d Feature/improve style of personal finance tools list (#3486) 2024-06-13 12:07:42 +02:00
873fd53715 Feature/extend market data with currencies preset by activities count and date (#3460)
* Extend market data with currencies preset by activities count and date

* Update changelog
2024-06-12 20:28:55 +02:00
e5d8faf2dc Feature/improve language localization for de 20240612 (#3483)
* Update translations

* Update changelog
2024-06-12 10:43:54 +02:00
65d3bd2802 Release 2.88.0 (#3484) 2024-06-11 20:02:21 +02:00
ad60373813 Feature/improve style of blog post list (#3481)
* Improve style

* Update changelog
2024-06-11 20:00:41 +02:00
b725e6e2ec Feature/migrate client to control flow (#3475)
* Migrate to control flow

* Update changelog
2024-06-11 19:46:16 +02:00
88c420ca5e Feature/improve language localization for de 20240611 (#3480)
* Update translations

* Update changelog
2024-06-11 19:20:13 +02:00
118e17f78c Feature/improve wording on allocations page (#3479) 2024-06-11 11:58:26 +02:00
cc92592d86 Feature/refactor personal finance tools section (#3478)
* Refactoring
2024-06-11 11:57:24 +02:00
46eb3254a9 Feature/set image source label in Dockerfile (#3477)
* Set image source label in Dockerfile

* Update changelog
2024-06-10 17:42:17 +02:00
2477491f18 Feature/upgrade nx to version 19.2.2 (#3474)
* Upgrade Nx to version 19.2.2 and Angular to version 18.0.2

* Update changelog
2024-06-09 10:24:16 +02:00
5fc9fde129 Release 2.87.0 (#3473) 2024-06-08 19:33:21 +02:00
00e50c6abe Feature/improve portfolio summary (#3472)
* Improve portfolio summary

* Update changelog
2024-06-08 19:31:50 +02:00
8131a7ad03 Feature/improve error handling in http response interceptor (#3471)
* Improve error handling in HttpResponseInterceptor

* Update changelog
2024-06-08 19:30:03 +02:00
f5e6f7dcfe Bugfix/fix initialization of fire calculator (#3470)
* Fix initialization

* Update changelog
2024-06-08 18:44:57 +02:00
87501e094d Feature/improve allocations by ETF holding for mobile (#3469) 2024-06-08 17:20:38 +02:00
d3bfdf78c3 Feature/upgrade prisma to version 5.15.0 (#3462)
* Upgrade prisma to version 5.15.0

* Update changelog
2024-06-08 16:15:36 +02:00
fc4e6ae6db Feature/improve language localization for de 20240608 (#3468)
* Update translations

* Update changelog
2024-06-08 15:51:20 +02:00
23e4d5454d Feature/improve allocations by etf holding (#3467)
* Improve allocations by ETF holding

* Update changelog
2024-06-08 11:17:27 +02:00
fdcf5fd396 Release 2.86.0 (#3465) 2024-06-07 21:47:18 +02:00
8a9ae9bb33 Feature/allocations by etf holding (#3464)
* Setup allocations by ETF holding

* Update changelog
2024-06-07 21:45:07 +02:00
3fb7e746df Feature/upgrade prettier to version 3.3.1 (#3459)
* Upgrade prettier to version 3.3.1

* Update changelog
2024-06-07 12:12:34 +02:00
137e8e090a Release 2.85.0 (#3461) 2024-06-06 17:25:08 +02:00
6d941500cd Bugfix/fix default locale in value component (#3454)
* Fix default locale

* Update changelog
2024-06-06 17:22:23 +02:00
9c4e22978d Feature/update translations (#3458) 2024-06-05 17:40:39 +02:00
af65a99398 Feature/extend personal finance tools 20240604 (#3457)
* Add Koyfin

* Add Navexa

* Add Stonksfolio
2024-06-05 17:32:43 +02:00
1a0cb561cd Feature/add ability to close user account (#3444)
* Add ability to close user account

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-06-04 15:07:06 +02:00
9f875adf0c Feature/improve language localization for de 20240602 (#3452)
* Update translations

* Update changelog
2024-06-03 20:26:58 +02:00
6b0dadb895 Feature/upgrade ng extract i18n merge to version 2.12.0 (#3449)
* Upgrade ng-extract-i18n-merge to version 2.12.0

* Update changelog
2024-06-02 10:22:31 +02:00
98a9523eee Feature/extend personal finance tools (#3448) 2024-06-02 10:20:09 +02:00
dfb3365efb Release 2.84.0 (#3447) 2024-06-01 11:16:13 +02:00
0e08d8830e Handle reduce of empty array (#3446) 2024-06-01 11:14:34 +02:00
69d85eadfd Feature/setup cascading on delete for various relations in database schema (#3445)
* Setup cascading on delete

* Update changelog
2024-06-01 11:08:36 +02:00
c009f8c12f Feature/add data provider info to asset profile details dialog (#3434)
* Add data provider info to asset profile details dialog

* Update changelog
2024-06-01 10:55:42 +02:00
60ef46accf Bugfix/fix state handling of currency selector component (#2795) (#3429)
* Fix state handling of currency selector component

* Update changelog
2024-06-01 10:53:02 +02:00
b12ac1fe84 Feature/simplify module imports of api (#3443)
* Simplify module imports
2024-06-01 10:02:43 +02:00
4355c96ab6 Bugfix/fix initial annual interest rate in fire calculator (#3437)
* Fix initial annual interest rate

* Update changelog
2024-05-31 17:29:19 +02:00
fb326fe0cc Release 2.83.0 (#3442) 2024-05-30 20:47:43 +02:00
02cfebd98c Feature/upgrade yahoo finance2 to version 2.11.3 (#3441)
* Upgrade yahoo-finance2 to version 2.11.3

* Update changelog
2024-05-30 20:45:32 +02:00
dd2936d703 Feature/upgrade countup.js to version 2.8.0 (#3436)
* Upgrade countup.js to version 2.8.0

* Update changelog
2024-05-29 16:06:58 +02:00
918d0b85d4 Feature/update passport dependencies (#3433)
* Update passport dependencies

* Refactor Google strategy

* Update changelog
2024-05-28 13:47:45 +02:00
a061595101 Feature/upgrade class validator to version 0.14.1 (#3431)
* Upgrade class-validator to version 0.14.1

* Update changelog
2024-05-27 13:55:24 +02:00
dcd496ac50 Feature/upgrade angular to version 17.3.10 (#3430)
* Upgrade angular to version 17.3.10

* Update changelog
2024-05-26 14:13:43 +02:00
6b9ec549da Feature/upgrade prisma to version 5.14.0 (#3423)
* Upgrade prisma to version 5.14.0

* Update changelog
2024-05-24 15:36:36 +02:00
b5bd4df483 Feature/upgrade nx to version 19.0.5 (#3422)
* Upgrade Nx to version 19.0.5

* Update changelog
2024-05-23 20:48:26 +02:00
766a2d7c2f Release 2.82.0 (#3425) 2024-05-22 19:53:20 +02:00
6dabf7516a Feature/preselect account in create or update activity dialog (#3413)
* Preselect account if there is only one

* Update changelog
2024-05-22 18:15:08 +02:00
5d49ff7a4a Feature/improve usability of date range selector in assistant (#3409)
* Improve usability of date range selector

* Update changelog
2024-05-22 16:39:39 +02:00
8998c18836 Feature/upgrade internationalized number to version 3.5.2 (#3412)
* Upgrade @internationalized/number to version 3.5.2

* Update changelog
2024-05-22 16:36:50 +02:00
741a0e36d2 Add links to tagged issues (#3405)
* help wanted
* good first issue
2024-05-20 22:28:43 +03:00
e31b4c64cb Feature/refactor holding detail dialog to standalone (#3407)
* Refactor holding detail dialog to standalone

* Update changelog
2024-05-19 19:13:14 +03:00
812ff5cbdc Feature/refresh cryptocurrencies list 20240514 (#3411)
* Refresh cryptocurrencies list

* Update changelog
2024-05-18 17:40:25 +03:00
035b90a689 Feature/upgrade zone.js to version 0.14.5 (#3410)
* Upgrade zone.js to version 0.14.5

* Update changelog
2024-05-17 18:08:37 +03:00
5ea3a187f4 Feature/upgrade body parser to version 1.20.2 (#3406)
* Upgrade body-parser to version 1.20.2

* Update changelog
2024-05-16 17:26:27 +02:00
5d9c38663d Feature/migrate various pages to standalone components (#3404)
* Migrate to standalone components

* Update changelog
2024-05-15 12:04:07 +02:00
5616bc4956 Feature/validate account balance creation/update using DTO (#3400)
* Validate create account balance using DTO
2024-05-13 13:47:33 +02:00
9ad1c2177c Release 2.81.0 (#3403) 2024-05-12 10:42:04 +02:00
15bf9f2f9c Feature/add Türkçe to footer (#3401) 2024-05-12 10:34:33 +02:00
ebc5008569 Feature/improve language localization for de 20240512 (#3402)
* Update translations

* Update changelog
2024-05-12 10:25:59 +02:00
37759ba03f Feature/improve language localization for tr 20240501 (#3355)
* Improve translations

* Update changelog

---------

Co-authored-by: sadmimye <134071831+sadmimye@users.noreply.github.com>
2024-05-12 09:58:11 +02:00
8319b216bb Feature/support delete activities with filtering (#3394)
* Support delete activities with filtering

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-05-12 09:56:07 +02:00
782d131b0d Feature/add indicator for active filters (#3398)
* Add indicator for active filters

* Update changelog
2024-05-12 09:42:27 +02:00
72e75208df Bugfix/fix position detail dialog close functionality (#3396)
* Handle holding detail dialog open functionality in a single place (AppComponent)

* Update changelog
2024-05-11 20:27:18 +02:00
4b1c27c245 Feature/upgrade nx to version 19.0.2 (#3391)
* Upgrade Nx dependencies to version 19.0.2

* Update changelog
2024-05-11 08:33:33 +02:00
61f0da35bc Feature/disable delete all activities if filters are active (#3389)
* Disable delete all activities button if filters are active

* Update changelog
2024-05-10 08:51:34 +02:00
80464c7846 Release 2.80.0 (#3386) 2024-05-08 20:55:39 +02:00
74f4323903 Feature/increase spacing around floating action buttons (#3385)
* Increase spacing around floating action buttons

* Update changelog
2024-05-08 20:54:13 +02:00
127dbf9dcd Update translations (#3384) 2024-05-08 20:37:53 +02:00
66bdb374e8 Feature/set icon columns of tables to stick at beginning (#3377)
* Set icon columns to stick at the beginning

* Update changelog
2024-05-08 20:04:58 +02:00
4ad4fa2b30 Feature/clean up deprecated GET api/portfolio/positions endpoint (#3373) 2024-05-08 20:04:32 +02:00
1fd836194f Feature/add absolute change column to holdings table (#3378)
* Add absolute change column

* Update changelog
2024-05-08 20:02:50 +02:00
2090db1199 Feature/increase number of attempts of queue jobs (#3376)
* Increase number of attempts

* Update changelog
2024-05-07 20:48:02 +02:00
053c7e591e Feature/upgrade ionicons to version 7.4.0 (#3356)
* Upgrade ionicons to version 7.4.0

* Update changelog
2024-05-07 19:00:55 +02:00
9b5e350e3b Feature/harmonize log message (#3343) 2024-05-06 17:03:58 +02:00
378e57c3bc Feature/add links to Home Assistant add-on (#3367)
* Add links to Home Assistant add-on
2024-05-06 17:02:18 +02:00
6765191a8c Bugfix/fix position detail dialog open in holding search of assistant (#3374)
* Open position detail dialog (via holding search of assistant)

* Update changelog
2024-05-05 09:09:57 +02:00
8438a45bcf Update changelog (#3372) 2024-05-04 16:32:32 +02:00
30a64e7fc1 Release 2.79.0 (#3371) 2024-05-04 15:54:29 +02:00
f2cb671c7f Feature/optimize get porfolio details endpoint (#3366)
* Eliminate getPerformance() from getSummary() function

* Disable cache for getDetails()

* Add hint to portfolio summary

* Update changelog
2024-05-04 15:53:02 +02:00
3f41e5c5de Bugfix/fix locale in markets overview (#3369)
* Fix locale if no user is logged in

* Update changelog
2024-05-04 15:51:09 +02:00
c1ad483f33 Improve alignment (#3370) 2024-05-04 15:50:47 +02:00
f3d961bc16 Feature/move holdings table to holdings tab of home page (#3368)
* Move holdings table to holdings tab of home page

* Deprecate api/v1/portfolio/positions endpoint

* Update changelog
2024-05-04 14:11:37 +02:00
42b70ef568 Feature/improve performance labels in position detail dialog (#3363)
* Improve performance labels (with and without currency effects)

* Update changelog
2024-05-04 07:49:37 +02:00
77beaaba08 Refactoring portfolio service (#3365) 2024-05-03 21:48:46 +02:00
d9c07456cd Release 2.78.0 (#3361) 2024-05-02 20:32:47 +02:00
0a53df4293 Feature/improve inactive user role (#3360)
* Improve inactive role

* Update changelog
2024-05-02 20:31:20 +02:00
4416ba0c88 Feature/set performance column of holdings table to stick at end (#3353)
* Set up stickyEnd in performance column

* Update changelog
2024-05-02 19:16:59 +02:00
486de968a2 Bugfix/fix division by zero error in dividend yield calculation (#3354)
* Handle division by zero

* Update changelog
2024-05-02 17:53:34 +02:00
a5833566a8 Feature/skip caching in portfolio calculator if active filters (#3348)
* Skip caching if active filters

* Update changelog
2024-05-02 17:52:39 +02:00
261f5844dd Add type column to README.md (#3295) 2024-04-30 14:08:14 +02:00
2173c418a7 Feature/validate forms using DTO for access, asset profile, tag and platform management (#3337)
* Validate forms using DTO for access, asset profile, tag and platform management

* Update changelog
2024-04-30 08:04:45 +02:00
4efd5cefd8 Bugfix/calculation of portfolio summary caused by future liabilities (#3342)
* Adapt date of future activities

* Update changelog
2024-04-29 20:12:12 +02:00
d735e4db75 Release 2.77.1 (#3340) 2024-04-27 19:25:23 +02:00
e10707fde4 Add missing guard to fix public page (#3339) 2024-04-27 19:24:08 +02:00
ac953df809 Release 2.77.0 (#3338) 2024-04-27 15:37:48 +02:00
bb4ee50738 Feature/update browserslist database 20240417 (#3288)
* Update the browserslist database

* Update changelog
2024-04-27 15:35:57 +02:00
4f41bac328 Feature/set up caching in portfolio calculator (#3335)
* Set up caching

* Update changelog
2024-04-27 15:35:28 +02:00
cd07802400 Bugfix/fix historical market data gathering for asset profiles with manual data source (#3336)
* Fix historical market data gathering for asset profiles with MANUAL data source

* Update changelog
2024-04-27 15:29:32 +02:00
b692b7432c Feature/set up event system for portfolio changes (#3333)
* Set up event system for portfolio changes

* Update changelog
2024-04-26 20:13:53 +02:00
a4efbc0131 Feature/migrate UI components to control flow (#3324)
* Migrate to control flow

* Update changelog
2024-04-26 17:40:00 +02:00
55b0fe232c Bugfix/reset form values to null if empty string (#3327)
* Reset form values to null if empty string

* Update changelog
2024-04-25 19:04:28 +02:00
46432edce9 Feature/extend faq by custom asset instructions (#3326)
* Add instructions for custom asset

* Update changelog
2024-04-24 20:35:39 +02:00
990028316e Refactor form controls to form getter (#3325) 2024-04-24 20:20:56 +02:00
37871fbabc Feature/upgrade prisma to version 5.13.0 (#3323)
* Upgrade prisma to version 5.13.0

* Update changelog
2024-04-24 20:20:09 +02:00
0fdeef7953 Release 2.76.0 (#3322) 2024-04-23 18:58:43 +02:00
cdbe6eedeb Feature/change cash to liquidity in asset class enum (#3321)
* Change CASH to LIQUIDITY in asset class enum

* Update changelog
2024-04-23 18:55:37 +02:00
453 changed files with 67482 additions and 122544 deletions

View File

@ -24,12 +24,18 @@
{
"files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nx/typescript"],
"rules": {}
"rules": {
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
},
{
"files": ["*.js", "*.jsx"],
"extends": ["plugin:@nx/javascript"],
"rules": {}
"rules": {
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
},
{
"files": ["*.ts"],

View File

@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
node_version:
- 18
- 20
steps:
- name: Checkout code
uses: actions/checkout@v4
@ -24,16 +24,16 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node_version }}
cache: 'yarn'
cache: 'npm'
- name: Install dependencies
run: yarn install --frozen-lockfile
run: npm ci
- name: Check formatting
run: yarn format:check
run: npm run format:check
- name: Execute tests
run: yarn test
run: npm test
- name: Build application
run: yarn build:production
run: npm run build:production

5
.gitignore vendored
View File

@ -5,8 +5,8 @@
/tmp
# dependencies
/.yarn
/node_modules
npm-debug.log
# IDEs and editors
/.idea
@ -28,15 +28,14 @@
.env
.env.prod
.nx/cache
.nx/workspace-data
/.sass-cache
/connect.lock
/coverage
/dist
/libpeerconnection.log
npm-debug.log
testem.log
/typings
yarn-error.log
# System Files
.DS_Store

2
.nvmrc
View File

@ -1 +1 @@
v18
v20

View File

@ -1,4 +1,5 @@
/.nx/cache
/.nx/workspace-data
/apps/client/src/polyfills.ts
/dist
/test/import

View File

@ -1 +0,0 @@
network-timeout 600000

View File

@ -5,6 +5,391 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.102.0 - 2024-08-07
### Added
- Added support to clone an activity from the account detail dialog (experimental)
- Added support to edit an activity from the account detail dialog (experimental)
- Added support to clone an activity from the holding detail dialog (experimental)
- Added support to edit an activity from the holding detail dialog (experimental)
### Changed
- Improved the caching of the benchmarks in the markets overview by returning cached data and recalculating in the background when it expires
- Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`)
- Upgraded `Nx` from version `19.5.1` to `19.5.6`
### Fixed
- Fixed the cache flush endpoint response
## 2.101.0 - 2024-08-03
### Changed
- Hardened container security by switching to a non-root user, setting the filesystem to read-only, and dropping unnecessary capabilities
## 2.100.0 - 2024-08-03
### Added
- Added support to manage tags of holdings in the holding detail dialog
### Changed
- Improved the color assignment in the chart of the holdings tab on the home page (experimental)
- Persisted the view mode of the holdings tab on the home page (experimental)
- Improved the language localization for Catalan (`ca`)
- Improved the language localization for Spanish (`es`)
## 2.99.0 - 2024-07-29
### Changed
- Migrated the usage of `yarn` to `npm`
- Upgraded `storybook` from version `7.0.9` to `8.2.5`
- Downgraded `marked` from version `13.0.0` to `12.0.2`
## 2.98.0 - 2024-07-27
### Added
- Set up the language localization for Catalan (`ca`)
### Changed
- Improved the account selector of the create or update activity dialog
- Improved the handling of the numerical precision in the value component
- Skipped derived currencies in the get quotes functionality of the data provider service
- Improved the language localization for Spanish (`es`)
- Upgraded `angular` from version `18.0.4` to `18.1.1`
- Upgraded `Nx` from version `19.4.3` to `19.5.1`
- Upgraded `prisma` from version `5.16.1` to `5.17.0`
### Fixed
- Fixed the dividend import from a data provider for holdings without an account
- Fixed an issue in the public page related to a non-existent access
## 2.97.0 - 2024-07-20
### Added
- Added _selfh.st_ to the _As seen in_ section on the landing page
### Changed
- Improved the numerical precision in the holding detail dialog
- Improved the handling of the numerical precision in the value component
- Optimized the 7d data gathering by prioritizing the currencies
- Improved the language localization for German (`de`)
- Upgraded `Node.js` from version `18` to `20` (`Dockerfile`)
- Upgraded `Nx` from version `19.4.0` to `19.4.3`
- Upgraded `prettier` from version `3.3.1` to `3.3.3`
### Fixed
- Fixed the table sorting of the holdings tab on the home page
## 2.96.0 - 2024-07-13
### Changed
- Improved the chart of the holdings tab on the home page (experimental)
- Separated the icon purposes in the `site.webmanifest`
### Fixed
- Fixed an issue in the portfolio summary with the currency conversion of fees
- Fixed an issue in the the search for a holding
- Removed the show condition of the experimental features setting in the user settings
## 2.95.0 - 2024-07-12
### Added
- Added a chart to the holdings tab of the home page (experimental)
## 2.94.0 - 2024-07-09
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed a pagination issue in the activities endpoint by adding `id` as a secondary sort criterion to `date` to ensure consistent ordering
## 2.93.0 - 2024-07-07
### Added
- Added the _Crypto Coins Heatmap_ to the resources section
- Added the _Stock Heatmap_ to the resources section
- Extended the content of the _Self-Hosting_ section by the platforms concept on the Frequently Asked Questions (FAQ) page
### Changed
- Improved the allocations by ETF holding on the allocations page for the impersonation mode (experimental)
- Improved the detection of REST APIs (`JSON`) used via the scraper configuration
- Improved the usability to delete an asset profile of type currency in the historical market data table and the asset profile details dialog of the admin control
- Refreshed the cryptocurrencies list
- Refactored the thresholds of the rules in the _X-ray_ section
- Removed the obsolete `version` from the `docker-compose` files
- Upgraded `Nx` from version `19.2.2` to `19.4.0`
## 2.92.0 - 2024-06-30
### Added
- Added support for bulk deletion of asset profiles from the market data table in the admin control panel
### Changed
- Added support for derived currencies in the currency validation
- Added support for automatic deletion of unused asset profiles when deleting activities
- Improved the caching of the benchmarks in the markets overview (only cache if needed)
- Upgraded `prisma` from version `5.15.0` to `5.16.1`
### Fixed
- Fixed an issue with the all time high in the benchmarks of the markets overview
## 2.91.0 - 2024-06-26
### Added
- Added a benchmarks preset to the historical market data table of the admin control panel
### Changed
- Upgraded `angular` from version `18.0.2` to `18.0.4`
### Fixed
- Fixed the dialog position (center) on mobile
- Fixed the horizontal overflow in the historical market data table of the admin control panel
- Changed the mechanism of the `INTRADAY` data gathering to persist data only if the market state is `OPEN`
- Fixed the creation of activities with `MANUAL` data source (with no historical market data)
## 2.90.0 - 2024-06-22
### Added
- Added a dialog for the benchmarks in the markets overview
- Extended the asset profile details dialog of the admin control for currencies
- Extended the content of the _Self-Hosting_ section by the mobile app question on the Frequently Asked Questions (FAQ) page
### Changed
- Moved the indicator for active filters from experimental to general availability
- Improved the error handling in the biometric authentication registration
- Improved the language localization for German (`de`)
- Set up SSL for local development
- Upgraded the _Stripe_ dependencies
- Upgraded `marked` from version `9.1.6` to `13.0.0`
- Upgraded `ngx-device-detector` from version `5.0.1` to `8.0.0`
- Upgraded `ngx-markdown` from version `17.1.1` to `18.0.0`
- Upgraded `zone.js` from version `0.14.5` to `0.14.7`
## 2.89.0 - 2024-06-14
### Added
- Extended the historical market data table with currencies preset by date and activities count in the admin control panel
### Changed
- Improved the date validation in the create, import and update activities endpoints
- Improved the language localization for German (`de`)
## 2.88.0 - 2024-06-11
### Added
- Set the image source label in `Dockerfile`
### Changed
- Improved the style of the blog post list
- Migrated the `@ghostfolio/client` components to control flow
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `17.3.10` to `18.0.2`
- Upgraded `Nx` from version `19.0.5` to `19.2.2`
## 2.87.0 - 2024-06-08
### Changed
- Improved the portfolio summary
- Improved the allocations by ETF holding on the allocations page (experimental)
- Improved the error handling in the `HttpResponseInterceptor`
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.14.0` to `5.15.0`
### Fixed
- Fixed an issue in the _FIRE_ calculator
## 2.86.0 - 2024-06-07
### Added
- Introduced the allocations by ETF holding on the allocations page (experimental)
### Changed
- Upgraded `prettier` from version `3.2.5` to `3.3.1`
## 2.85.0 - 2024-06-06
### Added
- Added the ability to close a user account
### Changed
- Improved the language localization for German (`de`)
- Upgraded `ng-extract-i18n-merge` from version `2.10.0` to `2.12.0`
### Fixed
- Fixed an issue with the default locale in the value component
## 2.84.0 - 2024-06-01
### Added
- Added the data provider information to the asset profile details dialog of the admin control
- Added the cascading on delete for various relations in the database schema
### Fixed
- Fixed an issue with the initial annual interest rate in the _FIRE_ calculator
- Fixed the state handling in the currency selector
- Fixed the deletion of an asset profile with symbol profile overrides in the asset profile details dialog of the admin control
## 2.83.0 - 2024-05-30
### Changed
- Upgraded `@nestjs/passport` from version `10.0.0` to `10.0.3`
- Upgraded `angular` from version `17.3.5` to `17.3.10`
- Upgraded `class-validator` from version `0.14.0` to `0.14.1`
- Upgraded `countup.js` from version `2.3.2` to `2.8.0`
- Upgraded `Nx` from version `19.0.2` to `19.0.5`
- Upgraded `passport` from version `0.6.0` to `0.7.0`
- Upgraded `passport-jwt` from version `4.0.0` to `4.0.1`
- Upgraded `prisma` from version `5.13.0` to `5.14.0`
- Upgraded `yahoo-finance2` from version `2.11.2` to `2.11.3`
## 2.82.0 - 2024-05-22
### Changed
- Improved the usability of the create or update activity dialog by preselecting the (only) account
- Improved the usability of the date range selector in the assistant
- Refactored the holding detail dialog to a standalone component
- Refreshed the cryptocurrencies list
- Refactored various pages to standalone components
- Upgraded `@internationalized/number` from version `3.5.0` to `3.5.2`
- Upgraded `body-parser` from version `1.20.1` to `1.20.2`
- Upgraded `zone.js` from version `0.14.4` to `0.14.5`
## 2.81.0 - 2024-05-12
### Added
- Added an indicator for active filters (experimental)
### Changed
- Improved the delete all activities functionality on the portfolio activities page to work with the filters of the assistant
- Improved the language localization for German (`de`)
- Improved the language localization for Türkçe (`tr`)
- Upgraded `Nx` from version `18.3.3` to `19.0.2`
### Fixed
- Fixed the position detail dialog close functionality
## 2.80.0 - 2024-05-08
### Added
- Added the absolute change column to the holdings table on the home page
### Changed
- Increased the spacing around the floating action buttons (FAB)
- Set the icon column of the activities table to stick at the beginning
- Set the icon column of the holdings table to stick at the beginning
- Increased the number of attempts of queue jobs from `10` to `12` (fail later)
- Upgraded `ionicons` from version `7.3.0` to `7.4.0`
### Fixed
- Fixed the position detail dialog open functionality when searching for a holding in the assistant
## 2.79.0 - 2024-05-04
### Changed
- Moved the holdings table to the holdings tab of the home page
- Improved the performance labels (with and without currency effects) in the position detail dialog
- Optimized the calculations of the portfolio details endpoint
### Fixed
- Fixed an issue with the benchmarks in the markets overview
- Fixed an issue with the _Fear & Greed Index_ (market mood) in the markets overview
## 2.78.0 - 2024-05-02
### Added
- Added a form validation against the DTO in the create or update access dialog
- Added a form validation against the DTO in the asset profile details dialog of the admin control
- Added a form validation against the DTO in the platform management of the admin control panel
- Added a form validation against the DTO in the tag management of the admin control panel
### Changed
- Set the performance column of the holdings table to stick at the end
- Skipped the caching in the portfolio calculator if there are active filters (experimental)
- Improved the `INACTIVE` user role
### Fixed
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
- Fixed a division by zero error in the dividend yield calculation (experimental)
## 2.77.1 - 2024-04-27
### Added
- Extended the content of the _Self-Hosting_ section by the custom asset instructions on the Frequently Asked Questions (FAQ) page
- Added the caching to the portfolio calculator (experimental)
### Changed
- Migrated the `@ghostfolio/ui` components to control flow
- Updated the browserslist database
- Upgraded `prisma` from version `5.12.1` to `5.13.0`
### Fixed
- Fixed the form submit in the asset profile details dialog of the admin control due to the `url` validation
- Fixed the historical market data gathering for asset profiles with `MANUAL` data source
## 2.76.0 - 2024-04-23
### Changed
- Changed `CASH` to `LIQUIDITY` in the asset class enum
## 2.75.1 - 2024-04-21
### Added
@ -4515,7 +4900,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the attribute `precision` in the value component
- Added the attribute `precision` to the value component
### Fixed

View File

@ -10,7 +10,7 @@ Remove permission in `UserService` using `without()`
### Frontend
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
## Git
@ -30,26 +30,26 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
#### 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` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
1. Run `npx nx migrate latest`
1. Make sure `package.json` changes make sense and then run `npm install`
1. Run `npx nx migrate --run-migrations`
### Prisma
#### Access database via GUI
Run `yarn database:gui`
Run `npm run database:gui`
https://www.prisma.io/studio
#### Synchronize schema with database for prototyping
Run `yarn database:push`
Run `npm run 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`
Run `npm run prisma migrate dev --name added_job_title`
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate

View File

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:18-slim as builder
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
# Build application and add additional files
WORKDIR /ghostfolio
@ -8,18 +8,17 @@ WORKDIR /ghostfolio
COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE
COPY ./package.json package.json
COPY ./yarn.lock yarn.lock
COPY ./.yarnrc .yarnrc
COPY ./package-lock.json package-lock.json
COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apt update && apt install -y \
g++ \
git \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN yarn install
RUN apt-get update && apt-get install -y --no-install-suggests \
g++ \
git \
make \
openssl \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install
# See https://github.com/nrwl/nx/issues/6586 for further details
COPY ./decorate-angular-cli.js decorate-angular-cli.js
@ -33,31 +32,36 @@ COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs
COPY ./apps apps
RUN yarn build:production
RUN npm run build:production
# Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original
# yarn.lock needs to be used to ensure the same versions
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
# package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
RUN yarn
RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma
# Overwrite the generated package.json with the original one to ensure having
# all the scripts
COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings
RUN npm run database:generate-typings
# Image to run, copy everything needed from builder
FROM node:18-slim
RUN apt update && apt install -y \
curl \
openssl \
&& rm -rf /var/lib/apt/lists/*
FROM node:20-slim
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y --no-install-suggests \
curl \
openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
RUN chown -R node:node /ghostfolio
WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333}
USER node
CMD [ "/ghostfolio/entrypoint.sh" ]

View File

@ -7,7 +7,7 @@
**Open Source Wealth Management Software**
[**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) |
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://twitter.com/ghostfolio_)
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio)
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing)
@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions
- ✅ Multi account management
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions
@ -71,7 +71,7 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
### Frontend
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
The frontend is built with [Angular](https://angular.dev) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Self-hosting
@ -85,23 +85,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables
| Name | Default Value | Description |
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` |   | The _CoinGecko_ Pro API |
| `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 |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `0` | The database index of _Redis_ |
| `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
| Name | Type | Default Value | Description |
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose
@ -142,57 +142,56 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
### Home Server Systems (Community)
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), Home Assistant, [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
## Development
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 18+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- [Node.js](https://nodejs.org/en/download) (version 20+)
- Create a local copy of this Git repository (clone)
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
### Setup
1. Run `yarn install`
1. Run `npm install`
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
1. Run `npm run database:setup` to initialize the database schema
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
1. Start the server and the client (see [_Development_](#Development))
1. Open http://localhost:4200/en in your browser
1. Open https://localhost:4200/en in your browser
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
### Start Server
#### Debug
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
#### Serve
Run `yarn start:server`
Run `npm run start:server`
### Start Client
Run `yarn start:client` and open http://localhost:4200/en in your browser
Run `npm run start:client` and open https://localhost:4200/en in your browser
### Start _Storybook_
Run `yarn start:storybook`
Run `npm run start:storybook`
### Migrate Database
With the following command you can keep your database schema in sync:
```bash
yarn database:push
npm run database:push
```
## Testing
Run `yarn test`
Run `npm test`
## Public API
@ -233,18 +232,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
}
```
| Field | Type | Description |
| ---------- | ------------------- | ----------------------------------------------------------------------------- |
| accountId | string (`optional`) | Id of the account |
| comment | string (`optional`) | Comment of the activity |
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
| date | string | Date in the format `ISO-8601` |
| fee | number | Fee of the activity |
| quantity | number | Quantity of the activity |
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| unitPrice | number | Price per unit of the activity |
| Field | Type | Description |
| ------------ | ------------------- | ----------------------------------------------------------------------------- |
| `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
| `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity of the activity |
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| `unitPrice` | `number` | Price per unit of the activity |
#### Response
@ -275,7 +274,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
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](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. 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).

View File

@ -1,7 +1,6 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { resetHours } from '@ghostfolio/common/helper';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -18,7 +17,6 @@ import {
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AccountBalance } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service';
@ -67,10 +65,11 @@ export class AccountBalanceController {
@Param('id') id: string
): Promise<AccountBalance> {
const accountBalance = await this.accountBalanceService.accountBalance({
id
id,
userId: this.request.user.id
});
if (!accountBalance || accountBalance.userId !== this.request.user.id) {
if (!accountBalance) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -78,7 +77,8 @@ export class AccountBalanceController {
}
return this.accountBalanceService.deleteAccountBalance({
id
id: accountBalance.id,
userId: accountBalance.userId
});
}
}

View File

@ -1,3 +1,4 @@
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
@ -5,6 +6,7 @@ import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
@ -13,6 +15,7 @@ import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Injectable()
export class AccountBalanceService {
public constructor(
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
@ -36,7 +39,7 @@ export class AccountBalanceService {
}: CreateAccountBalanceDto & {
userId: string;
}): Promise<AccountBalance> {
return this.prismaService.accountBalance.upsert({
const accountBalance = await this.prismaService.accountBalance.upsert({
create: {
Account: {
connect: {
@ -59,14 +62,32 @@ export class AccountBalanceService {
}
}
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
return accountBalance;
}
public async deleteAccountBalance(
where: Prisma.AccountBalanceWhereUniqueInput
): Promise<AccountBalance> {
return this.prismaService.accountBalance.delete({
const accountBalance = await this.prismaService.accountBalance.delete({
where
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: <string>where.userId
})
);
return accountBalance;
}
public async getAccountBalances({

View File

@ -2,7 +2,7 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {

View File

@ -1,9 +1,7 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -19,13 +17,11 @@ import { AccountService } from './account.service';
imports: [
AccountBalanceModule,
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
PortfolioModule,
PrismaModule,
RedisCacheModule,
UserModule
RedactValuesInResponseModule
],
providers: [AccountService]
})

View File

@ -1,10 +1,12 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Account, Order, Platform, Prisma } from '@prisma/client';
import { Big } from 'big.js';
import { format } from 'date-fns';
@ -16,6 +18,7 @@ import { CashDetails } from './interfaces/cash-details.interface';
export class AccountService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
@ -94,6 +97,13 @@ export class AccountService {
userId: aUserId
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
}
@ -101,9 +111,18 @@ export class AccountService {
where: Prisma.AccountWhereUniqueInput,
aUserId: string
): Promise<Account> {
return this.prismaService.account.delete({
const account = await this.prismaService.account.delete({
where
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
}
public async getAccounts(aUserId: string): Promise<Account[]> {
@ -155,8 +174,8 @@ export class AccountService {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
} = groupBy(filters, ({ type }) => {
return type;
});
if (filtersByAccount?.length > 0) {
@ -201,10 +220,19 @@ export class AccountService {
userId: aUserId
});
return this.prismaService.account.update({
const account = await this.prismaService.account.update({
data,
where
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
}
public async updateAccountBalance({

View File

@ -1,7 +1,8 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsBoolean,
IsISO4217CurrencyCode,
IsNumber,
IsOptional,
IsString,
@ -20,7 +21,7 @@ export class CreateAccountDto {
)
comment?: string;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
currency: string;
@IsOptional()

View File

@ -1,7 +1,8 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsBoolean,
IsISO4217CurrencyCode,
IsNumber,
IsOptional,
IsString,
@ -20,7 +21,7 @@ export class UpdateAccountDto {
)
comment?: string;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
currency: string;
@IsString()

View File

@ -1,6 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
@ -81,10 +81,11 @@ export class AdminController {
@Post('gather/max')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => {
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return {
data: {
dataSource,
@ -107,10 +108,11 @@ export class AdminController {
@Post('gather/profile-data')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue(
uniqueAssets.map(({ dataSource, symbol }) => {
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return {
data: {
dataSource,

View File

@ -1,4 +1,7 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -18,16 +21,19 @@ import { QueueModule } from './queue/queue.module';
@Module({
imports: [
ApiModule,
BenchmarkModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
OrderModule,
PrismaModule,
PropertyModule,
QueueModule,
SubscriptionModule,
SymbolProfileModule
SymbolProfileModule,
TransformDataSourceInRequestModule
],
controllers: [AdminController],
providers: [AdminService],

View File

@ -1,3 +1,5 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -13,22 +15,25 @@ import {
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import { isCurrency, getCurrencyFromSymbol } from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
Filter,
UniqueAsset
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
Prisma,
PrismaClient,
Property,
SymbolProfile
} from '@prisma/client';
@ -38,10 +43,12 @@ import { groupBy } from 'lodash';
@Injectable()
export class AdminService {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
@ -52,7 +59,9 @@ export class AdminService {
currency,
dataSource,
symbol
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
}: AssetProfileIdentifier & { currency?: string }): Promise<
SymbolProfile | never
> {
try {
if (dataSource === 'MANUAL') {
return this.symbolProfileService.add({
@ -89,7 +98,10 @@ export class AdminService {
}
}
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
public async deleteProfileData({
dataSource,
symbol
}: AssetProfileIdentifier) {
await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol });
}
@ -147,7 +159,16 @@ export class AdminService {
[{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {};
if (presetId === 'CURRENCIES') {
if (presetId === 'BENCHMARKS') {
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
where.id = {
in: benchmarkAssetProfiles.map(({ id }) => {
return id;
})
};
} else if (presetId === 'CURRENCIES') {
return this.getMarketDataForCurrencies();
} else if (
presetId === 'ETF_WITHOUT_COUNTRIES' ||
@ -197,104 +218,129 @@ export class AdminService {
}
}
let [assetProfiles, count] = await Promise.all([
this.prismaService.symbolProfile.findMany({
orderBy,
skip,
take,
where,
select: {
_count: {
select: { Order: true }
},
assetClass: true,
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
id: true,
name: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
sectors: true,
symbol: true
const extendedPrismaClient = this.getExtendedPrismaClient();
try {
let [assetProfiles, count] = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
orderBy,
skip,
take,
where,
select: {
_count: {
select: { Order: true }
},
assetClass: true,
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
id: true,
isUsedByUsersWithSubscription: true,
name: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
sectors: true,
symbol: true
}
}),
this.prismaService.symbolProfile.count({ where })
]);
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(
async ({
_count,
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
id,
isUsedByUsersWithSubscription,
name,
Order,
sectors,
symbol
}) => {
const countriesCount = countries
? Object.keys(countries).length
: 0;
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
currency,
countriesCount,
dataSource,
id,
name,
symbol,
marketDataItemCount,
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
};
}
)
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
}
}),
this.prismaService.symbolProfile.count({ where })
]);
let marketData: AdminMarketDataItem[] = assetProfiles.map(
({
_count,
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
id,
name,
Order,
sectors,
symbol
}) => {
const countriesCount = countries ? Object.keys(countries).length : 0;
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
currency,
countriesCount,
dataSource,
id,
name,
symbol,
marketDataItemCount,
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date
};
}
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
count = marketData.length;
}
count = marketData.length;
return {
count,
marketData
};
} finally {
await extendedPrismaClient.$disconnect();
Logger.debug('Disconnect extended prisma client', 'AdminService');
}
return {
count,
marketData
};
}
public async getMarketDataBySymbol({
dataSource,
symbol
}: UniqueAsset): Promise<AdminMarketDataDetails> {
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency));
}
const [[assetProfile], marketData] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
{
@ -313,11 +359,20 @@ export class AdminService {
})
]);
if (assetProfile) {
assetProfile.dataProviderInfo = this.dataProviderService
.getDataProvider(assetProfile.dataSource)
.getDataProviderInfo();
}
return {
marketData,
assetProfile: assetProfile ?? {
symbol,
currency: '-'
activitiesCount,
currency,
dataSource,
dateOfFirstActivity,
symbol
}
};
}
@ -329,13 +384,14 @@ export class AdminService {
countries,
currency,
dataSource,
holdings,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
const symbolProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
@ -343,27 +399,28 @@ export class AdminService {
url: url as string
};
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset =
{
comment,
countries,
currency,
dataSource,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
const updatedSymbolProfile: AssetProfileIdentifier &
Prisma.SymbolProfileUpdateInput = {
comment,
countries,
currency,
dataSource,
holdings,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
})
};
}
})
};
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
@ -395,36 +452,97 @@ export class AdminService {
return response;
}
private getExtendedPrismaClient() {
Logger.debug('Connect extended prisma client', 'AdminService');
const symbolProfileExtension = Prisma.defineExtension((client) => {
return client.$extends({
result: {
symbolProfile: {
isUsedByUsersWithSubscription: {
compute: async ({ id }) => {
const { _count } =
await this.prismaService.symbolProfile.findUnique({
select: {
_count: {
select: {
Order: {
where: {
User: {
Subscription: {
some: {
expiresAt: {
gt: new Date()
}
}
}
}
}
}
}
}
},
where: {
id
}
});
return _count.Order > 0;
}
}
}
}
});
});
return new PrismaClient().$extends(symbolProfileExtension);
}
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const marketDataPromise: Promise<AdminMarketDataItem>[] =
this.exchangeRateDataService
.getCurrencyPairs()
.map(async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
return {
dataSource,
marketDataItemCount,
symbol,
assetClass: 'CASH',
countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''),
id: undefined,
name: symbol,
sectorsCount: 0
};
});
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency));
}
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
activitiesCount,
currency,
dataSource,
marketDataItemCount,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
name: symbol,
sectorsCount: 0
};
});
const marketData = await Promise.all(marketDataPromise);
return { marketData, count: marketData.length };
}

View File

@ -1,8 +1,9 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
import {
IsArray,
IsEnum,
IsISO4217CurrencyCode,
IsObject,
IsOptional,
IsString,
@ -26,7 +27,7 @@ export class UpdateAssetProfileDto {
@IsOptional()
countries?: Prisma.InputJsonArray;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
@IsOptional()
currency?: string;

View File

@ -1,3 +1,4 @@
import { EventsModule } from '@ghostfolio/api/events/events.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -14,6 +15,7 @@ import {
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes';
@ -23,6 +25,7 @@ import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module';
import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
@ -44,10 +47,12 @@ import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module';
@Module({
controllers: [AppController],
imports: [
AdminModule,
AccessModule,
AccountModule,
AssetModule,
AuthDeviceModule,
AuthModule,
BenchmarkModule,
@ -64,6 +69,8 @@ import { UserModule } from './user/user.module';
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
EventEmitterModule.forRoot(),
EventsModule,
ExchangeRateModule,
ExchangeRateDataModule,
ExportModule,
@ -109,7 +116,6 @@ import { UserModule } from './user/user.module';
TwitterBotModule,
UserModule
],
controllers: [AppController],
providers: [CronService]
})
export class AppModule {}

View File

@ -0,0 +1,29 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { pick } from 'lodash';
@Controller('asset')
export class AssetController {
public constructor(private readonly adminService: AdminService) {}
@Get(':dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAsset(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
const { assetProfile, marketData } =
await this.adminService.getMarketDataBySymbol({ dataSource, symbol });
return {
marketData,
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
};
}
}

View File

@ -0,0 +1,17 @@
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { Module } from '@nestjs/common';
import { AssetController } from './asset.controller';
@Module({
controllers: [AssetController],
imports: [
AdminModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
]
})
export class AssetModule {}

View File

@ -1,6 +1,5 @@
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
@ -9,7 +8,6 @@ import { JwtModule } from '@nestjs/jwt';
@Module({
controllers: [AuthDeviceController],
imports: [
ConfigurationModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }

View File

@ -1,4 +1,3 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
@ -6,10 +5,7 @@ import { AuthDevice, Prisma } from '@prisma/client';
@Injectable()
export class AuthDeviceService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService
) {}
public constructor(private readonly prismaService: PrismaService) {}
public async authDevice(
where: Prisma.AuthDeviceWhereUniqueInput

View File

@ -3,7 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client';
import { Strategy } from 'passport-google-oauth20';
import { Profile, Strategy } from 'passport-google-oauth20';
import { AuthService } from './auth.service';
@ -11,7 +11,7 @@ import { AuthService } from './auth.service';
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
public constructor(
private readonly authService: AuthService,
readonly configurationService: ConfigurationService
private readonly configurationService: ConfigurationService
) {
super({
callbackURL: `${configurationService.get(
@ -20,7 +20,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
clientID: configurationService.get('GOOGLE_CLIENT_ID'),
clientSecret: configurationService.get('GOOGLE_SECRET'),
passReqToCallback: true,
scope: ['email', 'profile']
scope: ['profile']
});
}
@ -28,20 +28,17 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
request: any,
token: string,
refreshToken: string,
profile,
profile: Profile,
done: Function,
done2: Function
) {
try {
const jwt: string = await this.authService.validateOAuthLogin({
const jwt = await this.authService.validateOAuthLogin({
provider: Provider.GOOGLE,
thirdPartyId: profile.id
});
const user = {
jwt
};
done(null, user);
done(null, { jwt });
} catch (error) {
Logger.error(error, 'GoogleStrategy');
done(error, false);

View File

@ -2,10 +2,12 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { HttpException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import * as countriesAndTimezones from 'countries-and-timezones';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
@ -29,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
if (user) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
@ -45,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return user;
} else {
throw '';
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
} catch (error) {
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) {
throw error;
} else {
throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED
);
}
} catch (err) {
throw new UnauthorizedException('unauthorized', err.message);
}
}
}

View File

@ -1,12 +1,12 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import type {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
BenchmarkResponse,
UniqueAsset
BenchmarkResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
@ -41,7 +41,9 @@ export class BenchmarkController {
@HasPermission(permissions.accessAdminControl)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
public async addBenchmark(
@Body() { dataSource, symbol }: AssetProfileIdentifier
) {
try {
const benchmark = await this.benchmarkService.addBenchmark({
dataSource,
@ -105,7 +107,7 @@ export class BenchmarkController {
@Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol(
public async getBenchmarkMarketDataForUser(
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string,
@ -117,7 +119,7 @@ export class BenchmarkController {
);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataBySymbol({
return this.benchmarkService.getMarketDataForUser({
dataSource,
endDate,
startDate,

View File

@ -1,6 +1,7 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
@ -17,7 +18,6 @@ import { BenchmarkService } from './benchmark.service';
controllers: [BenchmarkController],
exports: [BenchmarkService],
imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
@ -25,7 +25,9 @@ import { BenchmarkService } from './benchmark.service';
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [BenchmarkService]
})

View File

@ -17,11 +17,11 @@ import {
resetHours
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
Benchmark,
BenchmarkMarketDataDetails,
BenchmarkProperty,
BenchmarkResponse,
UniqueAsset
BenchmarkResponse
} from '@ghostfolio/common/interfaces';
import { BenchmarkTrend } from '@ghostfolio/common/types';
@ -29,15 +29,19 @@ import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import {
addHours,
differenceInDays,
eachDayOfInterval,
format,
isAfter,
isSameDay,
subDays
} from 'date-fns';
import { isNumber, last, uniqBy } from 'lodash';
import ms from 'ms';
import { BenchmarkValue } from './interfaces/benchmark-value.interface';
@Injectable()
export class BenchmarkService {
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
@ -61,7 +65,10 @@ export class BenchmarkService {
return 0;
}
public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) {
public async getBenchmarkTrends({
dataSource,
symbol
}: AssetProfileIdentifier) {
const historicalData = await this.marketDataService.marketDataItems({
orderBy: {
date: 'desc'
@ -89,94 +96,26 @@ export class BenchmarkService {
enableSharing = false,
useCache = true
} = {}): Promise<BenchmarkResponse['benchmarks']> {
let benchmarks: BenchmarkResponse['benchmarks'];
if (useCache) {
try {
benchmarks = JSON.parse(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
const cachedBenchmarkValue = await this.redisCacheService.get(
this.CACHE_KEY_BENCHMARKS
);
if (benchmarks) {
return benchmarks;
const { benchmarks, expiration }: BenchmarkValue =
JSON.parse(cachedBenchmarkValue);
if (isAfter(new Date(), new Date(expiration))) {
this.calculateAndCacheBenchmarks({
enableSharing
});
}
return benchmarks;
} catch {}
}
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
enableSharing
});
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
[];
const promisesBenchmarkTrends: Promise<{
trend50d: BenchmarkTrend;
trend200d: BenchmarkTrend;
}>[] = [];
const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
requestTimeout: ms('30 seconds'),
useCache: false
});
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
promisesAllTimeHighs.push(
this.marketDataService.getMax({ dataSource, symbol })
);
promisesBenchmarkTrends.push(
this.getBenchmarkTrends({ dataSource, symbol })
);
}
const [allTimeHighs, benchmarkTrends] = await Promise.all([
Promise.all(promisesAllTimeHighs),
Promise.all(promisesBenchmarkTrends)
]);
let storeInCache = true;
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } =
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh?.marketPrice && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh.marketPrice,
marketPrice
);
} else {
storeInCache = false;
}
return {
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
),
name: benchmarkAssetProfiles[index].name,
performances: {
allTimeHigh: {
date: allTimeHigh?.date,
performancePercent: performancePercentFromAllTimeHigh
}
},
trend50d: benchmarkTrends[index].trend50d,
trend200d: benchmarkTrends[index].trend200d
};
});
if (storeInCache) {
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks),
ms('2 hours') / 1000
);
}
return benchmarks;
return this.calculateAndCacheBenchmarks({ enableSharing });
}
public async getBenchmarkAssetProfiles({
@ -213,7 +152,7 @@ export class BenchmarkService {
.sort((a, b) => a.name.localeCompare(b.name));
}
public async getMarketDataBySymbol({
public async getMarketDataForUser({
dataSource,
endDate = new Date(),
startDate,
@ -223,7 +162,7 @@ export class BenchmarkService {
endDate?: Date;
startDate: Date;
userCurrency: string;
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = [];
const days = differenceInDays(endDate, startDate) + 1;
@ -343,7 +282,7 @@ export class BenchmarkService {
public async addBenchmark({
dataSource,
symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: {
dataSource,
@ -380,7 +319,7 @@ export class BenchmarkService {
public async deleteBenchmark({
dataSource,
symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: {
dataSource,
@ -414,10 +353,99 @@ export class BenchmarkService {
};
}
private async calculateAndCacheBenchmarks({
enableSharing = false
}): Promise<BenchmarkResponse['benchmarks']> {
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
enableSharing
});
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
[];
const promisesBenchmarkTrends: Promise<{
trend50d: BenchmarkTrend;
trend200d: BenchmarkTrend;
}>[] = [];
const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}),
requestTimeout: ms('30 seconds'),
useCache: false
});
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
promisesAllTimeHighs.push(
this.marketDataService.getMax({ dataSource, symbol })
);
promisesBenchmarkTrends.push(
this.getBenchmarkTrends({ dataSource, symbol })
);
}
const [allTimeHighs, benchmarkTrends] = await Promise.all([
Promise.all(promisesAllTimeHighs),
Promise.all(promisesBenchmarkTrends)
]);
let storeInCache = true;
const benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } =
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh?.marketPrice && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh.marketPrice,
marketPrice
);
} else {
storeInCache = false;
}
return {
dataSource: benchmarkAssetProfiles[index].dataSource,
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
),
name: benchmarkAssetProfiles[index].name,
performances: {
allTimeHigh: {
date: allTimeHigh?.date,
performancePercent:
performancePercentFromAllTimeHigh >= 0
? 0
: performancePercentFromAllTimeHigh
}
},
symbol: benchmarkAssetProfiles[index].symbol,
trend50d: benchmarkTrends[index].trend50d,
trend200d: benchmarkTrends[index].trend200d
};
});
if (storeInCache) {
const expiration = addHours(new Date(), 2);
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(<BenchmarkValue>{
benchmarks,
expiration: expiration.getTime()
}),
ms('12 hours') / 1000
);
}
return benchmarks;
}
private getMarketCondition(
aPerformanceInPercent: number
): Benchmark['marketCondition'] {
if (aPerformanceInPercent === 0) {
if (aPerformanceInPercent >= 0) {
return 'ALL_TIME_HIGH';
} else if (aPerformanceInPercent <= -0.2) {
return 'BEAR_MARKET';

View File

@ -0,0 +1,6 @@
import { BenchmarkResponse } from '@ghostfolio/common/interfaces';
export interface BenchmarkValue {
benchmarks: BenchmarkResponse['benchmarks'];
expiration: number;
}

View File

@ -14,6 +14,6 @@ export class CacheController {
@Post('flush')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async flushCache(): Promise<void> {
return this.redisCacheService.reset();
await this.redisCacheService.reset();
}
}

View File

@ -1,10 +1,4 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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';
@ -12,14 +6,6 @@ import { CacheController } from './cache.controller';
@Module({
controllers: [CacheController],
imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule
]
imports: [RedisCacheModule]
})
export class CacheModule {}

View File

@ -1,10 +1,6 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { Module } from '@nestjs/common';
@ -12,15 +8,7 @@ import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [
AccountModule,
ApiModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
OrderModule,
RedisCacheModule
],
imports: [AccountModule, ApiModule, OrderModule],
controllers: [ExportController],
providers: [ExportService]
})

View File

@ -1,4 +1,4 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import {
Controller,

View File

@ -1,4 +1,4 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -9,7 +9,11 @@ import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
imports: [
DataEnhancerModule,
DataProviderModule,
TransformDataSourceInRequestModule
],
providers: [HealthService]
})
export class HealthModule {}

View File

@ -1,7 +1,7 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';

View File

@ -4,6 +4,8 @@ import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -30,7 +32,9 @@ import { ImportService } from './import.service';
PortfolioModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [ImportService]
})

View File

@ -13,16 +13,13 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
} from '@ghostfolio/common/config';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import {
AccountWithPlatform,
OrderWithAccount,
@ -54,7 +51,7 @@ export class ImportService {
dataSource,
symbol,
userCurrency
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
}: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
try {
const { firstBuyDate, historicalData, orders } =
await this.portfolioService.getPosition(dataSource, undefined, symbol);
@ -75,9 +72,13 @@ export class ImportService {
})
]);
const accounts = orders.map((order) => {
return order.Account;
});
const accounts = orders
.filter(({ Account }) => {
return !!Account;
})
.map(({ Account }) => {
return Account;
});
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
@ -295,6 +296,7 @@ export class ImportService {
figi,
figiComposite,
figiShareClass,
holdings,
id,
isin,
name,
@ -367,6 +369,7 @@ export class ImportService {
figi,
figiComposite,
figiShareClass,
holdings,
id,
isin,
name,
@ -416,6 +419,11 @@ export class ImportService {
User: { connect: { id: user.id } },
userId: user.id
});
if (order.SymbolProfile?.symbol) {
// Update symbol that may have been assigned in createOrder()
assetProfile.symbol = order.SymbolProfile.symbol;
}
}
const value = new Big(quantity).mul(unitPrice).toNumber();
@ -533,6 +541,7 @@ export class ImportService {
assetSubClass: undefined,
countries: undefined,
createdAt: undefined,
holdings: undefined,
id: undefined,
sectors: undefined,
updatedAt: undefined

View File

@ -1,4 +1,4 @@
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseInterceptors } from '@nestjs/common';

View File

@ -2,11 +2,11 @@ import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -34,6 +34,7 @@ import { InfoService } from './info.service';
RedisCacheModule,
SymbolProfileModule,
TagModule,
TransformDataSourceInResponseModule,
UserModule
],
providers: [InfoService]

View File

@ -1,4 +1,4 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import {
Controller,

View File

@ -1,3 +1,4 @@
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -8,7 +9,11 @@ import { LogoService } from './logo.service';
@Module({
controllers: [LogoController],
imports: [ConfigurationModule, SymbolProfileModule],
imports: [
ConfigurationModule,
SymbolProfileModule,
TransformDataSourceInRequestModule
],
providers: [LogoService]
})
export class LogoModule {}

View File

@ -1,6 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@ -17,7 +17,7 @@ export class LogoService {
public async getLogoByDataSourceAndSymbol({
dataSource,
symbol
}: UniqueAsset) {
}: AssetProfileIdentifier) {
if (!DataSource[dataSource]) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),

View File

@ -1,3 +1,6 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import {
AssetClass,
AssetSubClass,
@ -10,12 +13,12 @@ import {
IsArray,
IsBoolean,
IsEnum,
IsISO4217CurrencyCode,
IsISO8601,
IsNumber,
IsOptional,
IsString,
Min
Min,
Validate
} from 'class-validator';
import { isString } from 'lodash';
@ -39,10 +42,10 @@ export class CreateOrderDto {
)
comment?: string;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
currency: string;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
@IsOptional()
customCurrency?: string;
@ -51,6 +54,7 @@ export class CreateOrderDto {
dataSource?: DataSource;
@IsISO8601()
@Validate(IsAfter1970Constraint)
date: string;
@IsNumber()

View File

@ -1,9 +1,9 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.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 { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
@ -11,7 +11,7 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import {
@ -36,7 +36,7 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto';
import { Activities } from './interfaces/activities.interface';
import { Activities, Activity } from './interfaces/activities.interface';
import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto';
@ -53,22 +53,33 @@ export class OrderController {
@Delete()
@HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrders(): Promise<number> {
public async deleteOrders(
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<number> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
return this.orderService.deleteOrders({
filters,
userId: this.request.user.id
});
}
@Delete(':id')
@HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
const order = await this.orderService.order({ id });
const order = await this.orderService.order({
id,
userId: this.request.user.id
});
if (
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
!order ||
order.userId !== this.request.user.id
) {
if (!order) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -88,21 +99,26 @@ export class OrderController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('range') dateRange?: DateRange,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('tags') filterByTags?: string,
@Query('take') take?: number
): Promise<Activities> {
let endDate: Date;
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getInterval(dateRange));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
const { endDate, startDate } = getInterval(dateRange);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
@ -124,6 +140,38 @@ export class OrderController {
return { activities, count };
}
@Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getOrderById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Param('id') id: string
): Promise<Activity> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { activities } = await this.orderService.getOrders({
userCurrency,
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
const activity = activities.find((activity) => {
return activity.id === id;
});
if (!activity) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return activity;
}
@HasPermission(permissions.createOrder)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)

View File

@ -2,9 +2,10 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -23,15 +24,16 @@ import { OrderService } from './order.service';
imports: [
ApiModule,
CacheModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
PrismaModule,
RedactValuesInResponseModule,
RedisCacheModule,
SymbolProfileModule,
UserModule
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [AccountBalanceService, AccountService, OrderService]
})

View File

@ -1,4 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -9,10 +10,15 @@ import {
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
AssetClass,
AssetSubClass,
@ -27,7 +33,6 @@ import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { CreateOrderDto } from './create-order.dto';
import { Activities } from './interfaces/activities.interface';
@Injectable()
@ -35,11 +40,45 @@ export class OrderService {
public constructor(
private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async assignTags({
dataSource,
symbol,
tags,
userId
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
const orders = await this.prismaService.order.findMany({
where: {
userId,
SymbolProfile: {
dataSource,
symbol
}
}
});
return Promise.all(
orders.map(({ id }) =>
this.prismaService.order.update({
data: {
tags: {
// The set operation replaces all existing connections with the provided ones
set: tags.map(({ id }) => {
return { id };
})
}
},
where: { id }
})
)
);
}
public async createOrder(
data: Prisma.OrderCreateInput & {
accountId?: string;
@ -138,7 +177,8 @@ export class OrderService {
return { id };
})
}
}
},
include: { SymbolProfile: true }
});
if (updateAccountBalance === true) {
@ -160,6 +200,13 @@ export class OrderService {
});
}
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order;
}
@ -170,22 +217,75 @@ export class OrderService {
where
});
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) {
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesByIds([
order.symbolProfileId
]);
if (
['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type) ||
symbolProfile.activitiesCount === 0
) {
await this.symbolProfileService.deleteById(order.symbolProfileId);
}
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order;
}
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> {
const { count } = await this.prismaService.order.deleteMany({
where
public async deleteOrders({
filters,
userId
}: {
filters?: Filter[];
userId: string;
}): Promise<number> {
const { activities } = await this.getOrders({
filters,
userId,
includeDrafts: true,
userCurrency: undefined,
withExcludedAccounts: true
});
const { count } = await this.prismaService.order.deleteMany({
where: {
id: {
in: activities.map(({ id }) => {
return id;
})
}
}
});
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesByIds(
activities.map(({ symbolProfileId }) => {
return symbolProfileId;
})
);
for (const { activitiesCount, id } of symbolProfiles) {
if (activitiesCount === 0) {
await this.symbolProfileService.deleteById(id);
}
}
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ userId })
);
return count;
}
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({
orderBy: {
date: 'desc'
@ -224,7 +324,8 @@ export class OrderService {
withExcludedAccounts?: boolean;
}): Promise<Activities> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' }
{ date: 'asc' },
{ id: 'asc' }
];
const where: Prisma.OrderWhereInput = { userId };
@ -244,10 +345,14 @@ export class OrderService {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
} = groupBy(filters, ({ type }) => {
return type;
});
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (filtersByAccount?.length > 0) {
where.accountId = {
in: filtersByAccount.map(({ id }) => {
@ -289,6 +394,30 @@ export class OrderService {
};
}
if (searchQuery) {
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
if (where.SymbolProfile) {
where.SymbolProfile = {
AND: [
where.SymbolProfile,
{
OR: searchQueryWhereInput
}
]
};
} else {
where.SymbolProfile = {
OR: searchQueryWhereInput
};
}
}
if (filtersByTag?.length > 0) {
where.tags = {
some: {
@ -300,7 +429,7 @@ export class OrderService {
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }];
}
if (types) {
@ -335,7 +464,7 @@ export class OrderService {
this.prismaService.order.count({ where })
]);
const uniqueAssets = uniqBy(
const assetProfileIdentifiers = uniqBy(
orders.map(({ SymbolProfile }) => {
return {
dataSource: SymbolProfile.dataSource,
@ -350,8 +479,9 @@ export class OrderService {
}
);
const assetProfiles =
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
const assetProfiles = await this.symbolProfileService.getSymbolProfiles(
assetProfileIdentifiers
);
const activities = orders.map((order) => {
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
@ -385,6 +515,26 @@ export class OrderService {
return { activities, count };
}
public async getStatisticsByCurrency(
currency: EnhancedSymbolProfile['currency']
): Promise<{
activitiesCount: EnhancedSymbolProfile['activitiesCount'];
dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
}> {
const { _count, _min } = await this.prismaService.order.aggregate({
_count: true,
_min: {
date: true
},
where: { SymbolProfile: { currency } }
});
return {
activitiesCount: _count as number,
dateOfFirstActivity: _min.date
};
}
public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> {
@ -455,7 +605,7 @@ export class OrderService {
where
});
return this.prismaService.order.update({
const order = await this.prismaService.order.update({
data: {
...data,
isDraft,
@ -467,6 +617,15 @@ export class OrderService {
},
where
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order;
}
private async orders(params: {

View File

@ -1,3 +1,6 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import {
AssetClass,
AssetSubClass,
@ -9,12 +12,12 @@ import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsEnum,
IsISO4217CurrencyCode,
IsISO8601,
IsNumber,
IsOptional,
IsString,
Min
Min,
Validate
} from 'class-validator';
import { isString } from 'lodash';
@ -38,10 +41,10 @@ export class UpdateOrderDto {
)
comment?: string;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
currency: string;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
@IsOptional()
customCurrency?: string;
@ -49,6 +52,7 @@ export class UpdateOrderDto {
dataSource: DataSource;
@IsISO8601()
@Validate(IsAfter1970Constraint)
date: string;
@IsNumber()

View File

@ -1,10 +1,9 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface';
import {
SymbolMetrics,
TimelinePosition,
UniqueAsset
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
export class MWRPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(
@ -31,7 +30,7 @@ export class MWRPortfolioCalculator extends PortfolioCalculator {
};
start: Date;
step?: number;
} & UniqueAsset): SymbolMetrics {
} & AssetProfileIdentifier): SymbolMetrics {
throw new Error('Method not implemented.');
}
}

View File

@ -20,7 +20,12 @@ export const symbolProfileDummyData = {
assetSubClass: undefined,
countries: [],
createdAt: undefined,
holdings: [],
id: undefined,
sectors: [],
updatedAt: undefined
};
export const userDummyData = {
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
};

View File

@ -1,8 +1,10 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
@ -18,8 +20,10 @@ export enum PerformanceCalculationType {
@Injectable()
export class PortfolioCalculatorFactory {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService
) {}
public createCalculator({
@ -27,14 +31,22 @@ export class PortfolioCalculatorFactory {
activities,
calculationType,
currency,
dateRange = 'max'
dateRange = 'max',
hasFilters,
isExperimentalFeatures = false,
userId
}: {
accountBalanceItems?: HistoricalDataItem[];
activities: Activity[];
calculationType: PerformanceCalculationType;
currency: string;
dateRange?: DateRange;
hasFilters: boolean;
isExperimentalFeatures?: boolean;
userId: string;
}): PortfolioCalculator {
const useCache = !hasFilters && isExperimentalFeatures;
switch (calculationType) {
case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({
@ -42,8 +54,12 @@ export class PortfolioCalculatorFactory {
activities,
currency,
dateRange,
useCache,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.TWR:
return new TWRPortfolioCalculator({
@ -52,7 +68,11 @@ export class PortfolioCalculatorFactory {
currency,
currentRateService: this.currentRateService,
dateRange,
exchangeRateDataService: this.exchangeRateDataService
useCache,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
});
default:
throw new Error('Invalid calculation type');

View File

@ -1,13 +1,14 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface';
import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import {
getFactor,
getInterval
} from '@ghostfolio/api/helper/portfolio.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MAX_CHART_ITEMS } from '@ghostfolio/common/config';
@ -18,22 +19,25 @@ import {
resetHours
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
DataProviderInfo,
HistoricalDataItem,
InvestmentItem,
ResponseError,
SymbolMetrics,
TimelinePosition,
UniqueAsset
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange, GroupBy } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { plainToClass } from 'class-transformer';
import {
differenceInDays,
eachDayOfInterval,
endOfDay,
format,
isAfter,
isBefore,
isSameDay,
max,
@ -46,54 +50,88 @@ export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false;
protected accountBalanceItems: HistoricalDataItem[];
protected orders: PortfolioOrder[];
protected activities: PortfolioOrder[];
private configurationService: ConfigurationService;
private currency: string;
private currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[];
private dateRange: DateRange;
private endDate: Date;
private exchangeRateDataService: ExchangeRateDataService;
private redisCacheService: RedisCacheService;
private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>;
private startDate: Date;
private transactionPoints: TransactionPoint[];
private useCache: boolean;
private userId: string;
public constructor({
accountBalanceItems,
activities,
configurationService,
currency,
currentRateService,
dateRange,
exchangeRateDataService
exchangeRateDataService,
redisCacheService,
useCache,
userId
}: {
accountBalanceItems: HistoricalDataItem[];
activities: Activity[];
configurationService: ConfigurationService;
currency: string;
currentRateService: CurrentRateService;
dateRange: DateRange;
exchangeRateDataService: ExchangeRateDataService;
redisCacheService: RedisCacheService;
useCache: boolean;
userId: string;
}) {
this.accountBalanceItems = accountBalanceItems;
this.configurationService = configurationService;
this.currency = currency;
this.currentRateService = currentRateService;
this.dateRange = dateRange;
this.exchangeRateDataService = exchangeRateDataService;
this.orders = activities.map(
({ date, fee, quantity, SymbolProfile, tags = [], type, unitPrice }) => {
return {
SymbolProfile,
tags,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
};
}
);
this.orders.sort((a, b) => {
return a.date?.localeCompare(b.date);
});
this.activities = activities
.map(
({
date,
fee,
quantity,
SymbolProfile,
tags = [],
type,
unitPrice
}) => {
if (isAfter(date, new Date(Date.now()))) {
// Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval
date = endOfDay(new Date(Date.now()));
}
return {
SymbolProfile,
tags,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
};
}
)
.sort((a, b) => {
return a.date?.localeCompare(b.date);
});
this.redisCacheService = redisCacheService;
this.useCache = useCache;
this.userId = userId;
const { endDate, startDate } = getInterval(dateRange);
@ -262,6 +300,12 @@ export abstract class PortfolioCalculator {
const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) {
const feeInBaseCurrency = item.fee.mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
lastTransactionPoint.date
]
);
const marketPriceInBaseCurrency = (
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
).mul(
@ -302,24 +346,25 @@ export abstract class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
positions.push({
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
feeInBaseCurrency,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
averagePrice: item.averagePrice,
currency: item.currency,
dataSource: item.dataSource,
fee: item.fee,
firstBuyDate: item.firstBuyDate,
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
grossPerformance: !hasErrors ? (grossPerformance ?? null) : null,
grossPerformancePercentage: !hasErrors
? grossPerformancePercentage ?? null
? (grossPerformancePercentage ?? null)
: null,
grossPerformancePercentageWithCurrencyEffect: !hasErrors
? grossPerformancePercentageWithCurrencyEffect ?? null
? (grossPerformancePercentageWithCurrencyEffect ?? null)
: null,
grossPerformanceWithCurrencyEffect: !hasErrors
? grossPerformanceWithCurrencyEffect ?? null
? (grossPerformanceWithCurrencyEffect ?? null)
: null,
investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
@ -327,15 +372,15 @@ export abstract class PortfolioCalculator {
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null,
marketPriceInBaseCurrency:
marketPriceInBaseCurrency?.toNumber() ?? null,
netPerformance: !hasErrors ? netPerformance ?? null : null,
netPerformance: !hasErrors ? (netPerformance ?? null) : null,
netPerformancePercentage: !hasErrors
? netPerformancePercentage ?? null
? (netPerformancePercentage ?? null)
: null,
netPerformancePercentageWithCurrencyEffect: !hasErrors
? netPerformancePercentageWithCurrencyEffect ?? null
? (netPerformancePercentageWithCurrencyEffect ?? null)
: null,
netPerformanceWithCurrencyEffect: !hasErrors
? netPerformanceWithCurrencyEffect ?? null
? (netPerformanceWithCurrencyEffect ?? null)
: null,
quantity: item.quantity,
symbol: item.symbol,
@ -860,7 +905,7 @@ export abstract class PortfolioCalculator {
};
start: Date;
step?: number;
} & UniqueAsset): SymbolMetrics;
} & AssetProfileIdentifier): SymbolMetrics;
public getTransactionPoints() {
return this.transactionPoints;
@ -887,7 +932,7 @@ export abstract class PortfolioCalculator {
tags,
type,
unitPrice
} of this.orders) {
} of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[SymbolProfile.symbol];
@ -1011,6 +1056,52 @@ export abstract class PortfolioCalculator {
}
private async initialize() {
this.snapshot = await this.computeSnapshot(this.startDate, this.endDate);
if (this.useCache) {
const startTimeTotal = performance.now();
const cachedSnapshot = await this.redisCacheService.get(
this.redisCacheService.getPortfolioSnapshotKey({
userId: this.userId
})
);
if (cachedSnapshot) {
this.snapshot = plainToClass(
PortfolioSnapshot,
JSON.parse(cachedSnapshot)
);
Logger.debug(
`Fetched portfolio snapshot from cache in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`,
'PortfolioCalculator'
);
} else {
this.snapshot = await this.computeSnapshot(
this.startDate,
this.endDate
);
this.redisCacheService.set(
this.redisCacheService.getPortfolioSnapshotKey({
userId: this.userId
}),
JSON.stringify(this.snapshot),
this.configurationService.get('CACHE_QUOTES_TTL')
);
Logger.debug(
`Computed portfolio snapshot in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`,
'PortfolioCalculator'
);
}
} else {
this.snapshot = await this.computeSnapshot(this.startDate, this.endDate);
}
}
}

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});
@ -101,7 +122,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF'
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
@ -145,6 +168,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});
@ -86,7 +107,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF'
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
@ -130,6 +153,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});
@ -71,7 +92,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF'
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
@ -115,6 +138,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1.55'),
feeInBaseCurrency: new Big('1.55'),
firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
@ -24,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
@ -37,11 +50,15 @@ jest.mock(
);
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -51,9 +68,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});
@ -99,7 +120,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF'
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
@ -143,6 +166,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.41978276196153750666'),

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});
@ -71,7 +92,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD'
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
@ -100,6 +123,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('49'),
feeInBaseCurrency: new Big('49'),
firstBuyDate: '2021-09-01',
grossPerformance: null,
grossPerformancePercentage: null,

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
@ -24,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
@ -37,11 +50,15 @@ jest.mock(
);
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -51,9 +68,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});
@ -84,7 +105,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF'
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
@ -128,6 +151,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1'),
feeInBaseCurrency: new Big('0.9238'),
firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33'),
grossPerformancePercentage: new Big('0.3066651705565529623'),
@ -154,7 +178,7 @@ describe('PortfolioCalculator', () => {
valueInBaseCurrency: new Big('103.10483')
}
],
totalFeesWithCurrencyEffect: new Big('1'),
totalFeesWithCurrencyEffect: new Big('0.9238'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12'),
totalInvestmentWithCurrencyEffect: new Big('82.329056'),

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});
@ -71,7 +92,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD'
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
@ -100,6 +123,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-01-01',
grossPerformance: null,
grossPerformancePercentage: null,

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});
@ -53,7 +74,7 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2022-01-01'),
date: new Date('2023-01-01'), // Date in future
fee: 0,
quantity: 1,
SymbolProfile: {
@ -71,64 +92,17 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD'
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-01-01')
);
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [
{
averagePrice: new Big('3000'),
currency: 'USD',
dataSource: 'MANUAL',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
firstBuyDate: '2022-01-01',
grossPerformance: null,
grossPerformancePercentage: null,
grossPerformancePercentageWithCurrencyEffect: null,
grossPerformanceWithCurrencyEffect: null,
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
marketPrice: null,
marketPriceInBaseCurrency: 3000,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
quantity: new Big('0'),
symbol: '55196015-1365-4560-aa60-8751ae6d18f8',
tags: [],
timeWeightedInvestment: new Big('0'),
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
transactionCount: 1,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
const liabilitiesInBaseCurrency =
await portfolioCalculator.getLiabilitiesInBaseCurrency();
expect(liabilitiesInBaseCurrency).toEqual(new Big(3000));
});
});
});

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
@ -24,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
@ -37,11 +50,15 @@ jest.mock(
);
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -51,9 +68,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});
@ -99,7 +120,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD'
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(

View File

@ -1,9 +1,13 @@
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
@ -19,12 +23,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -34,9 +51,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});
@ -49,7 +70,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({
activities: [],
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF'
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const start = subDays(new Date(Date.now()), 10);

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});
@ -86,7 +107,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF'
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
@ -130,6 +153,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.25'),
feeInBaseCurrency: new Big('4.25'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.15113417083448194384'),

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});
@ -86,7 +107,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF'
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
@ -160,6 +183,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),

View File

@ -1,13 +1,19 @@
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -17,9 +23,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});

View File

@ -1,13 +1,12 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
SymbolMetrics,
TimelinePosition,
UniqueAsset
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
@ -38,9 +37,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.fee) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.fee
currentPosition.feeInBaseCurrency
);
}
@ -155,7 +154,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
};
start: Date;
step?: number;
} & UniqueAsset): SymbolMetrics {
} & AssetProfileIdentifier): SymbolMetrics {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const currentValues: { [date: string]: Big } = {};
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
@ -207,7 +206,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(this.orders).filter(
let orders: PortfolioOrderItem[] = cloneDeep(this.activities).filter(
({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
}

View File

@ -8,6 +8,13 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) {
switch (symbol) {
case '55196015-1365-4560-aa60-8751ae6d18f8':
if (isSameDay(parseDate('2022-01-31'), date)) {
return { marketPrice: 3000 };
}
return { marketPrice: 0 };
case 'BALN.SW':
if (isSameDay(parseDate('2021-11-12'), date)) {
return { marketPrice: 146 };

View File

@ -1,7 +1,7 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client';
@ -24,32 +24,32 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
});
},
getRange: ({
assetProfileIdentifiers,
dateRangeEnd,
dateRangeStart,
uniqueAssets
dateRangeStart
}: {
assetProfileIdentifiers: AssetProfileIdentifier[];
dateRangeEnd: Date;
dateRangeStart: Date;
uniqueAssets: UniqueAsset[];
}) => {
return Promise.resolve<MarketData[]>([
{
createdAt: dateRangeStart,
dataSource: uniqueAssets[0].dataSource,
dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902,
state: 'CLOSE',
symbol: uniqueAssets[0].symbol
symbol: assetProfileIdentifiers[0].symbol
},
{
createdAt: dateRangeEnd,
dataSource: uniqueAssets[0].dataSource,
dataSource: assetProfileIdentifiers[0].dataSource,
date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966,
state: 'CLOSE',
symbol: uniqueAssets[0].symbol
symbol: assetProfileIdentifiers[0].symbol
}
]);
}

View File

@ -3,9 +3,9 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { resetHours } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
DataProviderInfo,
ResponseError,
UniqueAsset
ResponseError
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -80,17 +80,16 @@ export class CurrentRateService {
);
}
const uniqueAssets: UniqueAsset[] = dataGatheringItems.map(
({ dataSource, symbol }) => {
const assetProfileIdentifiers: AssetProfileIdentifier[] =
dataGatheringItems.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
}
);
});
promises.push(
this.marketDataService
.getRange({
dateQuery,
uniqueAssets
assetProfileIdentifiers,
dateQuery
})
.then((data) => {
return data.map(({ dataSource, date, marketPrice, symbol }) => {

View File

@ -1,6 +1,6 @@
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
export interface GetValueObject extends UniqueAsset {
export interface GetValueObject extends AssetProfileIdentifier {
date: Date;
marketPrice: number;
}

View File

@ -7,7 +7,7 @@ import {
import { Account, Tag } from '@prisma/client';
export interface PortfolioPositionDetail {
export interface PortfolioHoldingDetail {
accounts: Account[];
averagePrice: number;
dataProviderInfo: DataProviderInfo;

View File

@ -1,5 +0,0 @@
import { Position } from '@ghostfolio/common/interfaces';
export interface PortfolioPositions {
positions: Position[];
}

View File

@ -1,24 +0,0 @@
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import { Big } from 'big.js';
export interface PortfolioSnapshot extends ResponseError {
currentValueInBaseCurrency: Big;
grossPerformance: Big;
grossPerformanceWithCurrencyEffect: Big;
grossPerformancePercentage: Big;
grossPerformancePercentageWithCurrencyEffect: Big;
netAnnualizedPerformance?: Big;
netAnnualizedPerformanceWithCurrencyEffect?: Big;
netPerformance: Big;
netPerformanceWithCurrencyEffect: Big;
netPerformancePercentage: Big;
netPerformancePercentageWithCurrencyEffect: Big;
positions: TimelinePosition[];
totalFeesWithCurrencyEffect: Big;
totalInterestWithCurrencyEffect: Big;
totalInvestment: Big;
totalInvestmentWithCurrencyEffect: Big;
totalLiabilitiesWithCurrencyEffect: Big;
totalValuablesWithCurrencyEffect: Big;
}

View File

@ -1,15 +1,16 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.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 { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -27,6 +28,11 @@ import {
PortfolioPublicDetails,
PortfolioReport
} from '@ghostfolio/common/interfaces';
import {
hasReadRestrictedAccessPermission,
isRestrictedView,
permissions
} from '@ghostfolio/common/permissions';
import type {
DateRange,
GroupBy,
@ -34,12 +40,14 @@ import type {
} from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
Headers,
HttpException,
Inject,
Param,
Put,
Query,
UseGuards,
UseInterceptors,
@ -47,12 +55,13 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@Controller('portfolio')
export class PortfolioController {
@ -84,11 +93,6 @@ export class PortfolioController {
let hasDetails = true;
let hasError = false;
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = this.request.user.subscription.type === 'Premium';
@ -117,8 +121,11 @@ export class PortfolioController {
let portfolioSummary = summary;
if (
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
}) ||
isRestrictedView(this.request.user)
) {
const totalInvestment = Object.values(holdings)
.map(({ investment }) => {
@ -128,14 +135,19 @@ export class PortfolioController {
const totalValue = Object.values(holdings)
.filter(({ assetClass, assetSubClass }) => {
return assetClass !== 'CASH' && assetSubClass !== 'CASH';
return (
assetClass !== AssetClass.LIQUIDITY &&
assetSubClass !== AssetSubClass.CASH
);
})
.map(({ valueInBaseCurrency }) => {
return valueInBaseCurrency;
})
.reduce((a, b) => a + b, 0);
.reduce((a, b) => {
return a + b;
}, 0);
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
for (const [, portfolioPosition] of Object.entries(holdings)) {
portfolioPosition.investment =
portfolioPosition.investment / totalInvestment;
portfolioPosition.valueInPercentage =
@ -153,27 +165,30 @@ export class PortfolioController {
if (
hasDetails === false ||
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
}) ||
isRestrictedView(this.request.user)
) {
portfolioSummary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentGrossPerformance',
'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth',
'currentValue',
'currentValueInBaseCurrency',
'dividendInBaseCurrency',
'emergencyFund',
'excludedAccountsAndActivities',
'fees',
'filteredValueInBaseCurrency',
'fireWealth',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'interest',
'items',
'liabilities',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalBuy',
'totalInvestment',
'totalSell',
@ -185,15 +200,16 @@ export class PortfolioController {
holdings[symbol] = {
...portfolioPosition,
assetClass:
hasDetails || portfolioPosition.assetClass === 'CASH'
hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetClass
: undefined,
assetSubClass:
hasDetails || portfolioPosition.assetSubClass === 'CASH'
hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetSubClass
: undefined,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
holdings: hasDetails ? portfolioPosition.holdings : [],
markets: hasDetails ? portfolioPosition.markets : undefined,
marketsAdvanced: hasDetails
? portfolioPosition.marketsAdvanced
@ -221,12 +237,6 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioDividends> {
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -254,8 +264,11 @@ export class PortfolioController {
});
if (
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
}) ||
isRestrictedView(this.request.user)
) {
const maxDividend = dividends.reduce(
(investment, item) => Math.max(investment, item.investment),
@ -321,12 +334,6 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> {
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -342,8 +349,11 @@ export class PortfolioController {
});
if (
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
}) ||
isRestrictedView(this.request.user)
) {
const maxInvestment = investments.reduce(
(investment, item) => Math.max(investment, item.investment),
@ -392,12 +402,6 @@ export class PortfolioController {
): Promise<PortfolioPerformanceResponse> {
const withExcludedAccounts = withExcludedAccountsParam === 'true';
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
});
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -413,9 +417,12 @@ export class PortfolioController {
});
if (
hasReadRestrictedAccessPermission ||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
this.userService.isRestrictedView(this.request.user)
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
}) ||
isRestrictedView(this.request.user) ||
this.request.user.Settings.settings.viewMode === 'ZEN'
) {
performanceInformation.chart = performanceInformation.chart.map(
({
@ -443,10 +450,14 @@ export class PortfolioController {
.div(performanceInformation.performance.totalInvestment)
.toNumber(),
valueInPercentage:
performanceInformation.performance.currentValue === 0
performanceInformation.performance.currentValueInBaseCurrency ===
0
? 0
: new Big(value)
.div(performanceInformation.performance.currentValue)
.div(
performanceInformation.performance
.currentValueInBaseCurrency
)
.toNumber()
};
}
@ -455,12 +466,12 @@ export class PortfolioController {
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,
[
'currentGrossPerformance',
'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth',
'currentValue',
'currentValueInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalInvestment'
]
);
@ -477,48 +488,19 @@ export class PortfolioController {
);
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,
['currentNetPerformance', 'currentNetPerformancePercent']
['netPerformance']
);
}
return performanceInformation;
}
@Get('positions')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioPositions> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterBySearchQuery,
filterByTags
});
return this.portfolioService.getPositions({
dateRange,
filters,
impersonationId
});
}
@Get('public/:accessId')
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublic(
@Param('accessId') accessId
): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({
id: access.userId
});
if (!access) {
throw new HttpException(
@ -528,6 +510,11 @@ export class PortfolioController {
}
let hasDetails = true;
const user = await this.userService.user({
id: access.userId
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium';
}
@ -584,23 +571,23 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPosition(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource,
@Param('symbol') symbol
): Promise<PortfolioPositionDetail> {
const position = await this.portfolioService.getPosition(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<PortfolioHoldingDetail> {
const holding = await this.portfolioService.getPosition(
dataSource,
impersonationId,
symbol
);
if (position) {
return position;
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
return holding;
}
@Get('report')
@ -623,4 +610,36 @@ export class PortfolioController {
return report;
}
@HasPermission(permissions.updateOrder)
@Put('position/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateHoldingTags(
@Body() data: UpdateHoldingTagsDto,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
const holding = await this.portfolioService.getPosition(
dataSource,
impersonationId,
symbol
);
if (!holding) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
await this.portfolioService.updateTags({
dataSource,
impersonationId,
symbol,
tags: data.tags,
userId: this.request.user.id
});
}
}

View File

@ -2,7 +2,11 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -35,7 +39,11 @@ import { RulesService } from './rules.service';
MarketDataModule,
OrderModule,
PrismaModule,
RedactValuesInResponseModule,
RedisCacheModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule,
UserModule
],
providers: [

View File

@ -1,78 +0,0 @@
import { Big } from 'big.js';
import { PortfolioService } from './portfolio.service';
describe('PortfolioService', () => {
let portfolioService: PortfolioService;
beforeAll(async () => {
portfolioService = new PortfolioService(
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
);
});
describe('annualized performance percentage', () => {
it('Get annualized performance', async () => {
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercent: new Big(0)
})
.toNumber()
).toEqual(0);
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 0,
netPerformancePercent: new Big(0)
})
.toNumber()
).toEqual(0);
/**
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
*/
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year
netPerformancePercent: new Big(0.1025)
})
.toNumber()
).toBeCloseTo(0.729705);
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year
netPerformancePercent: new Big(0.05)
})
.toNumber()
).toBeCloseTo(0.05);
/**
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
*/
expect(
portfolioService
.getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year
netPerformancePercent: new Big(0.2374)
})
.toNumber()
).toBeCloseTo(0.145);
});
});
});

View File

@ -18,6 +18,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
import {
DEFAULT_CURRENCY,
EMERGENCY_FUND_TAG_ID,
@ -29,6 +30,7 @@ import {
EnhancedSymbolProfile,
Filter,
HistoricalDataItem,
InvestmentItem,
PortfolioDetails,
PortfolioInvestments,
PortfolioPerformanceResponse,
@ -36,10 +38,9 @@ import {
PortfolioReport,
PortfolioSummary,
Position,
TimelinePosition,
UserSettings
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { TimelinePosition } from '@ghostfolio/common/models';
import type {
AccountWithValue,
DateRange,
@ -54,10 +55,12 @@ import {
Account,
Type as ActivityType,
AssetClass,
AssetSubClass,
DataSource,
Order,
Platform,
Prisma
Prisma,
Tag
} from '@prisma/client';
import { Big } from 'big.js';
import {
@ -69,14 +72,14 @@ import {
parseISO,
set
} from 'date-fns';
import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash';
import { isEmpty, uniq, uniqBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from './calculator/portfolio-calculator.factory';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
import { RulesService } from './rules.service';
const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json');
@ -205,24 +208,6 @@ export class PortfolioService {
};
}
public getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent
}: {
daysInMarket: number;
netPerformancePercent: Big;
}): Big {
if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber();
return new Big(
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
).minus(1);
}
return new Big(0);
}
public async getDividends({
activities,
groupBy
@ -276,8 +261,13 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
dateRange,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency
currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
});
const items = await portfolioCalculator.getChart({
@ -351,8 +341,12 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
dateRange,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency
currency: userCurrency,
hasFilters: true, // disable cache
isExperimentalFeatures:
this.request.user?.Settings.settings.isExperimentalFeatures
});
const { currentValueInBaseCurrency, hasErrors, positions } =
@ -376,7 +370,7 @@ export class PortfolioService {
}) ?? false;
const isFilteredByCash = filters?.some(({ id, type }) => {
return id === 'CASH' && type === 'ASSET_CLASS';
return id === AssetClass.LIQUIDITY && type === 'ASSET_CLASS';
});
const isFilteredByClosedHoldings =
@ -391,8 +385,8 @@ export class PortfolioService {
if (
filters?.length === 0 ||
(filters?.length === 1 &&
filters[0].type === 'ASSET_CLASS' &&
filters[0].id === 'CASH')
filters[0].id === AssetClass.LIQUIDITY &&
filters[0].type === 'ASSET_CLASS')
) {
filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus(
cashDetails.balanceInBaseCurrency
@ -489,6 +483,17 @@ export class PortfolioService {
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
holdings: assetProfile.holdings.map(
({ allocationInPercentage, name }) => {
return {
allocationInPercentage,
name,
valueInBaseCurrency: valueInBaseCurrency
.mul(allocationInPercentage)
.toNumber()
};
}
),
investment: investment.toNumber(),
marketState: dataProviderResponse?.marketState ?? 'delayed',
name: assetProfile.name,
@ -592,7 +597,7 @@ export class PortfolioService {
aDataSource: DataSource,
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
): Promise<PortfolioHoldingDetail> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
@ -647,11 +652,15 @@ export class PortfolioService {
]);
const portfolioCalculator = this.calculatorFactory.createCalculator({
userId,
activities: orders.filter((order) => {
return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type);
}),
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency
currency: userCurrency,
hasFilters: true,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
});
const portfolioStart = portfolioCalculator.getStartDate();
@ -679,7 +688,7 @@ export class PortfolioService {
transactionCount
} = position;
const accounts: PortfolioPositionDetail['accounts'] = uniqBy(
const accounts: PortfolioHoldingDetail['accounts'] = uniqBy(
orders.filter(({ Account }) => {
return Account;
}),
@ -688,19 +697,23 @@ export class PortfolioService {
return Account;
});
const dividendYieldPercent = this.getAnnualizedPerformancePercent({
const dividendYieldPercent = getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercent: dividendInBaseCurrency.div(
timeWeightedInvestment
)
netPerformancePercentage: timeWeightedInvestment.eq(0)
? new Big(0)
: dividendInBaseCurrency.div(timeWeightedInvestment)
});
const dividendYieldPercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({
getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercent: dividendInBaseCurrency.div(
timeWeightedInvestmentWithCurrencyEffect
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
0
)
? new Big(0)
: dividendInBaseCurrency.div(
timeWeightedInvestmentWithCurrencyEffect
)
});
const historicalData = await this.dataProviderService.getHistorical(
@ -918,8 +931,12 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
dateRange,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency
currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
});
let { hasErrors, positions } = await portfolioCalculator.getSnapshot();
@ -1072,7 +1089,7 @@ export class PortfolioService {
)
);
const { endDate, startDate } = getInterval(dateRange);
const { endDate } = getInterval(dateRange);
const { activities } = await this.orderService.getOrders({
endDate,
@ -1088,16 +1105,16 @@ export class PortfolioService {
firstOrderDate: undefined,
hasErrors: false,
performance: {
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentGrossPerformancePercentWithCurrencyEffect: 0,
currentGrossPerformanceWithCurrencyEffect: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentNetPerformancePercentWithCurrencyEffect: 0,
currentNetPerformanceWithCurrencyEffect: 0,
currentNetWorth: 0,
currentValue: 0,
currentValueInBaseCurrency: 0,
grossPerformance: 0,
grossPerformancePercentage: 0,
grossPerformancePercentageWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
netPerformance: 0,
netPerformancePercentage: 0,
netPerformancePercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
totalInvestment: 0
}
};
@ -1107,8 +1124,12 @@ export class PortfolioService {
accountBalanceItems,
activities,
dateRange,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency
currency: userCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
});
const {
@ -1128,9 +1149,9 @@ export class PortfolioService {
let currentNetPerformance = netPerformance;
let currentNetPerformancePercent = netPerformancePercentage;
let currentNetPerformancePercentage = netPerformancePercentage;
let currentNetPerformancePercentWithCurrencyEffect =
let currentNetPerformancePercentageWithCurrencyEffect =
netPerformancePercentageWithCurrencyEffect;
let currentNetPerformanceWithCurrencyEffect =
@ -1149,11 +1170,11 @@ export class PortfolioService {
if (itemOfToday) {
currentNetPerformance = new Big(itemOfToday.netPerformance);
currentNetPerformancePercent = new Big(
currentNetPerformancePercentage = new Big(
itemOfToday.netPerformanceInPercentage
).div(100);
currentNetPerformancePercentWithCurrencyEffect = new Big(
currentNetPerformancePercentageWithCurrencyEffect = new Big(
itemOfToday.netPerformanceInPercentageWithCurrencyEffect
).div(100);
@ -1171,19 +1192,19 @@ export class PortfolioService {
firstOrderDate: parseDate(items[0]?.date),
performance: {
currentNetWorth,
currentGrossPerformance: grossPerformance.toNumber(),
currentGrossPerformancePercent: grossPerformancePercentage.toNumber(),
currentGrossPerformancePercentWithCurrencyEffect:
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
grossPerformance: grossPerformance.toNumber(),
grossPerformancePercentage: grossPerformancePercentage.toNumber(),
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect.toNumber(),
currentGrossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(),
currentNetPerformance: currentNetPerformance.toNumber(),
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(),
currentNetPerformancePercentWithCurrencyEffect:
currentNetPerformancePercentWithCurrencyEffect.toNumber(),
currentNetPerformanceWithCurrencyEffect:
netPerformance: currentNetPerformance.toNumber(),
netPerformancePercentage: currentNetPerformancePercentage.toNumber(),
netPerformancePercentageWithCurrencyEffect:
currentNetPerformancePercentageWithCurrencyEffect.toNumber(),
netPerformanceWithCurrencyEffect:
currentNetPerformanceWithCurrencyEffect.toNumber(),
currentValue: currentValueInBaseCurrency.toNumber(),
totalInvestment: totalInvestment.toNumber()
}
};
@ -1201,8 +1222,12 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency
currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: false,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
});
let { totalFeesWithCurrencyEffect, positions, totalInvestment } =
@ -1280,6 +1305,24 @@ export class PortfolioService {
};
}
public async updateTags({
dataSource,
impersonationId,
symbol,
tags,
userId
}: {
dataSource: DataSource;
impersonationId: string;
symbol: string;
tags: Tag[];
userId: string;
}) {
userId = await this.getUserId(impersonationId, userId);
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
}
private async getCashPositions({
cashDetails,
userCurrency,
@ -1425,8 +1468,8 @@ export class PortfolioService {
return {
currency,
allocationInPercentage: 0,
assetClass: AssetClass.CASH,
assetSubClass: AssetClass.CASH,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
dataSource: undefined,
dateOfFirstActivity: undefined,
@ -1435,6 +1478,7 @@ export class PortfolioService {
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
holdings: [],
investment: balance,
marketPrice: 0,
marketState: 'open',
@ -1576,11 +1620,6 @@ export class PortfolioService {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
const performanceInformation = await this.getPerformance({
impersonationId,
userId
});
const { activities } = await this.orderService.getOrders({
userCurrency,
userId,
@ -1598,6 +1637,19 @@ export class PortfolioService {
}
}
const {
currentValueInBaseCurrency,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
totalInvestment
} = await portfolioCalculator.getSnapshot();
const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency();
@ -1666,7 +1718,7 @@ export class PortfolioService {
.toNumber();
const netWorth = new Big(balanceInBaseCurrency)
.plus(performanceInformation.performance.currentValue)
.plus(currentValueInBaseCurrency)
.plus(valuables)
.plus(excludedAccountsAndActivities)
.minus(liabilities)
@ -1674,23 +1726,20 @@ export class PortfolioService {
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
const annualizedPerformancePercent = getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent: new Big(
performanceInformation.performance.currentNetPerformancePercent
)
netPerformancePercentage: new Big(netPerformancePercentage)
})?.toNumber();
const annualizedPerformancePercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({
getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent: new Big(
performanceInformation.performance.currentNetPerformancePercentWithCurrencyEffect
netPerformancePercentage: new Big(
netPerformancePercentageWithCurrencyEffect
)
})?.toNumber();
return {
...performanceInformation.performance,
annualizedPerformancePercent,
annualizedPerformancePercentWithCurrencyEffect,
cash,
@ -1699,6 +1748,7 @@ export class PortfolioService {
totalBuy,
totalSell,
committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: {
assets: emergencyFundPositionsValueInBaseCurrency,
@ -1712,15 +1762,28 @@ export class PortfolioService {
filteredValueInPercentage: netWorth
? filteredValueInBaseCurrency.div(netWorth).toNumber()
: undefined,
fireWealth: new Big(performanceInformation.performance.currentValue)
fireWealth: new Big(currentValueInBaseCurrency)
.minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(),
grossPerformance: grossPerformance.toNumber(),
grossPerformancePercentage: grossPerformancePercentage.toNumber(),
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect.toNumber(),
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(),
interest: interest.toNumber(),
items: valuables.toNumber(),
liabilities: liabilities.toNumber(),
netPerformance: netPerformance.toNumber(),
netPerformancePercentage: netPerformancePercentage.toNumber(),
netPerformancePercentageWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect.toNumber(),
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect.toNumber(),
ordersCount: activities.filter(({ type }) => {
return type === 'BUY' || type === 'SELL';
return ['BUY', 'SELL'].includes(type);
}).length,
totalInvestment: totalInvestment.toNumber(),
totalValueInBaseCurrency: netWorth
};
}

View File

@ -0,0 +1,7 @@
import { Tag } from '@prisma/client';
import { IsArray } from 'class-validator';
export class UpdateHoldingTagsDto {
@IsArray()
tags: Tag[];
}

View File

@ -0,0 +1,13 @@
import { RedisCacheService } from './redis-cache.service';
export const RedisCacheServiceMock = {
get: (key: string): Promise<string> => {
return Promise.resolve(null);
},
getPortfolioSnapshotKey: (userId: string): string => {
return `portfolio-snapshot-${userId}`;
},
set: (key: string, value: string, ttlInSeconds?: number): Promise<string> => {
return Promise.resolve(value);
}
};

View File

@ -1,6 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
@ -24,7 +24,11 @@ export class RedisCacheService {
return this.cache.get(key);
}
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
public getPortfolioSnapshotKey({ userId }: { userId: string }) {
return `portfolio-snapshot-${userId}`;
}
public getQuoteKey({ dataSource, symbol }: AssetProfileIdentifier) {
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
}

View File

@ -1,8 +1,10 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DATE_FORMAT,
getYesterday,
interpolate
} from '@ghostfolio/common/helper';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
@ -14,7 +16,9 @@ import * as path from 'path';
export class SitemapController {
public sitemapXml = '';
public constructor() {
public constructor(
private readonly configurationService: ConfigurationService
) {
try {
this.sitemapXml = fs.readFileSync(
path.join(__dirname, 'assets', 'sitemap.xml'),
@ -25,11 +29,51 @@ export class SitemapController {
@Get()
@Version(VERSION_NEUTRAL)
public async flushCache(@Res() response: Response): Promise<void> {
public async getSitemapXml(@Res() response: Response): Promise<void> {
const currentDate = format(getYesterday(), DATE_FORMAT);
response.setHeader('content-type', 'application/xml');
response.send(
interpolate(this.sitemapXml, {
currentDate: format(getYesterday(), DATE_FORMAT)
currentDate,
personalFinanceTools: this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? personalFinanceTools
.map(({ alias, key }) => {
return [
'<url>',
` <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/es/recursos/personal-finance-tools/alternativa-de-software-libre-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/fr/ressources/personal-finance-tools/alternative-open-source-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>',
'<url>',
` <loc>https://ghostfol.io/pt/recursos/personal-finance-tools/alternativa-de-software-livre-ao-${alias ?? key}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
'</url>'
].join('\n');
})
.join('\n')
: ''
})
);
}

View File

@ -1,10 +1,4 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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';
@ -12,14 +6,6 @@ import { SitemapController } from './sitemap.controller';
@Module({
controllers: [SitemapController],
imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule
]
imports: [ConfigurationModule]
})
export class SitemapModule {}

View File

@ -22,7 +22,7 @@ export class SubscriptionService {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2022-11-15'
apiVersion: '2024-04-10'
}
);
}

View File

@ -1,6 +1,9 @@
import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
export interface SymbolItem extends UniqueAsset {
export interface SymbolItem extends AssetProfileIdentifier {
currency: string;
historicalData: HistoricalDataItem[];
marketPrice: number;

View File

@ -1,6 +1,6 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';

View File

@ -1,4 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -12,10 +13,11 @@ import { SymbolService } from './symbol.service';
controllers: [SymbolController],
exports: [SymbolService],
imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PrismaModule
PrismaModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [SymbolService]
})

View File

@ -40,13 +40,13 @@ export class SymbolService {
const days = includeHistoricalData;
const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) },
uniqueAssets: [
assetProfileIdentifiers: [
{
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
}
]
],
dateQuery: { gte: subDays(new Date(), days) }
});
historicalData = marketData.map(({ date, marketPrice: value }) => {

View File

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

View File

@ -1,13 +1,14 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import type {
ColorScheme,
DateRange,
HoldingsViewMode,
ViewMode
} from '@ghostfolio/common/types';
import {
IsArray,
IsBoolean,
IsISO4217CurrencyCode,
IsISO8601,
IsIn,
IsNumber,
@ -21,7 +22,7 @@ export class UpdateUserSettingDto {
@IsOptional()
annualInterestRate?: number;
@IsISO4217CurrencyCode()
@IsCurrencyCode()
@IsOptional()
baseCurrency?: string;
@ -66,6 +67,10 @@ export class UpdateUserSettingDto {
@IsOptional()
'filters.tags'?: string[];
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE'])
@IsOptional()
holdingsViewMode?: HoldingsViewMode;
@IsBoolean()
@IsOptional()
isExperimentalFeatures?: boolean;

View File

@ -1,12 +1,9 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { User, UserSettings } from '@ghostfolio/common/interfaces';
import {
hasPermission,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -29,6 +26,7 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { size } from 'lodash';
import { DeleteOwnUserDto } from './delete-own-user.dto';
import { UserItem } from './interfaces/user-item.interface';
import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UserService } from './user.service';
@ -36,12 +34,41 @@ import { UserService } from './user.service';
@Controller('user')
export class UserController {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly jwtService: JwtService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@Delete()
@HasPermission(permissions.deleteOwnUser)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOwnUser(
@Body() data: DeleteOwnUserDto
): Promise<UserModel> {
const hashedAccessToken = this.userService.createAccessToken(
data.accessToken,
this.configurationService.get('ACCESS_TOKEN_SALT')
);
const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken, id: this.request.user.id }
});
if (!user) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.userService.deleteUser({
accessToken: hashedAccessToken,
id: user.id
});
}
@Delete(':id')
@HasPermission(permissions.deleteUser)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -63,13 +90,6 @@ export class UserController {
public async getUser(
@Headers('accept-language') acceptLanguage: string
): Promise<User> {
if (hasRole(this.request.user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
return this.userService.getUser(
this.request.user,
acceptLanguage?.split(',')?.[0]

View File

@ -1,3 +1,4 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -19,6 +20,7 @@ import { UserService } from './user.service';
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' }
}),
OrderModule,
PrismaModule,
PropertyModule,
SubscriptionModule,

View File

@ -1,5 +1,7 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -25,6 +27,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { sortBy, without } from 'lodash';
@ -37,6 +40,8 @@ export class UserService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly eventEmitter: EventEmitter2,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
@ -118,28 +123,6 @@ export class UserService {
return usersWithAdminRole.length > 0;
}
public hasReadRestrictedAccessPermission({
impersonationId,
user
}: {
impersonationId: string;
user: UserWithSettings;
}) {
if (!impersonationId) {
return false;
}
const access = user.Access?.find(({ id }) => {
return id === impersonationId;
});
return access?.permissions?.includes('READ_RESTRICTED') ?? true;
}
public isRestrictedView(aUser: UserWithSettings) {
return aUser.Settings.settings.isRestrictedView ?? false;
}
public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> {
@ -207,7 +190,7 @@ export class UserService {
(user.Settings.settings as UserSettings).dateRange =
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
? 'max'
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max';
: ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
// Set default value for view mode
if (!(user.Settings.settings as UserSettings).viewMode) {
@ -254,15 +237,22 @@ export class UserService {
currentPermissions = without(
currentPermissions,
permissions.accessHoldingsChart,
permissions.createAccess
);
// Reset benchmark
user.Settings.settings.benchmark = undefined;
}
if (user.subscription?.type === 'Premium') {
// Reset holdings view mode
user.Settings.settings.holdingsViewMode = undefined;
} else if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch);
currentPermissions = without(
currentPermissions,
permissions.deleteOwnUser
);
}
}
@ -414,8 +404,8 @@ export class UserService {
} catch {}
try {
await this.prismaService.order.deleteMany({
where: { userId: where.id }
await this.orderService.deleteOrders({
userId: where.id
});
} catch {}
@ -437,11 +427,9 @@ export class UserService {
userId: string;
userSettings: UserSettings;
}) {
const settings = userSettings as unknown as Prisma.JsonObject;
await this.prismaService.settings.upsert({
const { settings } = await this.prismaService.settings.upsert({
create: {
settings,
settings: userSettings as unknown as Prisma.JsonObject,
User: {
connect: {
id: userId
@ -449,14 +437,21 @@ export class UserService {
}
},
update: {
settings
settings: userSettings as unknown as Prisma.JsonObject
},
where: {
userId
}
});
return;
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
return settings;
}
private getRandomString(length: number) {

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,12 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<!--
<url>
<loc>https://ghostfol.io/ca</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<url>
<loc>https://ghostfol.io/de</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -54,206 +60,6 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-allinvestview</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-compound-planning</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-de.fi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-empower</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-fina</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockle</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-tiller</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-whal</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -408,206 +214,6 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-allinvestview</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-compound-planning</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-de.fi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-empower</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-fina</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockle</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-tiller</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-whal</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -638,6 +244,10 @@
<loc>https://ghostfol.io/es/recursos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/recursos/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/registro</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -716,6 +326,10 @@
<loc>https://ghostfol.io/fr/ressources</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/ressources/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -774,206 +388,6 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-allinvestview</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-compound-planning</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-de.fi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-empower</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-fina</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockle</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-tiller</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-whal</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -986,206 +400,6 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-allinvestview</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-compound-planning</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-de.fi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-empower</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-fina</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockle</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-tiller</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-whal</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/functionaliteiten</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -1233,10 +447,10 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<!--
<url>
<loc>https://ghostfol.io/pl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<url>
<loc>https://ghostfol.io/pt</loc>
@ -1270,6 +484,10 @@
<loc>https://ghostfol.io/pt/recursos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/recursos/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/registo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -1304,4 +522,5 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
${personalFinanceTools}
</urlset>

View File

@ -0,0 +1,11 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { Module } from '@nestjs/common';
import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({
imports: [RedisCacheModule],
providers: [PortfolioChangedListener]
})
export class EventsModule {}

View File

@ -0,0 +1,15 @@
export class PortfolioChangedEvent {
private userId: string;
public constructor({ userId }: { userId: string }) {
this.userId = userId;
}
public static getName() {
return 'portfolio.changed';
}
public getUserId() {
return this.userId;
}
}

View File

@ -0,0 +1,25 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PortfolioChangedEvent } from './portfolio-changed.event';
@Injectable()
export class PortfolioChangedListener {
public constructor(private readonly redisCacheService: RedisCacheService) {}
@OnEvent(PortfolioChangedEvent.getName())
handlePortfolioChangedEvent(event: PortfolioChangedEvent) {
Logger.log(
`Portfolio of user '${event.getUserId()}' has changed`,
'PortfolioChangedListener'
);
this.redisCacheService.remove(
this.redisCacheService.getPortfolioSnapshotKey({
userId: event.getUserId()
})
);
}
}

View File

@ -1,6 +1,9 @@
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 {
hasReadRestrictedAccessPermission,
isRestrictedView
} from '@ghostfolio/common/permissions';
import { UserWithSettings } from '@ghostfolio/common/types';
import {
@ -16,7 +19,7 @@ import { map } from 'rxjs/operators';
export class RedactValuesInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor(private userService: UserService) {}
public constructor() {}
public intercept(
context: ExecutionContext,
@ -29,15 +32,13 @@ export class RedactValuesInResponseInterceptor<T>
const impersonationId =
headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
const hasReadRestrictedPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
user
});
if (
hasReadRestrictedPermission ||
this.userService.isRestrictedView(user)
hasReadRestrictedAccessPermission({
impersonationId,
user
}) ||
isRestrictedView(user)
) {
data = redactAttributes({
object: data,

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