Compare commits
830 Commits
Author | SHA1 | Date | |
---|---|---|---|
31f0056a2d | |||
550e646079 | |||
37ff7acf04 | |||
8236091477 | |||
2a71cb66de | |||
e60fe48fdd | |||
d40bc5070a | |||
fda4e0ea7d | |||
08d696ce33 | |||
46614a7c24 | |||
02b433eb1e | |||
25112a450b | |||
727340748b | |||
8ad6492477 | |||
4af76f6f6d | |||
10940214a5 | |||
d9a6c22e1e | |||
692309988c | |||
42a54263f9 | |||
4fb88859b2 | |||
aa24b5e8c6 | |||
90e18338f6 | |||
ad5ae938ef | |||
c9a8dd4958 | |||
f1ec5e704e | |||
f40f0653c2 | |||
5f7a230fd3 | |||
71feb531e8 | |||
ec3552d7f6 | |||
41875e70d6 | |||
5fa0540936 | |||
5b69dee246 | |||
19b0fe04a6 | |||
19ea4479ff | |||
0b2f6a312c | |||
f79d60014b | |||
5b7409d08e | |||
6230aa87e2 | |||
8b615d2f56 | |||
4100446cac | |||
ad3e6d637c | |||
aa87262954 | |||
01b6bb5b99 | |||
884b7f4de7 | |||
3f8a2b47f9 | |||
e2e4c9be3c | |||
0f7c6ff0fe | |||
703a96f4db | |||
42c0560422 | |||
eb63802d01 | |||
6d9191a46f | |||
6744245d8b | |||
8f64a77a9d | |||
0d5fc7655b | |||
c511ec7e33 | |||
b12349a148 | |||
f7e3a4c727 | |||
5f276469b7 | |||
69e1d92ed3 | |||
ef2849aa6c | |||
c668d7b456 | |||
e23bf62859 | |||
54c5746d21 | |||
7130ac7565 | |||
1851ae137f | |||
6f6ff94979 | |||
7f25066f0f | |||
fc795aaa8c | |||
d0112968e8 | |||
522025ffa0 | |||
27bf662281 | |||
93c27277c6 | |||
5e6adfcef5 | |||
ab691bb27a | |||
8fc5676443 | |||
1fe1e2fe0c | |||
921d38a706 | |||
6161d5e77c | |||
369386f976 | |||
41437636b1 | |||
b21884eb66 | |||
1c5437e1fd | |||
58278ba5e6 | |||
921f3e9807 | |||
75ca125a70 | |||
a1fd4e7a38 | |||
0d5a8eb33e | |||
b088df2fa3 | |||
f45d8f616a | |||
d8300502ce | |||
502d51ad29 | |||
bc33e5f147 | |||
48ba8f936b | |||
05ec4cce05 | |||
d74f283707 | |||
0f8bc7db32 | |||
431500f28a | |||
9672de174e | |||
c6aa06b933 | |||
1f46a6b6f3 | |||
1bed940bc0 | |||
f9eb3cc3c5 | |||
2519c3ffb0 | |||
91013d1d10 | |||
6deefb9c43 | |||
d0744e07df | |||
93e1ee3ba7 | |||
dceaa55a6c | |||
8b4d55925d | |||
754b49e50f | |||
6ccbda8169 | |||
b0fb986208 | |||
0b59fc639d | |||
7ddd6f27b5 | |||
c5d56f4b47 | |||
2f2b712999 | |||
c2fd31f5e5 | |||
f2d70f9070 | |||
f41dd9cd8e | |||
7d238b4935 | |||
da6591fca0 | |||
1f9b9e9998 | |||
49c4ea306d | |||
ccb5c664ef | |||
97e165ff69 | |||
45aefb6a45 | |||
2435535975 | |||
bd3d43bf05 | |||
02dc7c52b1 | |||
ff59fd4196 | |||
4955555ddd | |||
a98c788a26 | |||
9c16af81c7 | |||
2df27100f0 | |||
6cf6538719 | |||
0fd3db3228 | |||
18835149e2 | |||
6c9779fb0d | |||
3e98f097ef | |||
183ac8fa2b | |||
9036f53e7d | |||
f7c04e469a | |||
b5f01c0d15 | |||
5a23cd34ad | |||
6e87f34c6f | |||
6618aa2e9b | |||
0d25a96f7e | |||
4f6d9d3a76 | |||
928f6f0c45 | |||
09e95ddcee | |||
2d003225bc | |||
de93cabd69 | |||
51489cca81 | |||
f7f4c3afb1 | |||
0821086e41 | |||
7a905fde63 | |||
d2882b1119 | |||
3a500598c5 | |||
42274917e0 | |||
8ba50f2729 | |||
f22071f061 | |||
d2312371a6 | |||
ba837c3c30 | |||
d85d83a0f5 | |||
62e8594c57 | |||
509f95ea30 | |||
43d0b55004 | |||
c0f130a077 | |||
90dc34380e | |||
286e41eb21 | |||
4973d0261d | |||
c4a62dfd68 | |||
4d6be0a507 | |||
b259ab7b0c | |||
e1ac5245c7 | |||
d4fea075af | |||
cef7fa79de | |||
ca05397dcd | |||
2a11977001 | |||
fb1a5c93ef | |||
77e9791e03 | |||
efd9e7a5c7 | |||
d9ced885e1 | |||
5fe07cb85f | |||
af008aa74f | |||
ca7bf27c20 | |||
0866587cab | |||
622bb8b0cf | |||
16b9fbe00e | |||
c9353d0a39 | |||
ea101dd3bd | |||
cd67ce82fa | |||
d5b3c52602 | |||
bdf72164b1 | |||
455a2d2e92 | |||
9c0f46b587 | |||
8533606177 | |||
6728e04ff7 | |||
2bf4f1237a | |||
4857b2e620 | |||
68a9a7f6f9 | |||
81ef95e13e | |||
b633132757 | |||
2b0f961370 | |||
30f1a3514a | |||
ed735e0b29 | |||
b89ccd2dde | |||
df6d39377f | |||
d5d14497d6 | |||
09c300661a | |||
92382e0b4d | |||
c25f532487 | |||
5d26d94586 | |||
73b6784e9f | |||
6159f48a62 | |||
7d34fba7c1 | |||
c434b730a8 | |||
2d23c566f1 | |||
ba220eaee9 | |||
09023214ce | |||
1ceabb6e6b | |||
421072c7fa | |||
0d421e7181 | |||
f5180ce88f | |||
aabf27dc96 | |||
421809ae95 | |||
d3234f9e77 | |||
a40be2f744 | |||
e62da06c5c | |||
b7f635bdfc | |||
0a465f125d | |||
c02e390bc1 | |||
f9bec0d793 | |||
2f44748f79 | |||
97504756be | |||
6a802a62a0 | |||
51ca26bb4d | |||
2ecc8dbc4e | |||
c0e0e2401e | |||
1a30c180bc | |||
39d4f80f36 | |||
3693091ad6 | |||
bf52f1137d | |||
54ea6c84b4 | |||
689e50ae1a | |||
677757fdf0 | |||
58d9816f01 | |||
5f3d445f1d | |||
fce6caebc2 | |||
d0a4f5c000 | |||
b5e2a3aa91 | |||
f47883fb0b | |||
2932744a68 | |||
73c0f02e06 | |||
382fe24f29 | |||
908876ca6e | |||
99cf9f8802 | |||
7444ff97fc | |||
834a48466e | |||
a9526430c2 | |||
fce3b2084e | |||
f5a50a95de | |||
06dfb91f82 | |||
be36050d76 | |||
7931e6950d | |||
04eb452e04 | |||
6f7e370fca | |||
b4a126280f | |||
2d009aacc4 | |||
9116443305 | |||
0adaf12a01 | |||
b6562b6e2c | |||
b0a4b09ef5 | |||
ad8b9ad333 | |||
809956f210 | |||
6077bfa754 | |||
09498bd804 | |||
fd84f4ec14 | |||
c711a11d6e | |||
8232b05f62 | |||
0ea66aebcb | |||
64087de3fc | |||
7082ff12f8 | |||
1c7d92e15e | |||
a53461d257 | |||
d630fb900d | |||
51e8555fa5 | |||
9db675b955 | |||
45bd8ed029 | |||
707fd31550 | |||
6e5f0086a1 | |||
97bcd8ff49 | |||
1809fc8a80 | |||
beb24f9bd4 | |||
ae57a188f5 | |||
23db85e940 | |||
bd8bb1a36a | |||
c48670ccdc | |||
fc019002e2 | |||
4282cb66b8 | |||
1d0ba5fe4b | |||
24cfb26c5b | |||
26a70aa208 | |||
ab7e050066 | |||
26b1fd6572 | |||
d7e682b65a | |||
f589ccb775 | |||
206b6567fd | |||
6857e0314f | |||
c8682a7393 | |||
144b6b2211 | |||
16a5ace4be | |||
b24ddc30c9 | |||
19333ab084 | |||
7529a7a26c | |||
21ebaae6ef | |||
3bc8b3c836 | |||
bb9415cc15 | |||
b3baeb8a5d | |||
1f393e78f6 | |||
215f5eafa6 | |||
1916e5343d | |||
fa9863fc54 | |||
7bf48ef351 | |||
faef3606fd | |||
d0ccd4d238 | |||
51e3650790 | |||
db29e2b666 | |||
655a68a847 | |||
86296b3591 | |||
73c127f10c | |||
cf4c981cd9 | |||
1b9541b933 | |||
5bca8de44e | |||
136c4bf50b | |||
4d700e3b83 | |||
740fa6fc84 | |||
cdb8dc72c7 | |||
4b3afb5c97 | |||
abf208432a | |||
19e6df4fb2 | |||
7fc3fff431 | |||
edd690850c | |||
302339e1cd | |||
739796bc79 | |||
9c30139b86 | |||
0af528b649 | |||
9636c87a2e | |||
ad46fb6d61 | |||
8e000baef2 | |||
a2e1209196 | |||
ef4a75d1f0 | |||
3db20feb54 | |||
b9ec381ea2 | |||
7d6a74a67d | |||
b923cf7752 | |||
e32e457ff8 | |||
32c1e6b390 | |||
b42c0c8355 | |||
7140ed8512 | |||
27d9b075ce | |||
5249257dd8 | |||
606f6159c4 | |||
2e095603b5 | |||
3a99b81ade | |||
577a487301 | |||
086d43376c | |||
31a4c2ff1f | |||
6a1fad611c | |||
e1892d2870 | |||
8ba15f8f72 | |||
876b66f324 | |||
2c5bfb19d3 | |||
1bb94a04e3 | |||
e3c9316486 | |||
c19984c3d0 | |||
9002c20165 | |||
15c96a9757 | |||
1ca3792a4b | |||
90fe467114 | |||
e61b3b34a7 | |||
1326418ffc | |||
a5f0f48ddb | |||
e500ccb61b | |||
4090b03406 | |||
431d1d5fec | |||
d74d79198b | |||
623a284ba4 | |||
f79c36edbb | |||
f4c748f67a | |||
672d8dfab2 | |||
0464adccce | |||
c3df6c3194 | |||
29d53c7df4 | |||
7b77dc044a | |||
67e758365f | |||
475231ffd8 | |||
513a564e2c | |||
cddea0401f | |||
3dafbf7fef | |||
fcd75414be | |||
c1b5bfff8c | |||
3c322cca0d | |||
e965d12e31 | |||
3daf55a0dd | |||
aafedd5f75 | |||
32956ae04c | |||
bfd0241b2d | |||
5eff8402db | |||
ffa020ee2a | |||
80a3668aa9 | |||
7378900050 | |||
9be457943c | |||
93454c6c15 | |||
fccbd76993 | |||
922876a893 | |||
654446f068 | |||
947460abdd | |||
c5635b0050 | |||
8a3a6308a3 | |||
290a07fe79 | |||
4c907d56f0 | |||
56b437ca74 | |||
e23ff33e6f | |||
f2d206262e | |||
1ed5690b33 | |||
4451514ec5 | |||
8f73f85276 | |||
9a5d7b664b | |||
7d2d1d971a | |||
d111493eed | |||
e975f92a96 | |||
739cb4242d | |||
a37eebc9f1 | |||
e92730879e | |||
464973f9b0 | |||
f6228c099f | |||
a57fdfb2bb | |||
24716f0561 | |||
3453100afd | |||
84de2c0c68 | |||
1b7b082003 | |||
1928c2c2cc | |||
52e7a7886d | |||
36298b217e | |||
9bce57894e | |||
9d6bb325cd | |||
a5f833c612 | |||
732b14c6ab | |||
b74a042da8 | |||
d55c052f57 | |||
864f585efa | |||
6d56146054 | |||
2c9f29a3c6 | |||
9bef2e960c | |||
17b8c41673 | |||
f0afbd7346 | |||
5dc7429f6a | |||
7b39b32293 | |||
e5b5a9e7e9 | |||
1f3511368a | |||
b37df2c84f | |||
f92ba54060 | |||
a3bbd4030e | |||
4b30da2d92 | |||
93d082afbb | |||
0c85380dbf | |||
fb576376dc | |||
ff111d4c6c | |||
bc6e9a8b68 | |||
bd1963ec26 | |||
a0bec9e97f | |||
c45df20d88 | |||
fa1d669633 | |||
1009b462e9 | |||
b404858904 | |||
7ec033577f | |||
c8ca82b803 | |||
5db2faa17d | |||
1605fb8d48 | |||
b6a7804a26 | |||
de31381fd9 | |||
0d92b8d8bb | |||
7c6ff776d9 | |||
e37a34ed6c | |||
c4d9c00f92 | |||
3af8be89e3 | |||
0f1db71604 | |||
fce9e7fb0c | |||
6301c0c21c | |||
30bb484d5a | |||
f88ee5e5a0 | |||
73b5030972 | |||
a69a3442ab | |||
d4dff744b5 | |||
62c93ad99d | |||
1e42d6bffa | |||
002ac29f2f | |||
20ccf389e9 | |||
a2f99ed4d2 | |||
cc6320acfd | |||
261a0fb0b9 | |||
cfc05cce41 | |||
1f15b70134 | |||
a5b49b286d | |||
f3333f24da | |||
cad8f0d0e2 | |||
edd3e75730 | |||
ab68c2c69a | |||
cbb95f21a3 | |||
74d3954335 | |||
92449b0369 | |||
65276483e0 | |||
dde0d1e465 | |||
3ad802c6f5 | |||
b81377a682 | |||
545180b88f | |||
a9819b9e25 | |||
897e941e7a | |||
aef840c2cc | |||
80d0638922 | |||
494ba36d44 | |||
dab9154092 | |||
cd4a85abbf | |||
e7977a9fbb | |||
684c1e55b0 | |||
1ffa831c5c | |||
40eed0016c | |||
b58631083b | |||
e0c0425d21 | |||
bf2de5d572 | |||
2b4a1dc480 | |||
ce022c024f | |||
0f4bf529d8 | |||
dad6bf7095 | |||
86ca9eaae6 | |||
9d9b805b0e | |||
851401be1e | |||
85052bc9bc | |||
bff09f529d | |||
f438458687 | |||
7125b12631 | |||
0cbf275a2e | |||
0ec50819f5 | |||
c9abe818bc | |||
bfa32537a8 | |||
cef15afab8 | |||
1b9587c454 | |||
de76b0d8c3 | |||
e62989c981 | |||
d6b71e6314 | |||
8c59bfd6d7 | |||
f32df73256 | |||
9d03a8002c | |||
3c36ca29af | |||
efed7e3c2b | |||
b09d3cea95 | |||
eabd2f3934 | |||
cc184c2827 | |||
436f791fa4 | |||
e935a57dec | |||
203909d917 | |||
eed4f57f30 | |||
7878036bac | |||
75d140b436 | |||
a79f31b006 | |||
45cfd61dbb | |||
7fcfca952e | |||
279f16cc67 | |||
e7b1d8a5d3 | |||
1b2f8e5586 | |||
e4468252c6 | |||
ad3ebd42bb | |||
55b03733f4 | |||
0000317041 | |||
e5f2a3865d | |||
c61561664f | |||
a7d8a63ab8 | |||
5c51c1e825 | |||
3a67bf9bb4 | |||
f7597c213d | |||
2e7f46ad78 | |||
cfffb99f52 | |||
69ac3408f1 | |||
e1806b4bd8 | |||
6aae0cc1e4 | |||
5d8a50a80d | |||
662231e830 | |||
4d84459b5b | |||
efba7429c1 | |||
9cae5a3e79 | |||
c2ed0a436f | |||
8486c02575 | |||
5122ef3456 | |||
579b86665e | |||
52b3ad6dc3 | |||
bf9b60aa74 | |||
6cd51fb044 | |||
271001f523 | |||
a7e513a6d1 | |||
b5f256be95 | |||
a834ef6b4c | |||
e5bd0d1bfa | |||
7fa6eda45d | |||
f47e4d3b04 | |||
0300c6f3b7 | |||
4865c45fd4 | |||
2beceb36cf | |||
cd64601482 | |||
efac39eb51 | |||
4da8a547ca | |||
9e8a9e4670 | |||
bb99141e9c | |||
d147c2313f | |||
0878941c4f | |||
69a9e77820 | |||
104cca069f | |||
7ad58b1a62 | |||
e88dbb0181 | |||
152fd4fdf8 | |||
6b022b8de8 | |||
7ab699e5fe | |||
a7e5a316be | |||
3f2d3a2da9 | |||
0208bd0923 | |||
aeba6e1f03 | |||
1b899da9ff | |||
90a7a84ac5 | |||
fc8e23a9c8 | |||
f3c8ec27cb | |||
38474f54b0 | |||
18d25fb6c2 | |||
a850e8ca22 | |||
b5f565c054 | |||
aa6d0a4533 | |||
25e9028a41 | |||
925d38703e | |||
158bb00b8a | |||
b17111e6f1 | |||
c4765e31cd | |||
d321d56dee | |||
07dd22f7fe | |||
eb4d088a80 | |||
0509f0101f | |||
8818e09be8 | |||
d97fe4da9c | |||
b20fa55b79 | |||
dd7a6f1562 | |||
15357bd5b5 | |||
52c7adc266 | |||
1ae8970045 | |||
7c4c047140 | |||
527f7e4faf | |||
50160eb9dc | |||
58dff8a1e0 | |||
2cd41615b2 | |||
66d5793528 | |||
e8d65e1c85 | |||
da827a08f5 | |||
d545e4877c | |||
1918dee9c5 | |||
a08610b603 | |||
c22733db56 | |||
ee4866eb7d | |||
327b1fa0d7 | |||
b155666d21 | |||
c5ee3237ed | |||
16118d635c | |||
49ce4803ce | |||
0b65d05013 | |||
8793284e75 | |||
1c5e4050a8 | |||
4f187e1a9f | |||
b56111ae85 | |||
61dfc1f819 | |||
6137f228a8 | |||
5293de14cd | |||
7340a674b5 | |||
42cb3e2c73 | |||
e8a4a53c9f | |||
629f002074 | |||
7c65cf6ddd | |||
c38ebec3be | |||
2b8ab26e7e | |||
60f52bb209 | |||
616d168a7c | |||
b13e4425d3 | |||
1424236c48 | |||
2a605f850d | |||
88ffbfead0 | |||
5f4a8d505b | |||
e87b93f19c | |||
49dcade964 | |||
7cd65eed39 | |||
a51b210f79 | |||
285f2220f3 | |||
d72123246d | |||
3a78d6c3f1 | |||
d5e3ff5717 | |||
2efb331370 | |||
f521fe99c5 | |||
42306530b8 | |||
68c9d1b266 | |||
1ce90a0c06 | |||
50f6d154e5 | |||
e4c44faee4 | |||
5209f82cca | |||
292d345ce0 | |||
d58400788a | |||
7ff61ae839 | |||
b5b7af7741 | |||
de3e0fad83 | |||
8c8273c4d4 | |||
b406bcd17d | |||
fb496431e8 | |||
441b251536 | |||
1dbb5db611 | |||
8567efcd89 | |||
1cda5dcc0a | |||
3fb01c6dcf | |||
6a764fe893 | |||
d2b75a244c | |||
3611684f17 | |||
4b74be50da | |||
0d338bb083 | |||
b0d708fb82 | |||
be14458437 | |||
5978ddb80f | |||
18638dd1b7 | |||
81db3852e6 | |||
af27781234 | |||
608e7a774d | |||
ed15eb76fd | |||
39905e5046 | |||
7cd3f235df | |||
3b4f8c69bb | |||
c9bdf46b2b | |||
4169de580b | |||
3317fe7c46 | |||
c8f6fdbaa3 | |||
d95fc82f95 | |||
31c949f9d2 | |||
f68f40fcc6 | |||
9623a363ed | |||
2d42549967 | |||
c934c5088b | |||
678b3cc57e | |||
cd5eb64a4c | |||
fc1507de4f | |||
d147a66dcd | |||
33fd1282e5 | |||
693ff9d3ea | |||
21e87a0055 | |||
43426c9b01 | |||
3b4da72ea3 | |||
8d8e55fd0b | |||
ca18621ce8 | |||
b8574d24b2 | |||
6d12c27f9c | |||
c2c5326049 | |||
2a1339b61e | |||
c8a2579624 | |||
832ae063df | |||
b5e026934f | |||
901c997908 | |||
3b6e0b20e2 | |||
e449d51c3c | |||
f72d31bab3 | |||
4c893c4dcc | |||
ffb11cd10e | |||
d424b7731e | |||
6043c87481 | |||
fca0a688b6 | |||
5c6cc4fed5 | |||
64a7d38ff9 | |||
68d0d39161 | |||
233a8a8a18 | |||
190779ee35 | |||
6ef8121561 | |||
58bf57d1e6 | |||
71c5412dd5 | |||
ae85398c3d | |||
048900d01b | |||
074b09b543 | |||
f9e04022f4 | |||
8fd1fbd44a | |||
0fb33ae71c | |||
3a35d72ec2 | |||
32fe3e195f | |||
805f4b05be | |||
5b51a6840a | |||
36bd6164e6 | |||
eac52a215b | |||
9ff8cd5471 | |||
33cc7e4e7e | |||
47f84dab06 | |||
384d18b2a6 | |||
2363983bdc | |||
4af76764be | |||
a65424aafa | |||
f9cd629470 | |||
ccb8c86596 | |||
246de7aa86 | |||
a323313c71 | |||
538c8947cd | |||
1ec5fd12fe | |||
4376b8903e | |||
a8e096f9ac | |||
8e577592f6 | |||
c896bf9199 | |||
16145f18d9 | |||
5398da0dc8 | |||
2466f4ff5d | |||
8f3a9bdfbb | |||
44dfd2bd48 | |||
3fc2228f1d | |||
b018819a1f | |||
ac9311d783 | |||
e23ce0f35d | |||
f4b52aa41c | |||
655b040d4d | |||
0f637a5d0f | |||
3f85c327f5 | |||
c2df99072d | |||
e8afbcad9c | |||
e6d8de781b | |||
6e1935899f | |||
169cb85b66 | |||
fe6658d0ac | |||
1f0381228e |
@ -11,6 +11,5 @@ POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||
|
||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||
ALPHA_VANTAGE_API_KEY=
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
@ -1,12 +1,12 @@
|
||||
{
|
||||
"root": true,
|
||||
"ignorePatterns": ["**/*"],
|
||||
"plugins": ["@nrwl/nx"],
|
||||
"plugins": ["@nx"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {
|
||||
"@nrwl/nx/enforce-module-boundaries": [
|
||||
"@nx/enforce-module-boundaries": [
|
||||
"error",
|
||||
{
|
||||
"enforceBuildableLibDependency": true,
|
||||
@ -23,12 +23,12 @@
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"extends": ["plugin:@nrwl/nx/typescript"],
|
||||
"extends": ["plugin:@nx/typescript"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"extends": ["plugin:@nrwl/nx/javascript"],
|
||||
"extends": ["plugin:@nx/javascript"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
@ -113,5 +113,6 @@
|
||||
"radix": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"extends": [null, "plugin:storybook/recommended"]
|
||||
}
|
||||
|
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,37 +1,46 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
title: '[BUG]'
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
|
||||
**Bug Description**
|
||||
|
||||
**Describe the bug**
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
<!-- Steps to reproduce the behavior -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
4.
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
**Screenshots**
|
||||
|
||||
<!-- If applicable, add screenshots to help explain your problem. -->
|
||||
|
||||
**Logs**
|
||||
|
||||
<!-- If applicable, add logs to help explain your problem. -->
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
- Ghostfolio Version [e.g. 1.194.0]
|
||||
- Browser [e.g. chrome]
|
||||
- OS
|
||||
**Environment**
|
||||
|
||||
<!-- Please complete the following information -->
|
||||
|
||||
- Cloud or Self-hosted
|
||||
- Ghostfolio Version X.Y.Z
|
||||
- Browser
|
||||
- OS
|
||||
|
||||
**Additional context**
|
||||
|
||||
<!-- Add any other context about the problem here. -->
|
||||
|
4
.github/workflows/build-code.yml
vendored
4
.github/workflows/build-code.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node_version:
|
||||
- 16
|
||||
- 18
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
@ -33,4 +33,4 @@ jobs:
|
||||
run: yarn test
|
||||
|
||||
- name: Build application
|
||||
run: yarn build:all
|
||||
run: yarn build:production
|
||||
|
2
.github/workflows/docker-image.yml
vendored
2
.github/workflows/docker-image.yml
vendored
@ -41,7 +41,7 @@ jobs:
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.output.labels }}
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,6 +5,7 @@
|
||||
/tmp
|
||||
|
||||
# dependencies
|
||||
/.yarn
|
||||
/node_modules
|
||||
|
||||
# IDEs and editors
|
||||
@ -24,6 +25,7 @@
|
||||
|
||||
# misc
|
||||
/.angular/cache
|
||||
.env
|
||||
.env.prod
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
|
10
.prettierrc
10
.prettierrc
@ -1,5 +1,15 @@
|
||||
{
|
||||
"attributeGroups": [
|
||||
"$ANGULAR_ELEMENT_REF",
|
||||
"$ANGULAR_STRUCTURAL_DIRECTIVE",
|
||||
"$DEFAULT",
|
||||
"$ANGULAR_INPUT",
|
||||
"$ANGULAR_TWO_WAY_BINDING",
|
||||
"$ANGULAR_OUTPUT"
|
||||
],
|
||||
"attributeSort": "ASC",
|
||||
"endOfLine": "auto",
|
||||
"plugins": ["prettier-plugin-organize-attributes"],
|
||||
"printWidth": 80,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
|
@ -1,11 +0,0 @@
|
||||
module.exports = {
|
||||
stories: [],
|
||||
addons: ['@storybook/addon-essentials']
|
||||
// uncomment the property below if you want to apply some webpack config globally
|
||||
// webpackFinal: async (config, { configType }) => {
|
||||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
||||
|
||||
// // Return the altered config
|
||||
// return config;
|
||||
// },
|
||||
};
|
@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"exclude": [
|
||||
"../**/*.spec.js",
|
||||
"../**/*.spec.ts",
|
||||
"../**/*.spec.tsx",
|
||||
"../**/*.spec.jsx"
|
||||
],
|
||||
"include": ["../**/*"]
|
||||
}
|
29
.vscode/launch.json
vendored
29
.vscode/launch.json
vendored
@ -2,32 +2,33 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Jest File",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng",
|
||||
"args": [
|
||||
"test",
|
||||
"--codeCoverage=false",
|
||||
"--testFile=${workspaceFolder}/apps/api/src/models/portfolio.spec.ts"
|
||||
"--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts"
|
||||
],
|
||||
"console": "internalConsole",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "internalConsole"
|
||||
"name": "Debug Jest",
|
||||
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
|
||||
"request": "launch",
|
||||
"type": "node"
|
||||
},
|
||||
{
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"program": "${workspaceFolder}/apps/api/src/main.ts",
|
||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
|
||||
"autoAttachChildProcesses": true,
|
||||
"console": "integratedTerminal",
|
||||
"cwd": "${workspaceFolder}/apps/api",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"name": "Debug API",
|
||||
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
|
||||
"program": "${workspaceFolder}/apps/api/src/main.ts",
|
||||
"request": "launch",
|
||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||
"skipFiles": [
|
||||
"${workspaceFolder}/node_modules/**/*.js",
|
||||
"<node_internals>/**/*.js"
|
||||
],
|
||||
"console": "integratedTerminal"
|
||||
"type": "node"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
1615
CHANGELOG.md
1615
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
37
DEVELOPMENT.md
Normal file
37
DEVELOPMENT.md
Normal file
@ -0,0 +1,37 @@
|
||||
# Ghostfolio Development Guide
|
||||
|
||||
## Git
|
||||
|
||||
### Rebase
|
||||
|
||||
`git rebase -i --autosquash main`
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Nx
|
||||
|
||||
#### Upgrade
|
||||
|
||||
1. Run `yarn nx migrate latest`
|
||||
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||
1. Run `yarn nx migrate --run-migrations`
|
||||
|
||||
### Prisma
|
||||
|
||||
#### Access database via GUI
|
||||
|
||||
Run `yarn database:gui`
|
||||
|
||||
https://www.prisma.io/studio
|
||||
|
||||
#### Synchronize schema with database for prototyping
|
||||
|
||||
Run `yarn database:push`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||
|
||||
#### Create schema migration
|
||||
|
||||
Run `yarn prisma migrate dev --name added_job_title`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
11
Dockerfile
11
Dockerfile
@ -1,4 +1,4 @@
|
||||
FROM --platform=$BUILDPLATFORM node:16-slim as builder
|
||||
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||
|
||||
# Build application and add additional files
|
||||
WORKDIR /ghostfolio
|
||||
@ -25,7 +25,6 @@ RUN yarn install
|
||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||
RUN node decorate-angular-cli.js
|
||||
|
||||
COPY ./angular.json angular.json
|
||||
COPY ./nx.json nx.json
|
||||
COPY ./replace.build.js replace.build.js
|
||||
COPY ./jest.preset.js jest.preset.js
|
||||
@ -34,7 +33,7 @@ COPY ./tsconfig.base.json tsconfig.base.json
|
||||
COPY ./libs libs
|
||||
COPY ./apps apps
|
||||
|
||||
RUN yarn build:all
|
||||
RUN yarn build:production
|
||||
|
||||
# Prepare the dist image with additional node_modules
|
||||
WORKDIR /ghostfolio/dist/apps/api
|
||||
@ -51,12 +50,12 @@ COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:16-slim
|
||||
FROM node:18-slim
|
||||
RUN apt update && apt install -y \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||
WORKDIR /ghostfolio/apps/api
|
||||
EXPOSE 3333
|
||||
CMD [ "yarn", "start:prod" ]
|
||||
EXPOSE ${PORT:-3333}
|
||||
CMD [ "yarn", "start:production" ]
|
||||
|
140
README.md
140
README.md
@ -1,37 +1,33 @@
|
||||
<div align="center">
|
||||
<a href="https://ghostfol.io">
|
||||
<img
|
||||
alt="Ghostfolio Logo"
|
||||
src="https://avatars.githubusercontent.com/u/82473144?s=200"
|
||||
width="100"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<h1>Ghostfolio</h1>
|
||||
<p>
|
||||
<strong>Open Source Wealth Management Software</strong>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/en/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/en/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/en/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="#contributing">
|
||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
|
||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
||||
</p>
|
||||
[<img src="https://avatars.githubusercontent.com/u/82473144?s=200" width="100" alt="Ghostfolio logo">](https://ghostfol.io)
|
||||
|
||||
# Ghostfolio
|
||||
|
||||
**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) | [**Twitter**](https://twitter.com/ghostfolio_)
|
||||
|
||||
[](https://www.buymeacoffee.com/ghostfolio)
|
||||
[](#contributing)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
|
||||
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
|
||||
|
||||
</div>
|
||||
|
||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||
|
||||
<div align="center">
|
||||
|
||||
[<img src="./apps/client/src/assets/images/video-preview.jpg" width="600" alt="Preview image of the Ghostfolio video trailer">](https://www.youtube.com/watch?v=yY6ObSQVJZk)
|
||||
|
||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
||||
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
|
||||
</div>
|
||||
|
||||
## Ghostfolio Premium
|
||||
|
||||
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development.
|
||||
|
||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
||||
|
||||
@ -46,23 +42,25 @@ Ghostfolio is for you if you are...
|
||||
- 🧘 into minimalism
|
||||
- 🧺 caring about diversifying your financial resources
|
||||
- 🆓 interested in financial independence
|
||||
- 🙅 saying no to spreadsheets in 2022
|
||||
- 🙅 saying no to spreadsheets
|
||||
- 😎 still reading this list
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Import and export transactions
|
||||
- ✅ Dark Mode
|
||||
- ✅ Zen Mode
|
||||
- ✅ Mobile-first design
|
||||
- ✅ Progressive Web App (PWA) with a mobile-first design
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300" alt="Image of a phone showing the Ghostfolio app open">
|
||||
|
||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
||||
</div>
|
||||
|
||||
## Technology Stack
|
||||
@ -79,14 +77,19 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
||||
|
||||
## Self-hosting
|
||||
|
||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`.
|
||||
|
||||
<div align="center">
|
||||
|
||||
[<img src="./apps/client/src/assets/images/button-buy-me-a-coffee.png" width="150" alt="Buy me a coffee button"/>](https://www.buymeacoffee.com/ghostfolio)
|
||||
|
||||
</div>
|
||||
|
||||
### Supported Environment Variables
|
||||
|
||||
| Name | Default Value | Description |
|
||||
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. Caution: This cannot be changed later! |
|
||||
| `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) |
|
||||
@ -104,7 +107,8 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
- Basic knowledge of Docker
|
||||
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- Local copy of this Git repository (clone)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||
|
||||
#### a. Run environment
|
||||
|
||||
@ -123,13 +127,10 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
```
|
||||
|
||||
#### Fetch Historical Data
|
||||
|
||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||
#### Setup
|
||||
|
||||
1. Open http://localhost:3333 in your browser
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
#### Upgrade Version
|
||||
|
||||
@ -137,40 +138,42 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
||||
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||
At each start, the container will automatically apply the database schema migrations if needed.
|
||||
|
||||
### Run with _Unraid_ (Community)
|
||||
### Home Server Systems (Community)
|
||||
|
||||
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
|
||||
Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 16+)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- A local copy of this Git repository (clone)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
1. Run `yarn build:dev` to build the source code including the assets
|
||||
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||
1. Run `yarn database:setup` to initialize the database schema
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
1. Open http://localhost:4200/en in your browser
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
### Start Server
|
||||
|
||||
<ol type="a">
|
||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
|
||||
<li>Serve: Run <code>yarn start:server</code></li>
|
||||
</ol>
|
||||
#### Debug
|
||||
|
||||
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
|
||||
#### Serve
|
||||
|
||||
Run `yarn start:server`
|
||||
|
||||
### Start Client
|
||||
|
||||
Run `yarn start:client`
|
||||
Run `yarn start:client` and open http://localhost:4200/en in your browser
|
||||
|
||||
### Start _Storybook_
|
||||
|
||||
@ -190,20 +193,24 @@ Run `yarn test`
|
||||
|
||||
## Public API
|
||||
|
||||
### Authorization: Bearer Token
|
||||
|
||||
Set the header for each request as follows:
|
||||
|
||||
```
|
||||
"Authorization": "Bearer eyJh..."
|
||||
```
|
||||
|
||||
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
|
||||
|
||||
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||
|
||||
### Import Activities
|
||||
|
||||
#### Request
|
||||
|
||||
`POST http://localhost:3333/api/v1/import`
|
||||
|
||||
#### Authorization: Bearer Token
|
||||
|
||||
Set the header as follows:
|
||||
|
||||
```
|
||||
"Authorization": "Bearer eyJh..."
|
||||
```
|
||||
|
||||
#### Body
|
||||
|
||||
```
|
||||
@ -215,7 +222,7 @@ Set the header as follows:
|
||||
"date": "2021-09-15T00:00:00.000Z",
|
||||
"fee": 19,
|
||||
"quantity": 5,
|
||||
"symbol": "MSFT"
|
||||
"symbol": "MSFT",
|
||||
"type": "BUY",
|
||||
"unitPrice": 298.58
|
||||
}
|
||||
@ -226,6 +233,7 @@ Set the header as follows:
|
||||
| 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 | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||
| date | string | Date in the format `ISO-8601` |
|
||||
@ -254,16 +262,22 @@ Set the header as follows:
|
||||
}
|
||||
```
|
||||
|
||||
## Community Projects
|
||||
|
||||
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
|
||||
|
||||
Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ repository to get listed as well. [Learn more →](https://docs.github.com/en/articles/classifying-your-repository-with-topics)
|
||||
|
||||
## Contributing
|
||||
|
||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||
|
||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
|
||||
|
||||
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
|
||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||
|
||||
## License
|
||||
|
||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
||||
© 2021 - 2023 [Ghostfolio](https://ghostfol.io)
|
||||
|
||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
395
angular.json
395
angular.json
@ -1,395 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"projects": {
|
||||
"api": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/api",
|
||||
"sourceRoot": "apps/api/src",
|
||||
"projectType": "application",
|
||||
"prefix": "api",
|
||||
"schematics": {},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@nrwl/node:webpack",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/api",
|
||||
"main": "apps/api/src/main.ts",
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"assets": ["apps/api/src/assets"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"generatePackageJson": true,
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/api/src/environments/environment.ts",
|
||||
"with": "apps/api/src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@nrwl/node:node",
|
||||
"options": {
|
||||
"buildTarget": "api:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/api/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["coverage/apps/api"]
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"client": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "apps/client",
|
||||
"sourceRoot": "apps/client/src",
|
||||
"prefix": "gf",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/client",
|
||||
"index": "apps/client/src/index.html",
|
||||
"main": "apps/client/src/main.ts",
|
||||
"polyfills": "apps/client/src/polyfills.ts",
|
||||
"tsConfig": "apps/client/tsconfig.app.json",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "assetlinks.json",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../.well-known"
|
||||
},
|
||||
{
|
||||
"glob": "CHANGELOG.md",
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "LICENSE",
|
||||
"input": "",
|
||||
"output": "./../assets"
|
||||
},
|
||||
{
|
||||
"glob": "robots.txt",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "sitemap.xml",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/ionicons/dist/ionicons",
|
||||
"output": "./../ionicons"
|
||||
},
|
||||
{
|
||||
"glob": "**/*.js",
|
||||
"input": "node_modules/ionicons/dist/",
|
||||
"output": "./../"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "apps/client/src/assets",
|
||||
"output": "./../assets/"
|
||||
}
|
||||
],
|
||||
"styles": ["apps/client/src/styles.scss"],
|
||||
"scripts": ["node_modules/marked/marked.min.js"],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"baseHref": "/de/",
|
||||
"localize": ["de"]
|
||||
},
|
||||
"development-en": {
|
||||
"baseHref": "/en/",
|
||||
"localize": ["en"]
|
||||
},
|
||||
"development-es": {
|
||||
"baseHref": "/es/",
|
||||
"localize": ["es"]
|
||||
},
|
||||
"development-it": {
|
||||
"baseHref": "/it/",
|
||||
"localize": ["it"]
|
||||
},
|
||||
"development-nl": {
|
||||
"baseHref": "/nl/",
|
||||
"localize": ["nl"]
|
||||
},
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/client/src/environments/environment.ts",
|
||||
"with": "apps/client/src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "5mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "10kb"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"proxyConfig": "apps/client/proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"development-de": {
|
||||
"browserTarget": "client:build:development-de"
|
||||
},
|
||||
"development-en": {
|
||||
"browserTarget": "client:build:development-en"
|
||||
},
|
||||
"development-es": {
|
||||
"browserTarget": "client:build:development-es"
|
||||
},
|
||||
"development-it": {
|
||||
"browserTarget": "client:build:development-it"
|
||||
},
|
||||
"development-nl": {
|
||||
"browserTarget": "client:build:development-nl"
|
||||
},
|
||||
"production": {
|
||||
"browserTarget": "client:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
||||
"options": {
|
||||
"browserTarget": "client:build",
|
||||
"includeContext": true,
|
||||
"outputPath": "src/locales",
|
||||
"targetFiles": [
|
||||
"messages.de.xlf",
|
||||
"messages.es.xlf",
|
||||
"messages.it.xlf",
|
||||
"messages.nl.xlf"
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/client/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/client/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["coverage/apps/client"]
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
"locales": {
|
||||
"de": {
|
||||
"baseHref": "/de/",
|
||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||
},
|
||||
"es": {
|
||||
"baseHref": "/es/",
|
||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
||||
},
|
||||
"it": {
|
||||
"baseHref": "/it/",
|
||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||
},
|
||||
"nl": {
|
||||
"baseHref": "/nl/",
|
||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||
}
|
||||
},
|
||||
"sourceLocale": "en"
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"client-e2e": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/client-e2e",
|
||||
"sourceRoot": "apps/client-e2e/src",
|
||||
"projectType": "application",
|
||||
"architect": {
|
||||
"e2e": {
|
||||
"builder": "@nrwl/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/client-e2e/cypress.json",
|
||||
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
|
||||
"devServerTarget": "client:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "client:serve:production"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["client"]
|
||||
},
|
||||
"common": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "libs/common",
|
||||
"sourceRoot": "libs/common/src",
|
||||
"projectType": "library",
|
||||
"architect": {
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/common/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/common"],
|
||||
"options": {
|
||||
"jestConfig": "libs/common/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"ui": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "libs/ui",
|
||||
"sourceRoot": "libs/ui/src",
|
||||
"prefix": "gf",
|
||||
"architect": {
|
||||
"test": {
|
||||
"builder": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/ui"],
|
||||
"options": {
|
||||
"jestConfig": "libs/ui/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"builder": "@storybook/angular:start-storybook",
|
||||
"options": {
|
||||
"port": 4400,
|
||||
"configDir": "libs/ui/.storybook",
|
||||
"browserTarget": "ui:build-storybook",
|
||||
"compodoc": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"builder": "@storybook/angular:build-storybook",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputDir": "dist/storybook/ui",
|
||||
"configDir": "libs/ui/.storybook",
|
||||
"browserTarget": "ui:build-storybook",
|
||||
"compodoc": false
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"quiet": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
},
|
||||
"ui-e2e": {
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"root": "apps/ui-e2e",
|
||||
"sourceRoot": "apps/ui-e2e/src",
|
||||
"projectType": "application",
|
||||
"architect": {
|
||||
"e2e": {
|
||||
"builder": "@nrwl/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/ui-e2e/cypress.json",
|
||||
"devServerTarget": "ui:storybook",
|
||||
"tsConfig": "apps/ui-e2e/tsconfig.json"
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"devServerTarget": "ui:storybook:ci"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["ui"]
|
||||
}
|
||||
}
|
||||
}
|
@ -2,13 +2,14 @@
|
||||
export default {
|
||||
displayName: 'api',
|
||||
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||
}
|
||||
},
|
||||
globals: {},
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': 'ts-jest'
|
||||
'^.+\\.[tj]s$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||
}
|
||||
]
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/api',
|
||||
|
57
apps/api/project.json
Normal file
57
apps/api/project.json
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "api",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/api/src",
|
||||
"projectType": "application",
|
||||
"prefix": "api",
|
||||
"generators": {},
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/webpack:webpack",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/api",
|
||||
"main": "apps/api/src/main.ts",
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"assets": ["apps/api/src/assets"],
|
||||
"target": "node",
|
||||
"compiler": "tsc"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"generatePackageJson": true,
|
||||
"optimization": true,
|
||||
"extractLicenses": true,
|
||||
"inspect": false,
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/api/src/environments/environment.ts",
|
||||
"with": "apps/api/src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/js:node",
|
||||
"options": {
|
||||
"buildTarget": "api:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/api/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"outputs": ["{workspaceRoot}/coverage/apps/api"]
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccessController } from './access.controller';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Access, Prisma } from '@prisma/client';
|
||||
|
@ -1,10 +1,7 @@
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import {
|
||||
nullifyValuesInObject,
|
||||
nullifyValuesInObjects
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
@ -22,7 +19,8 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
@ -39,8 +37,7 @@ export class AccountController {
|
||||
private readonly accountService: AccountService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@ -85,87 +82,35 @@ export class AccountController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAllAccounts(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||
): Promise<Accounts> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations(
|
||||
impersonationUserId || this.request.user.id,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
accountsWithAggregations = {
|
||||
...nullifyValuesInObject(accountsWithAggregations, [
|
||||
'totalBalanceInBaseCurrency',
|
||||
'totalValueInBaseCurrency'
|
||||
]),
|
||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
||||
'balance',
|
||||
'balanceInBaseCurrency',
|
||||
'convertedBalance',
|
||||
'fee',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
return accountsWithAggregations;
|
||||
return this.portfolioService.getAccountsWithAggregations({
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAccountById(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Param('id') id: string
|
||||
): Promise<AccountWithValue> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations(
|
||||
impersonationUserId || this.request.user.id,
|
||||
[{ id, type: 'ACCOUNT' }],
|
||||
true
|
||||
);
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
accountsWithAggregations = {
|
||||
...nullifyValuesInObject(accountsWithAggregations, [
|
||||
'totalBalanceInBaseCurrency',
|
||||
'totalValueInBaseCurrency'
|
||||
]),
|
||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
||||
'balance',
|
||||
'balanceInBaseCurrency',
|
||||
'convertedBalance',
|
||||
'fee',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
])
|
||||
};
|
||||
}
|
||||
const accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations({
|
||||
filters: [{ id, type: 'ACCOUNT' }],
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
return accountsWithAggregations.accounts[0];
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.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.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.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';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccountController } from './account.controller';
|
||||
@ -15,6 +16,7 @@ import { AccountService } from './account.service';
|
||||
controllers: [AccountController],
|
||||
exports: [AccountService],
|
||||
imports: [
|
||||
AccountBalanceModule,
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||
@ -11,16 +12,21 @@ import { CashDetails } from './interfaces/cash-details.interface';
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
public constructor(
|
||||
private readonly accountBalanceService: AccountBalanceService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async account(
|
||||
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
|
||||
): Promise<Account | null> {
|
||||
return this.prismaService.account.findUnique({
|
||||
where: accountWhereUniqueInput
|
||||
public async account({
|
||||
id_userId
|
||||
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
||||
const { id, userId } = id_userId;
|
||||
|
||||
const [account] = await this.accounts({
|
||||
where: { id, userId }
|
||||
});
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public async accountWithOrders(
|
||||
@ -50,9 +56,11 @@ export class AccountService {
|
||||
Platform?: Platform;
|
||||
})[]
|
||||
> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
const { include = {}, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prismaService.account.findMany({
|
||||
include.balances = { orderBy: { date: 'desc' }, take: 1 };
|
||||
|
||||
const accounts = await this.prismaService.account.findMany({
|
||||
cursor,
|
||||
include,
|
||||
orderBy,
|
||||
@ -60,15 +68,36 @@ export class AccountService {
|
||||
take,
|
||||
where
|
||||
});
|
||||
|
||||
return accounts.map((account) => {
|
||||
account = { ...account, balance: account.balances[0]?.value ?? 0 };
|
||||
|
||||
delete account.balances;
|
||||
|
||||
return account;
|
||||
});
|
||||
}
|
||||
|
||||
public async createAccount(
|
||||
data: Prisma.AccountCreateInput,
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
return this.prismaService.account.create({
|
||||
const account = await this.prismaService.account.create({
|
||||
data
|
||||
});
|
||||
|
||||
await this.prismaService.accountBalance.create({
|
||||
data: {
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: account.id, userId: aUserId }
|
||||
}
|
||||
},
|
||||
value: data.balance
|
||||
}
|
||||
});
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public async deleteAccount(
|
||||
@ -167,9 +196,65 @@ export class AccountService {
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
const { data, where } = params;
|
||||
|
||||
await this.prismaService.accountBalance.create({
|
||||
data: {
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: where.id_userId
|
||||
}
|
||||
},
|
||||
value: <number>data.balance
|
||||
}
|
||||
});
|
||||
|
||||
return this.prismaService.account.update({
|
||||
data,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async updateAccountBalance({
|
||||
accountId,
|
||||
amount,
|
||||
currency,
|
||||
date,
|
||||
userId
|
||||
}: {
|
||||
accountId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
date: Date;
|
||||
userId: string;
|
||||
}) {
|
||||
const { balance, currency: currencyOfAccount } = await this.account({
|
||||
id_userId: {
|
||||
userId,
|
||||
id: accountId
|
||||
}
|
||||
});
|
||||
|
||||
const amountInCurrencyOfAccount =
|
||||
await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
amount,
|
||||
currency,
|
||||
currencyOfAccount,
|
||||
date
|
||||
);
|
||||
|
||||
if (amountInCurrencyOfAccount) {
|
||||
await this.accountBalanceService.createAccountBalance({
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId,
|
||||
id: accountId
|
||||
}
|
||||
}
|
||||
},
|
||||
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
@ -6,17 +7,30 @@ import {
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accountType: AccountType;
|
||||
accountType?: AccountType;
|
||||
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) =>
|
||||
isString(value) ? value.trim() : value
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExcluded?: boolean;
|
||||
|
12
apps/api/src/app/account/transfer-balance.dto.ts
Normal file
12
apps/api/src/app/account/transfer-balance.dto.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class TransferBalanceDto {
|
||||
@IsString()
|
||||
accountIdFrom: string;
|
||||
|
||||
@IsString()
|
||||
accountIdTo: string;
|
||||
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
@ -6,14 +7,23 @@ import {
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accountType: AccountType;
|
||||
accountType?: AccountType;
|
||||
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) =>
|
||||
isString(value) ? value.trim() : value
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
|
@ -1,18 +1,25 @@
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
EnhancedSymbolProfile,
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import type {
|
||||
MarketDataPreset,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -21,18 +28,21 @@ import {
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { isDate } from 'date-fns';
|
||||
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
|
||||
import { isDate, parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AdminService } from './admin.service';
|
||||
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||
|
||||
@Controller('admin')
|
||||
@ -97,16 +107,21 @@ export class AdminController {
|
||||
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
for (const { dataSource, symbol } of uniqueAssets) {
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
this.dataGatheringService.gatherMax();
|
||||
}
|
||||
@ -128,16 +143,21 @@ export class AdminController {
|
||||
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
for (const { dataSource, symbol } of uniqueAssets) {
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Post('gather/profile-data/:dataSource/:symbol')
|
||||
@ -158,14 +178,17 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
await this.dataGatheringService.addJobToQueue({
|
||||
data: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Post('gather/:dataSource/:symbol')
|
||||
@ -210,7 +233,7 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
const date = parseISO(dateString);
|
||||
|
||||
if (!isDate(date)) {
|
||||
throw new HttpException(
|
||||
@ -229,7 +252,12 @@ export class AdminController {
|
||||
@Get('market-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getMarketData(
|
||||
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
||||
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||
@Query('presetId') presetId?: MarketDataPreset,
|
||||
@Query('skip') skip?: number,
|
||||
@Query('sortColumn') sortColumn?: string,
|
||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||
@Query('take') take?: number
|
||||
): Promise<AdminMarketData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
@ -254,7 +282,14 @@ export class AdminController {
|
||||
})
|
||||
];
|
||||
|
||||
return this.adminService.getMarketData(filters);
|
||||
return this.adminService.getMarketData({
|
||||
filters,
|
||||
presetId,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
skip: isNaN(skip) ? undefined : skip,
|
||||
take: isNaN(take) ? undefined : take
|
||||
});
|
||||
}
|
||||
|
||||
@Get('market-data/:dataSource/:symbol')
|
||||
@ -298,12 +333,13 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
const date = parseISO(dateString);
|
||||
|
||||
return this.marketDataService.updateMarketData({
|
||||
data: { ...data, dataSource },
|
||||
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
|
||||
where: {
|
||||
date_symbol: {
|
||||
dataSource_date_symbol: {
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
}
|
||||
@ -311,6 +347,28 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async addProfileData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<SymbolProfile | never> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.addAssetProfile({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@Delete('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteProfileData(
|
||||
@ -332,6 +390,32 @@ export class AdminController {
|
||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@Patch('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async patchAssetProfileData(
|
||||
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<EnhancedSymbolProfile> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.patchAssetProfileData({
|
||||
...assetProfileData,
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
|
||||
@Put('settings/:key')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updateProperty(
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.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.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.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';
|
||||
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.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AdminController } from './admin.controller';
|
||||
|
@ -1,11 +1,18 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
PROPERTY_CURRENCIES,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
@ -14,25 +21,55 @@ import {
|
||||
Filter,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
private baseCurrency: string;
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
) {}
|
||||
|
||||
public async addAssetProfile({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<SymbolProfile | never> {
|
||||
try {
|
||||
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfiles[symbol]?.currency) {
|
||||
throw new BadRequestException(
|
||||
`Asset profile not found for ${symbol} (${dataSource})`
|
||||
);
|
||||
}
|
||||
|
||||
return await this.symbolProfileService.add(
|
||||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Asset profile of ${symbol} (${dataSource}) already exists`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
@ -45,15 +82,15 @@ export class AdminService {
|
||||
exchangeRates: this.exchangeRateDataService
|
||||
.getCurrencies()
|
||||
.filter((currency) => {
|
||||
return currency !== this.baseCurrency;
|
||||
return currency !== DEFAULT_CURRENCY;
|
||||
})
|
||||
.map((currency) => {
|
||||
return {
|
||||
label1: this.baseCurrency,
|
||||
label1: DEFAULT_CURRENCY,
|
||||
label2: currency,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
this.baseCurrency,
|
||||
DEFAULT_CURRENCY,
|
||||
currency
|
||||
)
|
||||
};
|
||||
@ -61,13 +98,39 @@ export class AdminService {
|
||||
settings: await this.propertyService.get(),
|
||||
transactionCount: await this.prismaService.order.count(),
|
||||
userCount: await this.prismaService.user.count(),
|
||||
users: await this.getUsersWithAnalytics()
|
||||
users: await this.getUsersWithAnalytics(),
|
||||
version: environment.version
|
||||
};
|
||||
}
|
||||
|
||||
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
|
||||
public async getMarketData({
|
||||
filters,
|
||||
presetId,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
skip,
|
||||
take = Number.MAX_SAFE_INTEGER
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
presetId?: MarketDataPreset;
|
||||
skip?: number;
|
||||
sortColumn?: string;
|
||||
sortDirection?: Prisma.SortOrder;
|
||||
take?: number;
|
||||
}): Promise<AdminMarketData> {
|
||||
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
|
||||
[{ symbol: 'asc' }];
|
||||
const where: Prisma.SymbolProfileWhereInput = {};
|
||||
|
||||
if (presetId === 'CURRENCIES') {
|
||||
return this.getMarketDataForCurrencies();
|
||||
} else if (
|
||||
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
||||
presetId === 'ETF_WITHOUT_SECTORS'
|
||||
) {
|
||||
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
||||
}
|
||||
|
||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||
filters,
|
||||
(filter) => {
|
||||
@ -75,47 +138,40 @@ export class AdminService {
|
||||
}
|
||||
);
|
||||
|
||||
const marketData = await this.prismaService.marketData.groupBy({
|
||||
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['dataSource', 'symbol']
|
||||
});
|
||||
|
||||
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
||||
|
||||
if (filtersByAssetSubClass) {
|
||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||
} else {
|
||||
currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.map(({ dataSource, symbol }) => {
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === dataSource &&
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
marketDataItemCount,
|
||||
symbol,
|
||||
countriesCount: 0,
|
||||
sectorsCount: 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
if (sortColumn) {
|
||||
orderBy = [{ [sortColumn]: sortDirection }];
|
||||
|
||||
if (sortColumn === 'activitiesCount') {
|
||||
orderBy = {
|
||||
Order: {
|
||||
_count: sortDirection
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let [assetProfiles, count] = await Promise.all([
|
||||
this.prismaService.symbolProfile.findMany({
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
comment: true,
|
||||
countries: true,
|
||||
dataSource: true,
|
||||
Order: {
|
||||
@ -127,37 +183,64 @@ export class AdminService {
|
||||
sectors: true,
|
||||
symbol: true
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
const countriesCount = symbolProfile.countries
|
||||
? Object.keys(symbolProfile.countries).length
|
||||
: 0;
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === symbolProfile.dataSource &&
|
||||
marketDataItem.symbol === symbolProfile.symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
const sectorsCount = symbolProfile.sectors
|
||||
? Object.keys(symbolProfile.sectors).length
|
||||
: 0;
|
||||
}),
|
||||
this.prismaService.symbolProfile.count({ where })
|
||||
]);
|
||||
|
||||
return {
|
||||
countriesCount,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activityCount: symbolProfile._count.Order,
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
date: symbolProfile.Order?.[0]?.date,
|
||||
symbol: symbolProfile.symbol
|
||||
};
|
||||
});
|
||||
let marketData = assetProfiles.map(
|
||||
({
|
||||
_count,
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
dataSource,
|
||||
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,
|
||||
countriesCount,
|
||||
dataSource,
|
||||
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;
|
||||
}
|
||||
|
||||
return {
|
||||
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
||||
count,
|
||||
marketData
|
||||
};
|
||||
}
|
||||
|
||||
@ -165,8 +248,14 @@ export class AdminService {
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||
return {
|
||||
marketData: await this.marketDataService.marketDataItems({
|
||||
const [[assetProfile], marketData] = await Promise.all([
|
||||
this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
]),
|
||||
this.marketDataService.marketDataItems({
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
@ -175,32 +264,112 @@ export class AdminService {
|
||||
symbol
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
marketData,
|
||||
assetProfile: assetProfile ?? {
|
||||
symbol,
|
||||
currency: '-'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async patchAssetProfileData({
|
||||
comment,
|
||||
dataSource,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
await this.symbolProfileService.updateSymbolProfile({
|
||||
comment,
|
||||
dataSource,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
});
|
||||
|
||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
]);
|
||||
|
||||
return symbolProfile;
|
||||
}
|
||||
|
||||
public async putSetting(key: string, value: string) {
|
||||
let response: Property;
|
||||
|
||||
if (value === '') {
|
||||
response = await this.propertyService.delete({ key });
|
||||
} else {
|
||||
if (value) {
|
||||
response = await this.propertyService.put({ key, value });
|
||||
} else {
|
||||
response = await this.propertyService.delete({ key });
|
||||
}
|
||||
|
||||
if (key === PROPERTY_CURRENCIES) {
|
||||
if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') {
|
||||
await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false');
|
||||
} else if (key === PROPERTY_CURRENCIES) {
|
||||
await this.exchangeRateDataService.initialize();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
marketDataItemCount,
|
||||
symbol,
|
||||
assetClass: 'CASH',
|
||||
countriesCount: 0,
|
||||
sectorsCount: 0
|
||||
};
|
||||
});
|
||||
|
||||
return { marketData, count: marketData.length };
|
||||
}
|
||||
|
||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||
orderBy: {
|
||||
let orderBy: any = {
|
||||
createdAt: 'desc'
|
||||
};
|
||||
let where;
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
orderBy = {
|
||||
Analytics: {
|
||||
updatedAt: 'desc'
|
||||
}
|
||||
},
|
||||
};
|
||||
where = {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||
orderBy,
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
select: { Account: true, Order: true }
|
||||
@ -208,6 +377,7 @@ export class AdminService {
|
||||
Analytics: {
|
||||
select: {
|
||||
activityCount: true,
|
||||
country: true,
|
||||
updatedAt: true
|
||||
}
|
||||
},
|
||||
@ -215,19 +385,16 @@ export class AdminService {
|
||||
id: true,
|
||||
Subscription: true
|
||||
},
|
||||
take: 30,
|
||||
where: {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
}
|
||||
}
|
||||
take: 30
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||
const daysSinceRegistration =
|
||||
differenceInDays(new Date(), createdAt) + 1;
|
||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
||||
const engagement = Analytics
|
||||
? Analytics.activityCount / daysSinceRegistration
|
||||
: undefined;
|
||||
|
||||
const subscription = this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
@ -241,7 +408,8 @@ export class AdminService {
|
||||
id,
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
lastActivity: Analytics.updatedAt,
|
||||
country: Analytics?.country,
|
||||
lastActivity: Analytics?.updatedAt,
|
||||
transactionCount: _count.Order || 0
|
||||
};
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { QueueController } from './queue.controller';
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
} from '@ghostfolio/common/config';
|
||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JobStatus, Queue } from 'bull';
|
||||
|
||||
@Injectable()
|
||||
@ -23,14 +23,11 @@ export class QueueService {
|
||||
}: {
|
||||
status?: JobStatus[];
|
||||
}) {
|
||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
await job.remove();
|
||||
} catch (error) {
|
||||
Logger.warn(error, 'QueueService');
|
||||
}
|
||||
for (const statusItem of status) {
|
||||
await this.dataGatheringQueue.clean(
|
||||
300,
|
||||
statusItem === 'waiting' ? 'wait' : statusItem
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,18 +41,23 @@ export class QueueService {
|
||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||
|
||||
const jobsWithState = await Promise.all(
|
||||
jobs.slice(0, limit).map(async (job) => {
|
||||
return {
|
||||
attemptsMade: job.attemptsMade + 1,
|
||||
data: job.data,
|
||||
finishedOn: job.finishedOn,
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
stacktrace: job.stacktrace,
|
||||
state: await job.getState(),
|
||||
timestamp: job.timestamp
|
||||
};
|
||||
})
|
||||
jobs
|
||||
.filter((job) => {
|
||||
return job;
|
||||
})
|
||||
.slice(0, limit)
|
||||
.map(async (job) => {
|
||||
return {
|
||||
attemptsMade: job.attemptsMade + 1,
|
||||
data: job.data,
|
||||
finishedOn: job.finishedOn,
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
stacktrace: job.stacktrace,
|
||||
state: await job.getState(),
|
||||
timestamp: job.timestamp
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
|
18
apps/api/src/app/admin/update-asset-profile.dto.ts
Normal file
18
apps/api/src/app/admin/update-asset-profile.dto.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateAssetProfileDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
comment?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
scraperConfiguration?: Prisma.InputJsonObject;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
symbolMapping?: {
|
||||
[dataProvider: string]: string;
|
||||
};
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
|
@ -1,35 +1,45 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||
import {
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
SUPPORTED_LANGUAGE_CODES
|
||||
} from '@ghostfolio/common/config';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
|
||||
import { AccessModule } from './access/access.module';
|
||||
import { AccountModule } from './account/account.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { FrontendMiddleware } from './frontend.middleware';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { InfoModule } from './info/info.module';
|
||||
import { LogoModule } from './logo/logo.module';
|
||||
import { OrderModule } from './order/order.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||
import { SitemapModule } from './sitemap/sitemap.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
import { SymbolModule } from './symbol/symbol.module';
|
||||
import { TagModule } from './tag/tag.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
|
||||
@Module({
|
||||
@ -43,7 +53,7 @@ import { UserModule } from './user/user.module';
|
||||
BullModule.forRoot({
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST,
|
||||
port: parseInt(process.env.REDIS_PORT, 10),
|
||||
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD
|
||||
}
|
||||
}),
|
||||
@ -52,41 +62,51 @@ import { UserModule } from './user/user.module';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateModule,
|
||||
ExchangeRateDataModule,
|
||||
ExportModule,
|
||||
HealthModule,
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
LogoModule,
|
||||
OrderModule,
|
||||
PlatformModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ServeStaticModule.forRoot({
|
||||
serveStaticOptions: {
|
||||
/*etag: false // Disable etag header to fix PWA
|
||||
setHeaders: (res, path) => {
|
||||
if (path.includes('ngsw.json')) {
|
||||
// Disable cache (https://stackoverflow.com/questions/22632593/how-to-disable-webpage-caching-in-expressjs-nodejs/39775595)
|
||||
// https://gertjans.home.xs4all.nl/javascript/cache-control.html#no-cache
|
||||
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
}
|
||||
}*/
|
||||
},
|
||||
exclude: ['/api*', '/sitemap.xml'],
|
||||
rootPath: join(__dirname, '..', 'client'),
|
||||
exclude: ['/api*']
|
||||
serveStaticOptions: {
|
||||
setHeaders: (res) => {
|
||||
if (res.req?.path === '/') {
|
||||
let languageCode = DEFAULT_LANGUAGE_CODE;
|
||||
|
||||
try {
|
||||
const code = res.req.headers['accept-language']
|
||||
.split(',')[0]
|
||||
.split('-')[0];
|
||||
|
||||
if (SUPPORTED_LANGUAGE_CODES.includes(code)) {
|
||||
languageCode = code;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
res.set('Location', `/${languageCode}`);
|
||||
res.statusCode = StatusCodes.MOVED_PERMANENTLY;
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
SitemapModule,
|
||||
SubscriptionModule,
|
||||
SymbolModule,
|
||||
TagModule,
|
||||
TwitterBotModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [CronService]
|
||||
})
|
||||
export class AppModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(FrontendMiddleware)
|
||||
.forRoutes({ path: '*', method: RequestMethod.ALL });
|
||||
}
|
||||
}
|
||||
export class AppModule {}
|
||||
|
@ -1,7 +1,7 @@
|
||||
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.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthDevice, Prisma } from '@prisma/client';
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
@ -16,6 +16,7 @@ import {
|
||||
Version
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Request, Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
@ -32,13 +33,32 @@ export class AuthController {
|
||||
private readonly webAuthService: WebAuthService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('anonymous/:accessToken')
|
||||
public async accessTokenLogin(
|
||||
public async accessTokenLoginGet(
|
||||
@Param('accessToken') accessToken: string
|
||||
): Promise<OAuthResponse> {
|
||||
try {
|
||||
const authToken =
|
||||
await this.authService.validateAnonymousLogin(accessToken);
|
||||
return { authToken };
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('anonymous')
|
||||
public async accessTokenLogin(
|
||||
@Body() body: { accessToken: string }
|
||||
): Promise<OAuthResponse> {
|
||||
try {
|
||||
const authToken = await this.authService.validateAnonymousLogin(
|
||||
accessToken
|
||||
body.accessToken
|
||||
);
|
||||
return { authToken };
|
||||
} catch {
|
||||
@ -58,18 +78,21 @@ export class AuthController {
|
||||
@Get('google/callback')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public googleLoginCallback(@Req() req, @Res() res) {
|
||||
public googleLoginCallback(
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
) {
|
||||
// Handles the Google OAuth2 callback
|
||||
const jwt: string = req.user.jwt;
|
||||
const jwt: string = (<any>request.user).jwt;
|
||||
|
||||
if (jwt) {
|
||||
res.redirect(
|
||||
response.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
||||
);
|
||||
} else {
|
||||
res.redirect(
|
||||
response.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
||||
@ -77,13 +100,13 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
@Get('internet-identity/:principalId')
|
||||
@Post('internet-identity')
|
||||
public async internetIdentityLogin(
|
||||
@Param('principalId') principalId: string
|
||||
@Body() body: { principalId: string }
|
||||
): Promise<OAuthResponse> {
|
||||
try {
|
||||
const authToken = await this.authService.validateInternetIdentityLogin(
|
||||
principalId
|
||||
body.principalId
|
||||
);
|
||||
return { authToken };
|
||||
} catch {
|
||||
|
@ -2,8 +2,9 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@ -21,6 +22,7 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
}),
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
SubscriptionModule,
|
||||
UserModule
|
||||
],
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Provider } from '@prisma/client';
|
||||
@ -11,6 +12,7 @@ export class AuthService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@ -50,10 +52,19 @@ export class AuthService {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (!isUserSignupEnabled || true) {
|
||||
throw new Error('Sign up forbidden');
|
||||
}
|
||||
|
||||
// Create new user if not found
|
||||
user = await this.userService.createUser({
|
||||
provider,
|
||||
thirdPartyId: principalId
|
||||
data: {
|
||||
provider,
|
||||
thirdPartyId: principalId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -78,10 +89,19 @@ export class AuthService {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (!isUserSignupEnabled) {
|
||||
throw new Error('Sign up forbidden');
|
||||
}
|
||||
|
||||
// Create new user if not found
|
||||
user = await this.userService.createUser({
|
||||
provider,
|
||||
thirdPartyId
|
||||
data: {
|
||||
provider,
|
||||
thirdPartyId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Provider } from '@prisma/client';
|
||||
|
@ -1,33 +1,46 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
public constructor(
|
||||
readonly configurationService: ConfigurationService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly userService: UserService
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
passReqToCallback: true,
|
||||
secretOrKey: configurationService.get('JWT_SECRET_KEY')
|
||||
});
|
||||
}
|
||||
|
||||
public async validate({ id }: { id: string }) {
|
||||
public async validate(request: Request, { id }: { id: string }) {
|
||||
try {
|
||||
const timezone = request.headers[HEADER_KEY_TIMEZONE.toLowerCase()];
|
||||
const user = await this.userService.user({ id });
|
||||
|
||||
if (user) {
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { User: { connect: { id: user.id } } },
|
||||
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
|
||||
where: { userId: user.id }
|
||||
});
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
const country =
|
||||
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { country, User: { connect: { id: user.id } } },
|
||||
update: {
|
||||
country,
|
||||
activityCount: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
},
|
||||
where: { userId: user.id }
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
} else {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Inject,
|
||||
|
@ -1,24 +1,114 @@
|
||||
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 {
|
||||
import type {
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkResponse
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { BenchmarkService } from './benchmark.service';
|
||||
|
||||
@Controller('benchmark')
|
||||
export class BenchmarkController {
|
||||
public constructor(private readonly benchmarkService: BenchmarkService) {}
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const benchmark = await this.benchmarkService.addBenchmark({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
if (!benchmark) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return benchmark;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteBenchmark(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const benchmark = await this.benchmarkService.deleteBenchmark({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
if (!benchmark) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return benchmark;
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
StatusCodes.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@ -30,8 +120,8 @@ export class BenchmarkController {
|
||||
}
|
||||
|
||||
@Get(':dataSource/:symbol/:startDateString')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getBenchmarkMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('startDateString') startDateString: string,
|
||||
|
@ -1,10 +1,11 @@
|
||||
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.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { BenchmarkController } from './benchmark.controller';
|
||||
@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service';
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolModule,
|
||||
|
@ -4,7 +4,15 @@ describe('BenchmarkService', () => {
|
||||
let benchmarkService: BenchmarkService;
|
||||
|
||||
beforeAll(async () => {
|
||||
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
|
||||
benchmarkService = new BenchmarkService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
it('calculateChangeInPercentage', async () => {
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
MAX_CHART_ITEMS,
|
||||
PROPERTY_BENCHMARKS
|
||||
@ -11,6 +12,7 @@ import {
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkProperty,
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -18,6 +20,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import { uniqBy } from 'lodash';
|
||||
import ms from 'ms';
|
||||
|
||||
@Injectable()
|
||||
@ -27,6 +30,7 @@ export class BenchmarkService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private readonly symbolProfileService: SymbolProfileService,
|
||||
@ -62,17 +66,18 @@ export class BenchmarkService {
|
||||
|
||||
const promises: Promise<number>[] = [];
|
||||
|
||||
const quotes = await this.dataProviderService.getQuotes(
|
||||
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
const quotes = await this.dataProviderService.getQuotes({
|
||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||
}
|
||||
|
||||
const allTimeHighs = await Promise.all(promises);
|
||||
let storeInCache = true;
|
||||
|
||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||
const { marketPrice } =
|
||||
@ -85,6 +90,8 @@ export class BenchmarkService {
|
||||
allTimeHigh,
|
||||
marketPrice
|
||||
);
|
||||
} else {
|
||||
storeInCache = false;
|
||||
}
|
||||
|
||||
return {
|
||||
@ -100,20 +107,22 @@ export class BenchmarkService {
|
||||
};
|
||||
});
|
||||
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(benchmarks),
|
||||
ms('4 hours') / 1000
|
||||
);
|
||||
if (storeInCache) {
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(benchmarks),
|
||||
ms('4 hours') / 1000
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
}
|
||||
|
||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||
const symbolProfileIds: string[] = (
|
||||
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
|
||||
symbolProfileId: string;
|
||||
}[]) ?? []
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as BenchmarkProperty[]) ?? []
|
||||
).map(({ symbolProfileId }) => {
|
||||
return symbolProfileId;
|
||||
});
|
||||
@ -199,6 +208,80 @@ export class BenchmarkService {
|
||||
return response;
|
||||
}
|
||||
|
||||
public async addBenchmark({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
||||
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||
where: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
});
|
||||
|
||||
if (!assetProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
let benchmarks =
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as BenchmarkProperty[]) ?? [];
|
||||
|
||||
benchmarks.push({ symbolProfileId: assetProfile.id });
|
||||
|
||||
benchmarks = uniqBy(benchmarks, 'symbolProfileId');
|
||||
|
||||
await this.propertyService.put({
|
||||
key: PROPERTY_BENCHMARKS,
|
||||
value: JSON.stringify(benchmarks)
|
||||
});
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
id: assetProfile.id,
|
||||
name: assetProfile.name
|
||||
};
|
||||
}
|
||||
|
||||
public async deleteBenchmark({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
||||
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||
where: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
});
|
||||
|
||||
if (!assetProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let benchmarks =
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as BenchmarkProperty[]) ?? [];
|
||||
|
||||
benchmarks = benchmarks.filter(({ symbolProfileId }) => {
|
||||
return symbolProfileId !== assetProfile.id;
|
||||
});
|
||||
|
||||
await this.propertyService.put({
|
||||
key: PROPERTY_BENCHMARKS,
|
||||
value: JSON.stringify(benchmarks)
|
||||
});
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
id: assetProfile.id,
|
||||
name: assetProfile.name
|
||||
};
|
||||
}
|
||||
|
||||
private getMarketCondition(aPerformanceInPercent: number) {
|
||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||
}
|
||||
|
10
apps/api/src/app/cache/cache.module.ts
vendored
10
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,10 +1,10 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.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.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.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';
|
||||
|
||||
import { CacheController } from './cache.controller';
|
||||
|
43
apps/api/src/app/exchange-rate/exchange-rate.controller.ts
Normal file
43
apps/api/src/app/exchange-rate/exchange-rate.controller.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Param,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { ExchangeRateService } from './exchange-rate.service';
|
||||
|
||||
@Controller('exchange-rate')
|
||||
export class ExchangeRateController {
|
||||
public constructor(
|
||||
private readonly exchangeRateService: ExchangeRateService
|
||||
) {}
|
||||
|
||||
@Get(':symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getExchangeRate(
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<IDataProviderHistoricalResponse> {
|
||||
const date = parseISO(dateString);
|
||||
|
||||
const exchangeRate = await this.exchangeRateService.getExchangeRate({
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
|
||||
if (exchangeRate) {
|
||||
return { marketPrice: exchangeRate };
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
}
|
13
apps/api/src/app/exchange-rate/exchange-rate.module.ts
Normal file
13
apps/api/src/app/exchange-rate/exchange-rate.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExchangeRateController } from './exchange-rate.controller';
|
||||
import { ExchangeRateService } from './exchange-rate.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ExchangeRateController],
|
||||
exports: [ExchangeRateService],
|
||||
imports: [ExchangeRateDataModule],
|
||||
providers: [ExchangeRateService]
|
||||
})
|
||||
export class ExchangeRateModule {}
|
26
apps/api/src/app/exchange-rate/exchange-rate.service.ts
Normal file
26
apps/api/src/app/exchange-rate/exchange-rate.service.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ExchangeRateService {
|
||||
public constructor(
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService
|
||||
) {}
|
||||
|
||||
public async getExchangeRate({
|
||||
date,
|
||||
symbol
|
||||
}: {
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}): Promise<number> {
|
||||
const [currency1, currency2] = symbol.split('-');
|
||||
|
||||
return this.exchangeRateDataService.toCurrencyAtDate(
|
||||
1,
|
||||
currency1,
|
||||
currency2,
|
||||
date
|
||||
);
|
||||
}
|
||||
}
|
@ -1,8 +1,9 @@
|
||||
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.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 { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExportController } from './export.controller';
|
||||
@ -10,10 +11,11 @@ import { ExportService } from './export.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AccountModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
PrismaModule,
|
||||
OrderModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
controllers: [ExportController],
|
||||
|
@ -1,11 +1,15 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly orderService: OrderService
|
||||
) {}
|
||||
|
||||
public async export({
|
||||
activityIds,
|
||||
@ -14,19 +18,30 @@ export class ExportService {
|
||||
activityIds?: string[];
|
||||
userId: string;
|
||||
}): Promise<Export> {
|
||||
let activities = await this.prismaService.order.findMany({
|
||||
const accounts = (
|
||||
await this.accountService.accounts({
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
},
|
||||
where: { userId }
|
||||
})
|
||||
).map(
|
||||
({ balance, comment, currency, id, isExcluded, name, platformId }) => {
|
||||
return {
|
||||
balance,
|
||||
comment,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
platformId
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
let activities = await this.orderService.orders({
|
||||
include: { SymbolProfile: true },
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
accountId: true,
|
||||
comment: true,
|
||||
date: true,
|
||||
fee: true,
|
||||
id: true,
|
||||
quantity: true,
|
||||
SymbolProfile: true,
|
||||
type: true,
|
||||
unitPrice: true
|
||||
},
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
@ -38,6 +53,7 @@ export class ExportService {
|
||||
|
||||
return {
|
||||
meta: { date: new Date().toISOString(), version: environment.version },
|
||||
accounts,
|
||||
activities: activities.map(
|
||||
({
|
||||
accountId,
|
||||
@ -61,7 +77,13 @@ export class ExportService {
|
||||
currency: SymbolProfile.currency,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
date: date.toISOString(),
|
||||
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
||||
symbol:
|
||||
type === 'FEE' ||
|
||||
type === 'INTEREST' ||
|
||||
type === 'ITEM' ||
|
||||
type === 'LIABILITY'
|
||||
? SymbolProfile.name
|
||||
: SymbolProfile.symbol
|
||||
};
|
||||
}
|
||||
)
|
||||
|
@ -1,146 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class FrontendMiddleware implements NestMiddleware {
|
||||
public indexHtmlDe = '';
|
||||
public indexHtmlEn = '';
|
||||
public indexHtmlEs = '';
|
||||
public indexHtmlIt = '';
|
||||
public indexHtmlNl = '';
|
||||
public isProduction: boolean;
|
||||
|
||||
public constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {
|
||||
const NODE_ENV =
|
||||
this.configService.get<'development' | 'production'>('NODE_ENV') ??
|
||||
'development';
|
||||
|
||||
this.isProduction = NODE_ENV === 'production';
|
||||
|
||||
try {
|
||||
this.indexHtmlDe = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('de'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlEn = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlEs = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('es'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlIt = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('it'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlNl = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('nl'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
public use(req: Request, res: Response, next: NextFunction) {
|
||||
let featureGraphicPath = 'assets/cover.png';
|
||||
|
||||
if (
|
||||
req.path === '/en/blog/2022/08/500-stars-on-github' ||
|
||||
req.path === '/en/blog/2022/08/500-stars-on-github/'
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||
} else if (
|
||||
req.path === '/en/blog/2022/10/hacktoberfest-2022' ||
|
||||
req.path === '/en/blog/2022/10/hacktoberfest-2022/'
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
||||
}
|
||||
|
||||
if (
|
||||
req.path.startsWith('/api/') ||
|
||||
this.isFileRequest(req.url) ||
|
||||
!this.isProduction
|
||||
) {
|
||||
// Skip
|
||||
next();
|
||||
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlDe, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'de',
|
||||
path: req.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/es' || req.path.startsWith('/es/')) {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlEs, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'es',
|
||||
path: req.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/it' || req.path.startsWith('/it/')) {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlIt, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'it',
|
||||
path: req.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/nl' || req.path.startsWith('/nl/')) {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlNl, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'nl',
|
||||
path: req.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlEn, {
|
||||
featureGraphicPath,
|
||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||
path: req.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getPathOfIndexHtmlFile(aLocale: string) {
|
||||
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
|
||||
}
|
||||
|
||||
private interpolate(template: string, context: any) {
|
||||
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
|
||||
const properties = objectPath.split('.');
|
||||
return properties.reduce(
|
||||
(previous, current) => previous?.[current],
|
||||
context
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private isFileRequest(filename: string) {
|
||||
if (filename === '/assets/LICENSE') {
|
||||
return true;
|
||||
} else if (filename.includes('auth/ey')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filename.split('.').pop() !== filename;
|
||||
}
|
||||
}
|
56
apps/api/src/app/health/health.controller.ts
Normal file
56
apps/api/src/app/health/health.controller.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Param,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
public constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get()
|
||||
public async getHealth() {}
|
||||
|
||||
@Get('data-enhancer/:name')
|
||||
public async getHealthOfDataEnhancer(@Param('name') name: string) {
|
||||
const hasResponse =
|
||||
await this.healthService.hasResponseFromDataEnhancer(name);
|
||||
|
||||
if (hasResponse !== true) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||
StatusCodes.SERVICE_UNAVAILABLE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('data-provider/:dataSource')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getHealthOfDataProvider(
|
||||
@Param('dataSource') dataSource: DataSource
|
||||
) {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
const hasResponse =
|
||||
await this.healthService.hasResponseFromDataProvider(dataSource);
|
||||
|
||||
if (hasResponse !== true) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||
StatusCodes.SERVICE_UNAVAILABLE
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
14
apps/api/src/app/health/health.module.ts
Normal file
14
apps/api/src/app/health/health.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
|
||||
providers: [HealthService]
|
||||
})
|
||||
export class HealthModule {}
|
20
apps/api/src/app/health/health.service.ts
Normal file
20
apps/api/src/app/health/health.service.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
public constructor(
|
||||
private readonly dataEnhancerService: DataEnhancerService,
|
||||
private readonly dataProviderService: DataProviderService
|
||||
) {}
|
||||
|
||||
public async hasResponseFromDataEnhancer(aName: string) {
|
||||
return this.dataEnhancerService.enhance(aName);
|
||||
}
|
||||
|
||||
public async hasResponseFromDataProvider(aDataSource: DataSource) {
|
||||
return this.dataProviderService.checkQuote(aDataSource);
|
||||
}
|
||||
}
|
@ -1,8 +1,15 @@
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, ValidateNested } from 'class-validator';
|
||||
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
||||
|
||||
export class ImportDataDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Type(() => CreateAccountDto)
|
||||
@ValidateNested({ each: true })
|
||||
accounts: CreateAccountDto[];
|
||||
|
||||
@IsArray()
|
||||
@Type(() => CreateOrderDto)
|
||||
@ValidateNested({ each: true })
|
||||
|
@ -1,16 +1,25 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Logger,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { ImportDataDto } from './import-data.dto';
|
||||
@ -26,8 +35,19 @@ export class ImportController {
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async import(@Body() importData: ImportDataDto): Promise<void> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async import(
|
||||
@Body() importData: ImportDataDto,
|
||||
@Query('dryRun') isDryRun?: boolean
|
||||
): Promise<ImportResponse> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.createAccount
|
||||
) ||
|
||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
@ -45,12 +65,19 @@ export class ImportController {
|
||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
try {
|
||||
return await this.importService.import({
|
||||
const activities = await this.importService.import({
|
||||
isDryRun,
|
||||
maxActivitiesToImport,
|
||||
activities: importData.activities,
|
||||
userCurrency,
|
||||
accountsDto: importData.accounts ?? [],
|
||||
activitiesDto: importData.activities,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return { activities };
|
||||
} catch (error) {
|
||||
Logger.error(error, ImportController);
|
||||
|
||||
@ -63,4 +90,23 @@ export class ImportController {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('dividends/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async gatherDividends(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<ImportResponse> {
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
const activities = await this.importService.getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
userCurrency
|
||||
});
|
||||
|
||||
return { activities };
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,15 @@
|
||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.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 { PrismaModule } from '@ghostfolio/api/services/prisma.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';
|
||||
|
||||
import { ImportController } from './import.controller';
|
||||
@ -19,9 +23,13 @@ import { ImportService } from './import.service';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
OrderModule,
|
||||
PlatformModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
providers: [ImportService]
|
||||
})
|
||||
|
@ -1,149 +1,594 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import {
|
||||
Activity,
|
||||
ActivityError
|
||||
} from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getAssetProfileIdentifier,
|
||||
parseDate
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AccountWithPlatform,
|
||||
OrderWithAccount
|
||||
} from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isSameDay, parseISO } from 'date-fns';
|
||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, format, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly orderService: OrderService
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly platformService: PlatformService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
userCurrency
|
||||
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
|
||||
try {
|
||||
const { firstBuyDate, historicalData, orders } =
|
||||
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||
|
||||
const [[assetProfile], dividends] = await Promise.all([
|
||||
this.symbolProfileService.getSymbolProfiles([
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
]),
|
||||
await this.dataProviderService.getDividends({
|
||||
dataSource,
|
||||
symbol,
|
||||
from: parseDate(firstBuyDate),
|
||||
granularity: 'day',
|
||||
to: new Date()
|
||||
})
|
||||
]);
|
||||
|
||||
const accounts = orders.map((order) => {
|
||||
return order.Account;
|
||||
});
|
||||
|
||||
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||
|
||||
return Object.entries(dividends).map(([dateString, { marketPrice }]) => {
|
||||
const quantity =
|
||||
historicalData.find((historicalDataItem) => {
|
||||
return historicalDataItem.date === dateString;
|
||||
})?.quantity ?? 0;
|
||||
|
||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||
|
||||
const isDuplicate = orders.some((activity) => {
|
||||
return (
|
||||
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||
isSameDay(activity.date, parseDate(dateString)) &&
|
||||
activity.quantity === quantity &&
|
||||
activity.SymbolProfile.symbol === assetProfile.symbol &&
|
||||
activity.type === 'DIVIDEND' &&
|
||||
activity.unitPrice === marketPrice
|
||||
);
|
||||
});
|
||||
|
||||
const error: ActivityError = isDuplicate
|
||||
? { code: 'IS_DUPLICATE' }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
Account,
|
||||
error,
|
||||
quantity,
|
||||
value,
|
||||
accountId: Account?.id,
|
||||
accountUserId: undefined,
|
||||
comment: undefined,
|
||||
createdAt: undefined,
|
||||
date: parseDate(dateString),
|
||||
fee: 0,
|
||||
feeInBaseCurrency: 0,
|
||||
id: assetProfile.id,
|
||||
isDraft: false,
|
||||
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
||||
symbolProfileId: assetProfile.id,
|
||||
type: 'DIVIDEND',
|
||||
unitPrice: marketPrice,
|
||||
updatedAt: undefined,
|
||||
userId: Account?.userId,
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
assetProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async import({
|
||||
activities,
|
||||
accountsDto,
|
||||
activitiesDto,
|
||||
isDryRun = false,
|
||||
maxActivitiesToImport,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
accountsDto: Partial<CreateAccountDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
isDryRun?: boolean;
|
||||
maxActivitiesToImport: number;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<void> {
|
||||
for (const activity of activities) {
|
||||
if (!activity.dataSource) {
|
||||
if (activity.type === 'ITEM') {
|
||||
activity.dataSource = 'MANUAL';
|
||||
} else {
|
||||
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||
}): Promise<Activity[]> {
|
||||
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||
|
||||
if (!isDryRun && accountsDto?.length) {
|
||||
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||
this.accountService.accounts({
|
||||
where: {
|
||||
id: {
|
||||
in: accountsDto.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
this.platformService.getPlatforms()
|
||||
]);
|
||||
|
||||
for (const account of accountsDto) {
|
||||
// Check if there is any existing account with the same ID
|
||||
const accountWithSameId = existingAccounts.find(
|
||||
(existingAccount) => existingAccount.id === account.id
|
||||
);
|
||||
|
||||
// If there is no account or if the account belongs to a different user then create a new account
|
||||
if (!accountWithSameId || accountWithSameId.userId !== userId) {
|
||||
let oldAccountId: string;
|
||||
const platformId = account.platformId;
|
||||
|
||||
delete account.platformId;
|
||||
|
||||
if (accountWithSameId) {
|
||||
oldAccountId = account.id;
|
||||
delete account.id;
|
||||
}
|
||||
|
||||
let accountObject: Prisma.AccountCreateInput = {
|
||||
...account,
|
||||
User: { connect: { id: userId } }
|
||||
};
|
||||
|
||||
if (
|
||||
existingPlatforms.some(({ id }) => {
|
||||
return id === platformId;
|
||||
})
|
||||
) {
|
||||
accountObject = {
|
||||
...accountObject,
|
||||
Platform: { connect: { id: platformId } }
|
||||
};
|
||||
}
|
||||
|
||||
const newAccount = await this.accountService.createAccount(
|
||||
accountObject,
|
||||
userId
|
||||
);
|
||||
|
||||
// Store the new to old account ID mappings for updating activities
|
||||
if (accountWithSameId && oldAccountId) {
|
||||
accountIdMapping[oldAccountId] = newAccount.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.validateActivities({
|
||||
activities,
|
||||
maxActivitiesToImport,
|
||||
for (const activity of activitiesDto) {
|
||||
if (!activity.dataSource) {
|
||||
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
|
||||
activity.dataSource = DataSource.MANUAL;
|
||||
} else {
|
||||
activity.dataSource =
|
||||
this.dataProviderService.getDataSourceForImport();
|
||||
}
|
||||
}
|
||||
|
||||
// If a new account is created, then update the accountId in all activities
|
||||
if (!isDryRun) {
|
||||
if (Object.keys(accountIdMapping).includes(activity.accountId)) {
|
||||
activity.accountId = accountIdMapping[activity.accountId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const assetProfiles = await this.validateActivities({
|
||||
activitiesDto,
|
||||
maxActivitiesToImport
|
||||
});
|
||||
|
||||
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||
activitiesDto,
|
||||
userId
|
||||
});
|
||||
|
||||
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
||||
(account) => {
|
||||
return account.id;
|
||||
const accounts = (await this.accountService.getAccounts(userId)).map(
|
||||
({ id, name }) => {
|
||||
return { id, name };
|
||||
}
|
||||
);
|
||||
|
||||
for (const {
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
dataSource,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
} of activities) {
|
||||
await this.orderService.createOrder({
|
||||
comment,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: accountIds.includes(accountId) ? accountId : undefined,
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
if (isDryRun) {
|
||||
accountsDto.forEach(({ id, name }) => {
|
||||
accounts.push({ id, name });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async validateActivities({
|
||||
activities,
|
||||
maxActivitiesToImport,
|
||||
userId
|
||||
}: {
|
||||
activities: Partial<CreateOrderDto>[];
|
||||
maxActivitiesToImport: number;
|
||||
userId: string;
|
||||
}) {
|
||||
if (activities?.length > maxActivitiesToImport) {
|
||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||
const activities: Activity[] = [];
|
||||
|
||||
for (let [
|
||||
index,
|
||||
{
|
||||
accountId,
|
||||
comment,
|
||||
date,
|
||||
error,
|
||||
fee,
|
||||
quantity,
|
||||
SymbolProfile,
|
||||
type,
|
||||
unitPrice
|
||||
}
|
||||
] of activitiesExtendedWithErrors.entries()) {
|
||||
const assetProfile = assetProfiles[
|
||||
getAssetProfileIdentifier({
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
symbol: SymbolProfile.symbol
|
||||
})
|
||||
] ?? {
|
||||
currency: SymbolProfile.currency,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
symbol: SymbolProfile.symbol
|
||||
};
|
||||
const {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
countries,
|
||||
createdAt,
|
||||
currency,
|
||||
dataSource,
|
||||
id,
|
||||
isin,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping,
|
||||
url,
|
||||
updatedAt
|
||||
} = assetProfile;
|
||||
const validatedAccount = accounts.find(({ id }) => {
|
||||
return id === accountId;
|
||||
});
|
||||
|
||||
let order:
|
||||
| OrderWithAccount
|
||||
| (Omit<OrderWithAccount, 'Account'> & {
|
||||
Account?: { id: string; name: string };
|
||||
});
|
||||
|
||||
if (SymbolProfile.currency !== assetProfile.currency) {
|
||||
// Convert the unit price and fee to the asset currency if the imported
|
||||
// activity is in a different currency
|
||||
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
unitPrice,
|
||||
SymbolProfile.currency,
|
||||
assetProfile.currency,
|
||||
date
|
||||
);
|
||||
|
||||
if (!unitPrice) {
|
||||
throw new Error(
|
||||
`activities.${index} historical exchange rate at ${format(
|
||||
date,
|
||||
DATE_FORMAT
|
||||
)} is not available from "${SymbolProfile.currency}" to "${
|
||||
assetProfile.currency
|
||||
}"`
|
||||
);
|
||||
}
|
||||
|
||||
fee = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
fee,
|
||||
SymbolProfile.currency,
|
||||
assetProfile.currency,
|
||||
date
|
||||
);
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
order = {
|
||||
comment,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccount?.id,
|
||||
accountUserId: undefined,
|
||||
createdAt: new Date(),
|
||||
id: uuidv4(),
|
||||
isDraft: isAfter(date, endOfToday()),
|
||||
SymbolProfile: {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
countries,
|
||||
createdAt,
|
||||
currency,
|
||||
dataSource,
|
||||
id,
|
||||
isin,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping,
|
||||
updatedAt,
|
||||
url,
|
||||
comment: assetProfile.comment
|
||||
},
|
||||
Account: validatedAccount,
|
||||
symbolProfileId: undefined,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
} else {
|
||||
if (error) {
|
||||
continue;
|
||||
}
|
||||
|
||||
order = await this.orderService.createOrder({
|
||||
comment,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccount?.id,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
updateAccountBalance: false,
|
||||
User: { connect: { id: userId } }
|
||||
});
|
||||
}
|
||||
|
||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||
|
||||
activities.push({
|
||||
...order,
|
||||
error,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
fee,
|
||||
currency,
|
||||
userCurrency
|
||||
),
|
||||
// @ts-ignore
|
||||
SymbolProfile: assetProfile,
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
currency,
|
||||
userCurrency
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
activities.sort((activity1, activity2) => {
|
||||
return Number(activity1.date) - Number(activity2.date);
|
||||
});
|
||||
|
||||
if (!isDryRun) {
|
||||
// Gather symbol data in the background, if not dry run
|
||||
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => {
|
||||
return getAssetProfileIdentifier({
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
symbol: SymbolProfile.symbol
|
||||
});
|
||||
});
|
||||
|
||||
this.dataGatheringService.gatherSymbols(
|
||||
uniqueActivities.map(({ date, SymbolProfile }) => {
|
||||
return {
|
||||
date,
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
symbol: SymbolProfile.symbol
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return activities;
|
||||
}
|
||||
|
||||
private async extendActivitiesWithErrors({
|
||||
activitiesDto,
|
||||
userId
|
||||
}: {
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
userId: string;
|
||||
}): Promise<Partial<Activity>[]> {
|
||||
const existingActivities = await this.orderService.orders({
|
||||
include: { SymbolProfile: true },
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
return activitiesDto.map(
|
||||
({
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
dataSource,
|
||||
date: dateString,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice
|
||||
}) => {
|
||||
const date = parseISO(<string>(<unknown>dateString));
|
||||
const isDuplicate = existingActivities.some((activity) => {
|
||||
return (
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
activity.SymbolProfile.dataSource === dataSource &&
|
||||
isSameDay(activity.date, date) &&
|
||||
activity.fee === fee &&
|
||||
activity.quantity === quantity &&
|
||||
activity.SymbolProfile.symbol === symbol &&
|
||||
activity.type === type &&
|
||||
activity.unitPrice === unitPrice
|
||||
);
|
||||
});
|
||||
|
||||
const error: ActivityError = isDuplicate
|
||||
? { code: 'IS_DUPLICATE' }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
accountId,
|
||||
comment,
|
||||
date,
|
||||
error,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
SymbolProfile: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol,
|
||||
assetClass: null,
|
||||
assetSubClass: null,
|
||||
comment: null,
|
||||
countries: null,
|
||||
createdAt: undefined,
|
||||
id: undefined,
|
||||
isin: null,
|
||||
name: null,
|
||||
scraperConfiguration: null,
|
||||
sectors: null,
|
||||
symbolMapping: null,
|
||||
updatedAt: undefined,
|
||||
url: null
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private isUniqueAccount(accounts: AccountWithPlatform[]) {
|
||||
const uniqueAccountIds = new Set<string>();
|
||||
|
||||
for (const account of accounts) {
|
||||
uniqueAccountIds.add(account.id);
|
||||
}
|
||||
|
||||
return uniqueAccountIds.size === 1;
|
||||
}
|
||||
|
||||
private async validateActivities({
|
||||
activitiesDto,
|
||||
maxActivitiesToImport
|
||||
}: {
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
maxActivitiesToImport: number;
|
||||
}) {
|
||||
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||
}
|
||||
|
||||
const assetProfiles: {
|
||||
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||
} = {};
|
||||
|
||||
const uniqueActivitiesDto = uniqBy(
|
||||
activitiesDto,
|
||||
({ dataSource, symbol }) => {
|
||||
return getAssetProfileIdentifier({ dataSource, symbol });
|
||||
}
|
||||
);
|
||||
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||
] of activities.entries()) {
|
||||
const duplicateActivity = existingActivities.find((activity) => {
|
||||
return (
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
activity.SymbolProfile.dataSource === dataSource &&
|
||||
isSameDay(activity.date, parseISO(<string>(<unknown>date))) &&
|
||||
activity.fee === fee &&
|
||||
activity.quantity === quantity &&
|
||||
activity.SymbolProfile.symbol === symbol &&
|
||||
activity.type === type &&
|
||||
activity.unitPrice === unitPrice
|
||||
);
|
||||
});
|
||||
|
||||
if (duplicateActivity) {
|
||||
throw new Error(`activities.${index} is a duplicate activity`);
|
||||
}
|
||||
|
||||
{ currency, dataSource, symbol }
|
||||
] of uniqueActivitiesDto.entries()) {
|
||||
if (dataSource !== 'MANUAL') {
|
||||
const quotes = await this.dataProviderService.getQuotes([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
const assetProfile = (
|
||||
await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
])
|
||||
)?.[symbol];
|
||||
|
||||
if (quotes[symbol] === undefined) {
|
||||
if (!assetProfile?.name) {
|
||||
throw new Error(
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
|
||||
if (quotes[symbol].currency !== currency) {
|
||||
if (
|
||||
assetProfile.currency !== currency &&
|
||||
!this.exchangeRateDataService.hasCurrencyPair(
|
||||
currency,
|
||||
assetProfile.currency
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
|
||||
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
|
||||
);
|
||||
}
|
||||
|
||||
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||
assetProfile;
|
||||
}
|
||||
}
|
||||
|
||||
return assetProfiles;
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,14 @@
|
||||
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.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.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.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.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
@ -26,11 +28,12 @@ import { InfoService } from './info.service';
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
}),
|
||||
PrismaModule,
|
||||
PlatformModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
TagModule
|
||||
TagModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [InfoService]
|
||||
})
|
||||
|
@ -1,12 +1,17 @@
|
||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import {
|
||||
DEMO_USER_ID,
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_REQUEST_TIMEOUT,
|
||||
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||
PROPERTY_DEMO_USER_ID,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||
PROPERTY_STRIPE_CONFIG,
|
||||
@ -14,18 +19,22 @@ import {
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
encodeDataSource,
|
||||
extractNumberFromString
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import {
|
||||
InfoItem,
|
||||
Statistics,
|
||||
Subscription
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { subDays } from 'date-fns';
|
||||
import { format, subDays } from 'date-fns';
|
||||
import got from 'got';
|
||||
|
||||
@Injectable()
|
||||
export class InfoService {
|
||||
@ -36,18 +45,18 @@ export class InfoService {
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly platformService: PlatformService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private readonly tagService: TagService
|
||||
private readonly tagService: TagService,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
public async get(): Promise<InfoItem> {
|
||||
const info: Partial<InfoItem> = {};
|
||||
let isReadOnlyMode: boolean;
|
||||
const platforms = await this.prismaService.platform.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true }
|
||||
const platforms = await this.platformService.getPlatforms({
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
let systemMessage: string;
|
||||
|
||||
@ -58,9 +67,7 @@ export class InfoService {
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||
) {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
info.fearAndGreedDataSource = encodeDataSource(
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
);
|
||||
@ -71,10 +78,6 @@ export class InfoService {
|
||||
globalPermissions.push(permissions.enableFearAndGreedIndex);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
globalPermissions.push(permissions.enableImport);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
@ -92,6 +95,10 @@ export class InfoService {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
globalPermissions.push(permissions.enableSubscription);
|
||||
|
||||
info.countriesOfSubscribers =
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
|
||||
)) as string[]) ?? [];
|
||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||
}
|
||||
|
||||
@ -103,29 +110,40 @@ export class InfoService {
|
||||
)) as string;
|
||||
}
|
||||
|
||||
const isUserSignupEnabled =
|
||||
await this.propertyService.isUserSignupEnabled();
|
||||
|
||||
if (isUserSignupEnabled) {
|
||||
globalPermissions.push(permissions.createUserAccount);
|
||||
}
|
||||
|
||||
const [benchmarks, demoAuthToken, statistics, subscriptions, tags] =
|
||||
await Promise.all([
|
||||
this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||
this.getDemoAuthToken(),
|
||||
this.getStatistics(),
|
||||
this.getSubscriptions(),
|
||||
this.tagService.get()
|
||||
]);
|
||||
|
||||
return {
|
||||
...info,
|
||||
benchmarks,
|
||||
demoAuthToken,
|
||||
globalPermissions,
|
||||
isReadOnlyMode,
|
||||
platforms,
|
||||
statistics,
|
||||
subscriptions,
|
||||
systemMessage,
|
||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
statistics: await this.getStatistics(),
|
||||
subscriptions: await this.getSubscriptions(),
|
||||
tags: await this.tagService.get()
|
||||
tags,
|
||||
baseCurrency: DEFAULT_CURRENCY,
|
||||
currencies: this.exchangeRateDataService.getCurrencies()
|
||||
};
|
||||
}
|
||||
|
||||
private async countActiveUsers(aDays: number) {
|
||||
return await this.prismaService.user.count({
|
||||
orderBy: {
|
||||
Analytics: {
|
||||
updatedAt: 'desc'
|
||||
}
|
||||
},
|
||||
return this.userService.count({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
@ -147,20 +165,24 @@ export class InfoService {
|
||||
|
||||
private async countDockerHubPulls(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||
'GET',
|
||||
'json',
|
||||
200,
|
||||
{
|
||||
'User-Agent': 'request'
|
||||
}
|
||||
);
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
|
||||
const { pull_count } = await got(
|
||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||
{
|
||||
headers: { 'User-Agent': 'request' },
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
}
|
||||
).json<any>();
|
||||
|
||||
const { pull_count } = await get();
|
||||
return pull_count;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'InfoService');
|
||||
Logger.error(error, 'InfoService - DockerHub');
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@ -168,16 +190,18 @@ export class InfoService {
|
||||
|
||||
private async countGitHubContributors(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
'https://github.com/ghostfolio/ghostfolio',
|
||||
'GET',
|
||||
'string',
|
||||
200,
|
||||
{}
|
||||
);
|
||||
const abortController = new AbortController();
|
||||
|
||||
const html = await get();
|
||||
const $ = cheerio.load(html);
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
|
||||
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
const $ = cheerio.load(body);
|
||||
|
||||
return extractNumberFromString(
|
||||
$(
|
||||
@ -185,7 +209,7 @@ export class InfoService {
|
||||
).text()
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error(error, 'InfoService');
|
||||
Logger.error(error, 'InfoService - GitHub');
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@ -193,30 +217,31 @@ export class InfoService {
|
||||
|
||||
private async countGitHubStargazers(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||
'GET',
|
||||
'json',
|
||||
200,
|
||||
{
|
||||
'User-Agent': 'request'
|
||||
}
|
||||
);
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
|
||||
const { stargazers_count } = await got(
|
||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||
{
|
||||
headers: { 'User-Agent': 'request' },
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
}
|
||||
).json<any>();
|
||||
|
||||
const { stargazers_count } = await get();
|
||||
return stargazers_count;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'InfoService');
|
||||
Logger.error(error, 'InfoService - GitHub');
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async countNewUsers(aDays: number) {
|
||||
return await this.prismaService.user.count({
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
},
|
||||
return this.userService.count({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
@ -240,10 +265,18 @@ export class InfoService {
|
||||
)) as string;
|
||||
}
|
||||
|
||||
private getDemoAuthToken() {
|
||||
return this.jwtService.sign({
|
||||
id: DEMO_USER_ID
|
||||
});
|
||||
private async getDemoAuthToken() {
|
||||
const demoUserId = (await this.propertyService.getByKey(
|
||||
PROPERTY_DEMO_USER_ID
|
||||
)) as string;
|
||||
|
||||
if (demoUserId) {
|
||||
return this.jwtService.sign({
|
||||
id: demoUserId
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async getStatistics() {
|
||||
@ -271,6 +304,7 @@ export class InfoService {
|
||||
const gitHubContributors = await this.countGitHubContributors();
|
||||
const gitHubStargazers = await this.countGitHubStargazers();
|
||||
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||
const uptime = await this.getUptime();
|
||||
|
||||
statistics = {
|
||||
activeUsers1d,
|
||||
@ -279,7 +313,8 @@ export class InfoService {
|
||||
gitHubContributors,
|
||||
gitHubStargazers,
|
||||
newUsers30d,
|
||||
slackCommunityUsers
|
||||
slackCommunityUsers,
|
||||
uptime
|
||||
};
|
||||
|
||||
await this.redisCacheService.set(
|
||||
@ -290,19 +325,54 @@ export class InfoService {
|
||||
return statistics;
|
||||
}
|
||||
|
||||
private async getSubscriptions(): Promise<Subscription[]> {
|
||||
private async getSubscriptions(): Promise<{
|
||||
[offer in SubscriptionOffer]: Subscription;
|
||||
}> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stripeConfig = await this.prismaService.property.findUnique({
|
||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||
});
|
||||
return (
|
||||
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
if (stripeConfig) {
|
||||
return [JSON.parse(stripeConfig.value)];
|
||||
private async getUptime(): Promise<number> {
|
||||
{
|
||||
try {
|
||||
const monitorId = (await this.propertyService.getByKey(
|
||||
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
||||
)) as string;
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
|
||||
const { data } = await got(
|
||||
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||
subDays(new Date(), 90),
|
||||
DATE_FORMAT
|
||||
)}&to${format(new Date(), DATE_FORMAT)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.configurationService.get(
|
||||
'BETTER_UPTIME_API_KEY'
|
||||
)}`
|
||||
},
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
}
|
||||
).json<any>();
|
||||
|
||||
return data.attributes.availability / 100;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'InfoService - Better Stack');
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
54
apps/api/src/app/logo/logo.controller.ts
Normal file
54
apps/api/src/app/logo/logo.controller.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Query,
|
||||
Res,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { LogoService } from './logo.service';
|
||||
|
||||
@Controller('logo')
|
||||
export class LogoController {
|
||||
public constructor(private readonly logoService: LogoService) {}
|
||||
|
||||
@Get(':dataSource/:symbol')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getLogoByDataSourceAndSymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string,
|
||||
@Res() response: Response
|
||||
) {
|
||||
try {
|
||||
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
response.contentType('image/png');
|
||||
response.send(buffer);
|
||||
} catch {
|
||||
response.status(HttpStatus.NOT_FOUND).send();
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
public async getLogoByUrl(
|
||||
@Query('url') url: string,
|
||||
@Res() response: Response
|
||||
) {
|
||||
try {
|
||||
const buffer = await this.logoService.getLogoByUrl(url);
|
||||
|
||||
response.contentType('image/png');
|
||||
response.send(buffer);
|
||||
} catch {
|
||||
response.status(HttpStatus.NOT_FOUND).send();
|
||||
}
|
||||
}
|
||||
}
|
13
apps/api/src/app/logo/logo.module.ts
Normal file
13
apps/api/src/app/logo/logo.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LogoController } from './logo.controller';
|
||||
import { LogoService } from './logo.service';
|
||||
|
||||
@Module({
|
||||
controllers: [LogoController],
|
||||
imports: [ConfigurationModule, SymbolProfileModule],
|
||||
providers: [LogoService]
|
||||
})
|
||||
export class LogoModule {}
|
60
apps/api/src/app/logo/logo.service.ts
Normal file
60
apps/api/src/app/logo/logo.service.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import got from 'got';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Injectable()
|
||||
export class LogoService {
|
||||
public constructor(
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async getLogoByDataSourceAndSymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset) {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfile) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return this.getBuffer(assetProfile.url);
|
||||
}
|
||||
|
||||
public async getLogoByUrl(aUrl: string) {
|
||||
return this.getBuffer(aUrl);
|
||||
}
|
||||
|
||||
private getBuffer(aUrl: string) {
|
||||
const abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
|
||||
return got(
|
||||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||
{
|
||||
headers: { 'User-Agent': 'request' },
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
}
|
||||
).buffer();
|
||||
}
|
||||
}
|
@ -8,6 +8,7 @@ import {
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
@ -64,4 +65,8 @@ export class CreateOrderDto {
|
||||
|
||||
@IsNumber()
|
||||
unitPrice: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
updateAccountBalance?: boolean;
|
||||
}
|
||||
|
@ -5,6 +5,14 @@ export interface Activities {
|
||||
}
|
||||
|
||||
export interface Activity extends OrderWithAccount {
|
||||
error?: ActivityError;
|
||||
feeInBaseCurrency: number;
|
||||
updateAccountBalance?: boolean;
|
||||
value: number;
|
||||
valueInBaseCurrency: number;
|
||||
}
|
||||
|
||||
export interface ActivityError {
|
||||
code: 'IS_DUPLICATE';
|
||||
message?: string;
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
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';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
@ -36,12 +36,30 @@ import { UpdateOrderDto } from './update-order.dto';
|
||||
@Controller('order')
|
||||
export class OrderController {
|
||||
public constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly orderService: OrderService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Delete()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteOrders(): Promise<number> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.orderService.deleteOrders({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||
@ -68,65 +86,33 @@ export class OrderController {
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getAllOrders(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
@Query('skip') skip?: number,
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('take') take?: number
|
||||
): Promise<Activities> {
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
|
||||
const filters: Filter[] = [
|
||||
...accountIds.map((accountId) => {
|
||||
return <Filter>{
|
||||
id: accountId,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}),
|
||||
...assetClasses.map((assetClass) => {
|
||||
return <Filter>{
|
||||
id: assetClass,
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
}),
|
||||
...tagIds.map((tagId) => {
|
||||
return <Filter>{
|
||||
id: tagId,
|
||||
type: 'TAG'
|
||||
};
|
||||
})
|
||||
];
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
let activities = await this.orderService.getOrders({
|
||||
const activities = await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
skip: isNaN(skip) ? undefined : skip,
|
||||
take: isNaN(take) ? undefined : take,
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
activities = nullifyValuesInObjects(activities, [
|
||||
'fee',
|
||||
'feeInBaseCurrency',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
]);
|
||||
}
|
||||
|
||||
return { activities };
|
||||
}
|
||||
|
||||
@ -143,7 +129,7 @@ export class OrderController {
|
||||
);
|
||||
}
|
||||
|
||||
return this.orderService.createOrder({
|
||||
const order = await this.orderService.createOrder({
|
||||
...data,
|
||||
date: parseISO(data.date),
|
||||
SymbolProfile: {
|
||||
@ -164,6 +150,20 @@ export class OrderController {
|
||||
User: { connect: { id: this.request.user.id } },
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (data.dataSource && !order.isDraft) {
|
||||
// Gather symbol data in the background, if data source is set
|
||||
// (not MANUAL) and not draft
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: data.dataSource,
|
||||
date: order.date,
|
||||
symbol: data.symbol
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
|
@ -2,13 +2,15 @@ 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 { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
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.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.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';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { OrderController } from './order.controller';
|
||||
@ -18,6 +20,7 @@ import { OrderService } from './order.service';
|
||||
controllers: [OrderController],
|
||||
exports: [OrderService],
|
||||
imports: [
|
||||
ApiModule,
|
||||
CacheModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
@ -29,6 +32,6 @@ import { OrderService } from './order.service';
|
||||
SymbolProfileModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [AccountService, OrderService]
|
||||
providers: [AccountBalanceService, AccountService, OrderService]
|
||||
})
|
||||
export class OrderModule {}
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
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';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
@ -73,35 +74,42 @@ export class OrderService {
|
||||
dataSource?: DataSource;
|
||||
symbol?: string;
|
||||
tags?: Tag[];
|
||||
updateAccountBalance?: boolean;
|
||||
userId: string;
|
||||
}
|
||||
): Promise<Order> {
|
||||
const defaultAccount = (
|
||||
await this.accountService.getAccounts(data.userId)
|
||||
).find((account) => {
|
||||
return account.isDefault === true;
|
||||
});
|
||||
let Account;
|
||||
|
||||
const tags = data.tags ?? [];
|
||||
|
||||
let Account = {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId: data.userId,
|
||||
id: data.accountId ?? defaultAccount?.id
|
||||
if (data.accountId) {
|
||||
Account = {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId: data.userId,
|
||||
id: data.accountId
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
if (data.type === 'ITEM') {
|
||||
const accountId = data.accountId;
|
||||
let currency = data.currency;
|
||||
const tags = data.tags ?? [];
|
||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||
const userId = data.userId;
|
||||
|
||||
if (
|
||||
data.type === 'FEE' ||
|
||||
data.type === 'INTEREST' ||
|
||||
data.type === 'ITEM' ||
|
||||
data.type === 'LIABILITY'
|
||||
) {
|
||||
const assetClass = data.assetClass;
|
||||
const assetSubClass = data.assetSubClass;
|
||||
const currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||
const dataSource: DataSource = 'MANUAL';
|
||||
const id = uuidv4();
|
||||
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||
|
||||
Account = undefined;
|
||||
data.id = id;
|
||||
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
||||
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
||||
@ -113,31 +121,23 @@ export class OrderService {
|
||||
dataSource,
|
||||
symbol: id
|
||||
};
|
||||
} else {
|
||||
data.SymbolProfile.connectOrCreate.create.symbol =
|
||||
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
||||
}
|
||||
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
|
||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||
|
||||
if (!isDraft) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
if (data.SymbolProfile.connectOrCreate.create.dataSource !== 'MANUAL') {
|
||||
this.dataGatheringService.addJobToQueue({
|
||||
data: {
|
||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: getAssetProfileIdentifier({
|
||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
})
|
||||
}
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
delete data.accountId;
|
||||
@ -152,11 +152,20 @@ export class OrderService {
|
||||
delete data.dataSource;
|
||||
delete data.symbol;
|
||||
delete data.tags;
|
||||
delete data.updateAccountBalance;
|
||||
delete data.userId;
|
||||
|
||||
const orderData: Prisma.OrderCreateInput = data;
|
||||
|
||||
return this.prismaService.order.create({
|
||||
const isDraft =
|
||||
data.type === 'FEE' ||
|
||||
data.type === 'INTEREST' ||
|
||||
data.type === 'ITEM' ||
|
||||
data.type === 'LIABILITY'
|
||||
? false
|
||||
: isAfter(data.date as Date, endOfToday());
|
||||
|
||||
const order = await this.prismaService.order.create({
|
||||
data: {
|
||||
...orderData,
|
||||
Account,
|
||||
@ -168,6 +177,27 @@ export class OrderService {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (updateAccountBalance === true) {
|
||||
let amount = new Big(data.unitPrice)
|
||||
.mul(data.quantity)
|
||||
.plus(data.fee)
|
||||
.toNumber();
|
||||
|
||||
if (data.type === 'BUY') {
|
||||
amount = new Big(amount).mul(-1).toNumber();
|
||||
}
|
||||
|
||||
await this.accountService.updateAccountBalance({
|
||||
accountId,
|
||||
amount,
|
||||
currency,
|
||||
userId,
|
||||
date: data.date as Date
|
||||
});
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
public async deleteOrder(
|
||||
@ -177,16 +207,31 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
|
||||
if (order.type === 'ITEM') {
|
||||
if (
|
||||
order.type === 'FEE' ||
|
||||
order.type === 'INTEREST' ||
|
||||
order.type === 'ITEM' ||
|
||||
order.type === 'LIABILITY'
|
||||
) {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> {
|
||||
const { count } = await this.prismaService.order.deleteMany({
|
||||
where
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async getOrders({
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
skip,
|
||||
take = Number.MAX_SAFE_INTEGER,
|
||||
types,
|
||||
userCurrency,
|
||||
userId,
|
||||
@ -194,6 +239,8 @@ export class OrderService {
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
types?: TypeOfOrder[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
@ -272,6 +319,8 @@ export class OrderService {
|
||||
|
||||
return (
|
||||
await this.orders({
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
include: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@ -288,7 +337,11 @@ export class OrderService {
|
||||
})
|
||||
)
|
||||
.filter((order) => {
|
||||
return withExcludedAccounts || order.Account?.isExcluded === false;
|
||||
return (
|
||||
withExcludedAccounts ||
|
||||
!order.Account ||
|
||||
order.Account?.isExcluded === false
|
||||
);
|
||||
})
|
||||
.map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
@ -336,7 +389,12 @@ export class OrderService {
|
||||
|
||||
let isDraft = false;
|
||||
|
||||
if (data.type === 'ITEM') {
|
||||
if (
|
||||
data.type === 'FEE' ||
|
||||
data.type === 'INTEREST' ||
|
||||
data.type === 'ITEM' ||
|
||||
data.type === 'LIABILITY'
|
||||
) {
|
||||
delete data.SymbolProfile.connect;
|
||||
} else {
|
||||
delete data.SymbolProfile.update;
|
||||
@ -362,6 +420,12 @@ export class OrderService {
|
||||
delete data.symbol;
|
||||
delete data.tags;
|
||||
|
||||
// Remove existing tags
|
||||
await this.prismaService.order.update({
|
||||
data: { tags: { set: [] } },
|
||||
where
|
||||
});
|
||||
|
||||
return this.prismaService.order.update({
|
||||
data: {
|
||||
...data,
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
|
9
apps/api/src/app/platform/create-platform.dto.ts
Normal file
9
apps/api/src/app/platform/create-platform.dto.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class CreatePlatformDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
url: string;
|
||||
}
|
115
apps/api/src/app/platform/platform.controller.ts
Normal file
115
apps/api/src/app/platform/platform.controller.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Platform } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { CreatePlatformDto } from './create-platform.dto';
|
||||
import { PlatformService } from './platform.service';
|
||||
import { UpdatePlatformDto } from './update-platform.dto';
|
||||
|
||||
@Controller('platform')
|
||||
export class PlatformController {
|
||||
public constructor(
|
||||
private readonly platformService: PlatformService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPlatforms() {
|
||||
return this.platformService.getPlatformsWithAccountCount();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async createPlatform(
|
||||
@Body() data: CreatePlatformDto
|
||||
): Promise<Platform> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createPlatform)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.platformService.createPlatform(data);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updatePlatform(
|
||||
@Param('id') id: string,
|
||||
@Body() data: UpdatePlatformDto
|
||||
) {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.updatePlatform)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const originalPlatform = await this.platformService.getPlatform({
|
||||
id
|
||||
});
|
||||
|
||||
if (!originalPlatform) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.platformService.updatePlatform({
|
||||
data: {
|
||||
...data
|
||||
},
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deletePlatform(@Param('id') id: string) {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deletePlatform)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const originalPlatform = await this.platformService.getPlatform({
|
||||
id
|
||||
});
|
||||
|
||||
if (!originalPlatform) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.platformService.deletePlatform({ id });
|
||||
}
|
||||
}
|
13
apps/api/src/app/platform/platform.module.ts
Normal file
13
apps/api/src/app/platform/platform.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PlatformController } from './platform.controller';
|
||||
import { PlatformService } from './platform.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PlatformController],
|
||||
exports: [PlatformService],
|
||||
imports: [PrismaModule],
|
||||
providers: [PlatformService]
|
||||
})
|
||||
export class PlatformModule {}
|
83
apps/api/src/app/platform/platform.service.ts
Normal file
83
apps/api/src/app/platform/platform.service.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Platform, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async createPlatform(data: Prisma.PlatformCreateInput) {
|
||||
return this.prismaService.platform.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async deletePlatform(
|
||||
where: Prisma.PlatformWhereUniqueInput
|
||||
): Promise<Platform> {
|
||||
return this.prismaService.platform.delete({ where });
|
||||
}
|
||||
|
||||
public async getPlatform(
|
||||
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
|
||||
): Promise<Platform> {
|
||||
return this.prismaService.platform.findUnique({
|
||||
where: platformWhereUniqueInput
|
||||
});
|
||||
}
|
||||
|
||||
public async getPlatforms({
|
||||
cursor,
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where
|
||||
}: {
|
||||
cursor?: Prisma.PlatformWhereUniqueInput;
|
||||
orderBy?: Prisma.PlatformOrderByWithRelationInput;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
where?: Prisma.PlatformWhereInput;
|
||||
} = {}) {
|
||||
return this.prismaService.platform.findMany({
|
||||
cursor,
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async getPlatformsWithAccountCount() {
|
||||
const platformsWithAccountCount =
|
||||
await this.prismaService.platform.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { Account: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return platformsWithAccountCount.map(({ _count, id, name, url }) => {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
url,
|
||||
accountCount: _count.Account
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async updatePlatform({
|
||||
data,
|
||||
where
|
||||
}: {
|
||||
data: Prisma.PlatformUpdateInput;
|
||||
where: Prisma.PlatformWhereUniqueInput;
|
||||
}): Promise<Platform> {
|
||||
return this.prismaService.platform.update({
|
||||
data,
|
||||
where
|
||||
});
|
||||
}
|
||||
}
|
12
apps/api/src/app/platform/update-platform.dto.ts
Normal file
12
apps/api/src/app/platform/update-platform.dto.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class UpdatePlatformDto {
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
url: string;
|
||||
}
|
@ -2,6 +2,7 @@ import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
|
||||
function mockGetValue(symbol: string, date: Date) {
|
||||
@ -21,6 +22,17 @@ function mockGetValue(symbol: string, date: Date) {
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'BTCUSD':
|
||||
if (isSameDay(parseDate('2015-01-01'), date)) {
|
||||
return { marketPrice: 314.25 };
|
||||
} else if (isSameDay(parseDate('2017-12-31'), date)) {
|
||||
return { marketPrice: 14156.4 };
|
||||
} else if (isSameDay(parseDate('2018-01-01'), date)) {
|
||||
return { marketPrice: 13657.2 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'NOVN.SW':
|
||||
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||
return { marketPrice: 87.8 };
|
||||
@ -37,8 +49,9 @@ export const CurrentRateServiceMock = {
|
||||
getValues: ({
|
||||
dataGatheringItems,
|
||||
dateQuery
|
||||
}: GetValuesParams): Promise<GetValueObject[]> => {
|
||||
const result: GetValueObject[] = [];
|
||||
}: GetValuesParams): Promise<GetValuesObject> => {
|
||||
const values: GetValueObject[] = [];
|
||||
|
||||
if (dateQuery.lt) {
|
||||
for (
|
||||
let date = resetHours(dateQuery.gte);
|
||||
@ -46,7 +59,7 @@ export const CurrentRateServiceMock = {
|
||||
date = addDays(date, 1)
|
||||
) {
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
values.push({
|
||||
date,
|
||||
marketPriceInBaseCurrency: mockGetValue(
|
||||
dataGatheringItem.symbol,
|
||||
@ -59,7 +72,7 @@ export const CurrentRateServiceMock = {
|
||||
} else {
|
||||
for (const date of dateQuery.in) {
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
values.push({
|
||||
date,
|
||||
marketPriceInBaseCurrency: mockGetValue(
|
||||
dataGatheringItem.symbol,
|
||||
@ -70,6 +83,7 @@ export const CurrentRateServiceMock = {
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.resolve(result);
|
||||
|
||||
return Promise.resolve({ values, dataProviderInfos: [], errors: [] });
|
||||
}
|
||||
};
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||
|
||||
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||
jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
|
||||
return {
|
||||
MarketDataService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
@ -17,7 +18,8 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||
createdAt: date,
|
||||
dataSource: DataSource.YAHOO,
|
||||
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
||||
marketPrice: 1847.839966
|
||||
marketPrice: 1847.839966,
|
||||
state: 'CLOSE'
|
||||
});
|
||||
},
|
||||
getRange: ({
|
||||
@ -36,6 +38,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||
date: dateRangeStart,
|
||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||
marketPrice: 1841.823902,
|
||||
state: 'CLOSE',
|
||||
symbol: symbols[0]
|
||||
},
|
||||
{
|
||||
@ -44,6 +47,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||
date: dateRangeEnd,
|
||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||
marketPrice: 1847.839966,
|
||||
state: 'CLOSE',
|
||||
symbol: symbols[0]
|
||||
}
|
||||
]);
|
||||
@ -53,14 +57,27 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
|
||||
jest.mock(
|
||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||
() => {
|
||||
return {
|
||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
initialize: () => Promise.resolve(),
|
||||
toCurrency: (value: number) => {
|
||||
return 1 * value;
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
jest.mock('@ghostfolio/api/services/property/property.service', () => {
|
||||
return {
|
||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||
PropertyService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
initialize: () => Promise.resolve(),
|
||||
toCurrency: (value: number) => {
|
||||
return 1 * value;
|
||||
}
|
||||
getByKey: (key: string) => Promise.resolve({})
|
||||
};
|
||||
})
|
||||
};
|
||||
@ -71,9 +88,19 @@ describe('CurrentRateService', () => {
|
||||
let dataProviderService: DataProviderService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let marketDataService: MarketDataService;
|
||||
let propertyService: PropertyService;
|
||||
|
||||
beforeAll(async () => {
|
||||
dataProviderService = new DataProviderService(null, [], null);
|
||||
propertyService = new PropertyService(null);
|
||||
|
||||
dataProviderService = new DataProviderService(
|
||||
null,
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
propertyService,
|
||||
null
|
||||
);
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
@ -102,17 +129,16 @@ describe('CurrentRateService', () => {
|
||||
},
|
||||
userCurrency: 'CHF'
|
||||
})
|
||||
).toMatchObject<GetValueObject[]>([
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1841.823902,
|
||||
symbol: 'AMZN'
|
||||
},
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1847.839966,
|
||||
symbol: 'AMZN'
|
||||
}
|
||||
]);
|
||||
).toMatchObject<GetValuesObject>({
|
||||
dataProviderInfos: [],
|
||||
errors: [],
|
||||
values: [
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1841.823902,
|
||||
symbol: 'AMZN'
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
import { flatten, isEmpty, uniqBy } from 'lodash';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
|
||||
@Injectable()
|
||||
@ -22,34 +24,52 @@ export class CurrentRateService {
|
||||
dataGatheringItems,
|
||||
dateQuery,
|
||||
userCurrency
|
||||
}: GetValuesParams): Promise<GetValueObject[]> {
|
||||
}: GetValuesParams): Promise<GetValuesObject> {
|
||||
const dataProviderInfos: DataProviderInfo[] = [];
|
||||
const includeToday =
|
||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||
|
||||
const promises: Promise<GetValueObject[]>[] = [];
|
||||
const quoteErrors: ResponseError['errors'] = [];
|
||||
const today = resetHours(new Date());
|
||||
|
||||
if (includeToday) {
|
||||
const today = resetHours(new Date());
|
||||
promises.push(
|
||||
this.dataProviderService
|
||||
.getQuotes(dataGatheringItems)
|
||||
.getQuotes({ items: dataGatheringItems })
|
||||
.then((dataResultProvider) => {
|
||||
const result: GetValueObject[] = [];
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
date: today,
|
||||
marketPriceInBaseCurrency:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
dataResultProvider?.[dataGatheringItem.symbol]
|
||||
?.marketPrice ?? 0,
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||
userCurrency
|
||||
),
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
if (
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
|
||||
) {
|
||||
dataProviderInfos.push(
|
||||
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
|
||||
);
|
||||
}
|
||||
|
||||
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
|
||||
result.push({
|
||||
date: today,
|
||||
marketPriceInBaseCurrency:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
dataResultProvider?.[dataGatheringItem.symbol]
|
||||
?.marketPrice,
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||
userCurrency
|
||||
),
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
} else {
|
||||
quoteErrors.push({
|
||||
dataSource: dataGatheringItem.dataSource,
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
@ -81,7 +101,60 @@ export class CurrentRateService {
|
||||
})
|
||||
);
|
||||
|
||||
return flatten(await Promise.all(promises));
|
||||
const values = flatten(await Promise.all(promises));
|
||||
|
||||
const response: GetValuesObject = {
|
||||
dataProviderInfos,
|
||||
errors: quoteErrors.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
}),
|
||||
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`)
|
||||
};
|
||||
|
||||
if (!isEmpty(quoteErrors)) {
|
||||
for (const { symbol } of quoteErrors) {
|
||||
try {
|
||||
// If missing quote, fallback to the latest available historical market price
|
||||
let value: GetValueObject = response.values.find((currentValue) => {
|
||||
return currentValue.symbol === symbol && isToday(currentValue.date);
|
||||
});
|
||||
|
||||
if (!value) {
|
||||
value = {
|
||||
symbol,
|
||||
date: today,
|
||||
marketPriceInBaseCurrency: 0
|
||||
};
|
||||
|
||||
response.values.push(value);
|
||||
}
|
||||
|
||||
const [latestValue] = response.values
|
||||
.filter((currentValue) => {
|
||||
return (
|
||||
currentValue.symbol === symbol &&
|
||||
currentValue.marketPriceInBaseCurrency
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.date < b.date) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.date > b.date) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
value.marketPriceInBaseCurrency =
|
||||
latestValue.marketPriceInBaseCurrency;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private containsToday(dates: Date[]): boolean {
|
||||
|
@ -0,0 +1,9 @@
|
||||
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { GetValueObject } from './get-value-object.interface';
|
||||
|
||||
export interface GetValuesObject {
|
||||
dataProviderInfos: DataProviderInfo[];
|
||||
errors: ResponseError['errors'];
|
||||
values: GetValueObject[];
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface PortfolioOrder {
|
||||
@ -9,6 +9,7 @@ export interface PortfolioOrder {
|
||||
name: string;
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
tags?: Tag[];
|
||||
type: TypeOfOrder;
|
||||
unitPrice: Big;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {
|
||||
DataProviderInfo,
|
||||
EnhancedSymbolProfile,
|
||||
HistoricalDataItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -7,6 +8,9 @@ import { Tag } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
averagePrice: number;
|
||||
dataProviderInfo: DataProviderInfo;
|
||||
dividendInBaseCurrency: number;
|
||||
feeInBaseCurrency: number;
|
||||
firstBuyDate: string;
|
||||
grossPerformance: number;
|
||||
grossPerformancePercent: number;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { DataSource, Tag } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TransactionPointSymbol {
|
||||
@ -9,5 +9,6 @@ export interface TransactionPointSymbol {
|
||||
investment: Big;
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
tags?: Tag[];
|
||||
transactionCount: number;
|
||||
}
|
||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big('3.2'),
|
||||
firstBuyDate: '2021-11-22',
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||
|
@ -53,7 +53,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
@ -70,6 +71,7 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('136.6'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big('1.55'),
|
||||
firstBuyDate: '2021-11-30',
|
||||
grossPerformance: new Big('24.6'),
|
||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||
|
@ -0,0 +1,146 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BTCUSD buy and sell partially', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2015-01-01',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Bitcoin USD',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BTCUSD',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(320.43)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2017-12-31',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Bitcoin USD',
|
||||
quantity: new Big(1),
|
||||
symbol: 'BTCUSD',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(14156.4)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2018-01-01').getTime());
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2015-01-01')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('13657.2'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('27172.74'),
|
||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('320.43'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big('0'),
|
||||
firstBuyDate: '2015-01-01',
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
investment: new Big('320.43'),
|
||||
netPerformance: new Big('27172.74'),
|
||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
marketPrice: 13657.2,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'BTCUSD',
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('320.43')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||
{ date: '2017-12-31', investment: new Big('320.43') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||
{ date: '2015-02-01', investment: new Big('0') },
|
||||
{ date: '2015-03-01', investment: new Big('0') },
|
||||
{ date: '2015-04-01', investment: new Big('0') },
|
||||
{ date: '2015-05-01', investment: new Big('0') },
|
||||
{ date: '2015-06-01', investment: new Big('0') },
|
||||
{ date: '2015-07-01', investment: new Big('0') },
|
||||
{ date: '2015-08-01', investment: new Big('0') },
|
||||
{ date: '2015-09-01', investment: new Big('0') },
|
||||
{ date: '2015-10-01', investment: new Big('0') },
|
||||
{ date: '2015-11-01', investment: new Big('0') },
|
||||
{ date: '2015-12-01', investment: new Big('0') },
|
||||
{ date: '2016-01-01', investment: new Big('0') },
|
||||
{ date: '2016-02-01', investment: new Big('0') },
|
||||
{ date: '2016-03-01', investment: new Big('0') },
|
||||
{ date: '2016-04-01', investment: new Big('0') },
|
||||
{ date: '2016-05-01', investment: new Big('0') },
|
||||
{ date: '2016-06-01', investment: new Big('0') },
|
||||
{ date: '2016-07-01', investment: new Big('0') },
|
||||
{ date: '2016-08-01', investment: new Big('0') },
|
||||
{ date: '2016-09-01', investment: new Big('0') },
|
||||
{ date: '2016-10-01', investment: new Big('0') },
|
||||
{ date: '2016-11-01', investment: new Big('0') },
|
||||
{ date: '2016-12-01', investment: new Big('0') },
|
||||
{ date: '2017-01-01', investment: new Big('0') },
|
||||
{ date: '2017-02-01', investment: new Big('0') },
|
||||
{ date: '2017-03-01', investment: new Big('0') },
|
||||
{ date: '2017-04-01', investment: new Big('0') },
|
||||
{ date: '2017-05-01', investment: new Big('0') },
|
||||
{ date: '2017-06-01', investment: new Big('0') },
|
||||
{ date: '2017-07-01', investment: new Big('0') },
|
||||
{ date: '2017-08-01', investment: new Big('0') },
|
||||
{ date: '2017-09-01', investment: new Big('0') },
|
||||
{ date: '2017-10-01', investment: new Big('0') },
|
||||
{ date: '2017-11-01', investment: new Big('0') },
|
||||
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -41,7 +41,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
|
@ -22,7 +22,7 @@ describe('PortfolioCalculator', () => {
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy and sell', async () => {
|
||||
it.only('with NOVN.SW buy and sell partially', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('75.80'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big('4.25'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('21.93'),
|
||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||
|
@ -0,0 +1,132 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with NOVN.SW buy and sell', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2022-03-07',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Novartis AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'NOVN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(75.8)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2022-04-08',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Novartis AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'NOVN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(85.73)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData(
|
||||
parseDate('2022-03-07')
|
||||
);
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2022-03-07')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(chartData[0]).toEqual({
|
||||
date: '2022-03-07',
|
||||
netPerformanceInPercentage: 0,
|
||||
netPerformance: 0,
|
||||
totalInvestment: 151.6,
|
||||
value: 151.6
|
||||
});
|
||||
|
||||
expect(chartData[chartData.length - 1]).toEqual({
|
||||
date: '2022-04-11',
|
||||
netPerformanceInPercentage: 13.100263852242744,
|
||||
netPerformance: 19.86,
|
||||
totalInvestment: 0,
|
||||
value: 0
|
||||
});
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('19.86'),
|
||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('19.86'),
|
||||
netPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big('0'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('19.86'),
|
||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
investment: new Big('0'),
|
||||
netPerformance: new Big('19.86'),
|
||||
netPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
marketPrice: 87.8,
|
||||
quantity: new Big('0'),
|
||||
symbol: 'NOVN.SW',
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2022-03-07', investment: new Big('151.6') },
|
||||
{ date: '2022-04-08', investment: new Big('0') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2022-03-01', investment: new Big('151.6') },
|
||||
{ date: '2022-04-01', investment: new Big('-171.46') }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,7 +1,12 @@
|
||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
ResponseError,
|
||||
TimelinePosition
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { GroupBy } from '@ghostfolio/common/types';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
@ -14,13 +19,15 @@ import {
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
max,
|
||||
min,
|
||||
set
|
||||
set,
|
||||
subDays
|
||||
} from 'date-fns';
|
||||
import { first, flatten, isNumber, last, sortBy } from 'lodash';
|
||||
import { first, flatten, isNumber, last, sortBy, uniq } from 'lodash';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||
@ -43,6 +50,7 @@ export class PortfolioCalculator {
|
||||
|
||||
private currency: string;
|
||||
private currentRateService: CurrentRateService;
|
||||
private dataProviderInfos: DataProviderInfo[];
|
||||
private orders: PortfolioOrder[];
|
||||
private transactionPoints: TransactionPoint[];
|
||||
|
||||
@ -106,6 +114,7 @@ export class PortfolioCalculator {
|
||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||
quantity: newQuantity,
|
||||
symbol: order.symbol,
|
||||
tags: order.tags,
|
||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||
};
|
||||
} else {
|
||||
@ -117,6 +126,7 @@ export class PortfolioCalculator {
|
||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||
quantity: order.quantity.mul(factor),
|
||||
symbol: order.symbol,
|
||||
tags: order.tags,
|
||||
transactionCount: 1
|
||||
};
|
||||
}
|
||||
@ -175,10 +185,10 @@ export class PortfolioCalculator {
|
||||
return isBefore(parseDate(transactionPoint.date), end);
|
||||
}) ?? [];
|
||||
|
||||
const firstIndex = transactionPointsBeforeEndDate.length;
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
const dates: Date[] = [];
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
const firstIndex = transactionPointsBeforeEndDate.length;
|
||||
|
||||
let day = start;
|
||||
|
||||
@ -187,7 +197,9 @@ export class PortfolioCalculator {
|
||||
day = addDays(day, step);
|
||||
}
|
||||
|
||||
dates.push(resetHours(end));
|
||||
if (!isSameDay(last(dates), end)) {
|
||||
dates.push(resetHours(end));
|
||||
}
|
||||
|
||||
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
|
||||
dataGatheringItems.push({
|
||||
@ -198,14 +210,17 @@ export class PortfolioCalculator {
|
||||
symbols[item.symbol] = true;
|
||||
}
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
const { dataProviderInfos, values: marketSymbols } =
|
||||
await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
this.dataProviderInfos = dataProviderInfos;
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
@ -223,19 +238,31 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
const netPerformanceValuesBySymbol: {
|
||||
[symbol: string]: { [date: string]: Big };
|
||||
const valuesByDate: {
|
||||
[date: string]: {
|
||||
maxTotalInvestmentValue: Big;
|
||||
totalCurrentValue: Big;
|
||||
totalInvestmentValue: Big;
|
||||
totalNetPerformanceValue: Big;
|
||||
};
|
||||
} = {};
|
||||
|
||||
const investmentValuesBySymbol: {
|
||||
[symbol: string]: { [date: string]: Big };
|
||||
const valuesBySymbol: {
|
||||
[symbol: string]: {
|
||||
currentValues: { [date: string]: Big };
|
||||
investmentValues: { [date: string]: Big };
|
||||
maxInvestmentValues: { [date: string]: Big };
|
||||
netPerformanceValues: { [date: string]: Big };
|
||||
};
|
||||
} = {};
|
||||
|
||||
const totalNetPerformanceValues: { [date: string]: Big } = {};
|
||||
const totalInvestmentValues: { [date: string]: Big } = {};
|
||||
|
||||
for (const symbol of Object.keys(symbols)) {
|
||||
const { netPerformanceValues, investmentValues } = this.getSymbolMetrics({
|
||||
const {
|
||||
currentValues,
|
||||
investmentValues,
|
||||
maxInvestmentValues,
|
||||
netPerformanceValues
|
||||
} = this.getSymbolMetrics({
|
||||
end,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
@ -244,47 +271,67 @@ export class PortfolioCalculator {
|
||||
isChartMode: true
|
||||
});
|
||||
|
||||
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
|
||||
investmentValuesBySymbol[symbol] = investmentValues;
|
||||
valuesBySymbol[symbol] = {
|
||||
currentValues,
|
||||
investmentValues,
|
||||
maxInvestmentValues,
|
||||
netPerformanceValues
|
||||
};
|
||||
}
|
||||
|
||||
for (const currentDate of dates) {
|
||||
const dateString = format(currentDate, DATE_FORMAT);
|
||||
|
||||
for (const symbol of Object.keys(netPerformanceValuesBySymbol)) {
|
||||
totalNetPerformanceValues[dateString] =
|
||||
totalNetPerformanceValues[dateString] ?? new Big(0);
|
||||
for (const symbol of Object.keys(valuesBySymbol)) {
|
||||
const symbolValues = valuesBySymbol[symbol];
|
||||
|
||||
if (netPerformanceValuesBySymbol[symbol]?.[dateString]) {
|
||||
totalNetPerformanceValues[dateString] = totalNetPerformanceValues[
|
||||
dateString
|
||||
].add(netPerformanceValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
const currentValue =
|
||||
symbolValues.currentValues?.[dateString] ?? new Big(0);
|
||||
const investmentValue =
|
||||
symbolValues.investmentValues?.[dateString] ?? new Big(0);
|
||||
const maxInvestmentValue =
|
||||
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
|
||||
const netPerformanceValue =
|
||||
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
|
||||
|
||||
totalInvestmentValues[dateString] =
|
||||
totalInvestmentValues[dateString] ?? new Big(0);
|
||||
|
||||
if (investmentValuesBySymbol[symbol]?.[dateString]) {
|
||||
totalInvestmentValues[dateString] = totalInvestmentValues[
|
||||
dateString
|
||||
].add(investmentValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
valuesByDate[dateString] = {
|
||||
totalCurrentValue: (
|
||||
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
|
||||
).add(currentValue),
|
||||
totalInvestmentValue: (
|
||||
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
|
||||
).add(investmentValue),
|
||||
maxTotalInvestmentValue: (
|
||||
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
|
||||
).add(maxInvestmentValue),
|
||||
totalNetPerformanceValue: (
|
||||
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
|
||||
).add(netPerformanceValue)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(totalNetPerformanceValues).map((date) => {
|
||||
const netPerformanceInPercentage = totalInvestmentValues[date].eq(0)
|
||||
return Object.entries(valuesByDate).map(([date, values]) => {
|
||||
const {
|
||||
maxTotalInvestmentValue,
|
||||
totalCurrentValue,
|
||||
totalInvestmentValue,
|
||||
totalNetPerformanceValue
|
||||
} = values;
|
||||
|
||||
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
|
||||
? 0
|
||||
: totalNetPerformanceValues[date]
|
||||
.div(totalInvestmentValues[date])
|
||||
: totalNetPerformanceValue
|
||||
.div(maxTotalInvestmentValue)
|
||||
.mul(100)
|
||||
.toNumber();
|
||||
|
||||
return {
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
netPerformance: totalNetPerformanceValues[date].toNumber(),
|
||||
value: netPerformanceInPercentage
|
||||
netPerformance: totalNetPerformanceValue.toNumber(),
|
||||
totalInvestment: totalInvestmentValue.toNumber(),
|
||||
value: totalCurrentValue.toNumber()
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -316,7 +363,7 @@ export class PortfolioCalculator {
|
||||
|
||||
let firstTransactionPoint: TransactionPoint = null;
|
||||
let firstIndex = transactionPointsBeforeEndDate.length;
|
||||
const dates = [];
|
||||
let dates = [];
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
|
||||
@ -345,7 +392,30 @@ export class PortfolioCalculator {
|
||||
|
||||
dates.push(resetHours(end));
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
// Add dates of last week for fallback
|
||||
dates.push(subDays(resetHours(new Date()), 7));
|
||||
dates.push(subDays(resetHours(new Date()), 6));
|
||||
dates.push(subDays(resetHours(new Date()), 5));
|
||||
dates.push(subDays(resetHours(new Date()), 4));
|
||||
dates.push(subDays(resetHours(new Date()), 3));
|
||||
dates.push(subDays(resetHours(new Date()), 2));
|
||||
dates.push(subDays(resetHours(new Date()), 1));
|
||||
dates.push(resetHours(new Date()));
|
||||
|
||||
dates = uniq(
|
||||
dates.map((date) => {
|
||||
return date.getTime();
|
||||
})
|
||||
).map((timestamp) => {
|
||||
return new Date(timestamp);
|
||||
});
|
||||
dates.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
const {
|
||||
dataProviderInfos,
|
||||
errors: currentRateErrors,
|
||||
values: marketSymbols
|
||||
} = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
@ -354,6 +424,8 @@ export class PortfolioCalculator {
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
this.dataProviderInfos = dataProviderInfos;
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
@ -408,6 +480,7 @@ export class PortfolioCalculator {
|
||||
: item.investment.div(item.quantity),
|
||||
currency: item.currency,
|
||||
dataSource: item.dataSource,
|
||||
fee: item.fee,
|
||||
firstBuyDate: item.firstBuyDate,
|
||||
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
||||
grossPerformancePercentage: !hasErrors
|
||||
@ -421,10 +494,17 @@ export class PortfolioCalculator {
|
||||
: null,
|
||||
quantity: item.quantity,
|
||||
symbol: item.symbol,
|
||||
tags: item.tags,
|
||||
transactionCount: item.transactionCount
|
||||
});
|
||||
|
||||
if (hasErrors) {
|
||||
if (
|
||||
(hasErrors ||
|
||||
currentRateErrors.find(({ dataSource, symbol }) => {
|
||||
return dataSource === item.dataSource && symbol === item.symbol;
|
||||
})) &&
|
||||
item.investment.gt(0)
|
||||
) {
|
||||
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
||||
}
|
||||
}
|
||||
@ -439,6 +519,10 @@ export class PortfolioCalculator {
|
||||
};
|
||||
}
|
||||
|
||||
public getDataProviderInfos() {
|
||||
return this.dataProviderInfos;
|
||||
}
|
||||
|
||||
public getInvestments(): { date: string; investment: Big }[] {
|
||||
if (this.transactionPoints.length === 0) {
|
||||
return [];
|
||||
@ -456,51 +540,95 @@ export class PortfolioCalculator {
|
||||
});
|
||||
}
|
||||
|
||||
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
|
||||
public getInvestmentsByGroup(
|
||||
groupBy: GroupBy
|
||||
): { date: string; investment: Big }[] {
|
||||
if (this.orders.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const investments = [];
|
||||
const investments: { date: string; investment: Big }[] = [];
|
||||
let currentDate: Date;
|
||||
let investmentByMonth = new Big(0);
|
||||
let investmentByGroup = new Big(0);
|
||||
|
||||
for (const [index, order] of this.orders.entries()) {
|
||||
if (
|
||||
isSameMonth(parseDate(order.date), currentDate) &&
|
||||
isSameYear(parseDate(order.date), currentDate)
|
||||
isSameYear(parseDate(order.date), currentDate) &&
|
||||
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
||||
) {
|
||||
// Same month: Add up investments
|
||||
|
||||
investmentByMonth = investmentByMonth.plus(
|
||||
// Same group: Add up investments
|
||||
investmentByGroup = investmentByGroup.plus(
|
||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||
);
|
||||
} else {
|
||||
// New month: Store previous month and reset
|
||||
|
||||
// New group: Store previous group and reset
|
||||
if (currentDate) {
|
||||
investments.push({
|
||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||
investment: investmentByMonth
|
||||
date: format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
),
|
||||
investment: investmentByGroup
|
||||
});
|
||||
}
|
||||
|
||||
currentDate = parseDate(order.date);
|
||||
investmentByMonth = order.quantity
|
||||
investmentByGroup = order.quantity
|
||||
.mul(order.unitPrice)
|
||||
.mul(this.getFactor(order.type));
|
||||
}
|
||||
|
||||
if (index === this.orders.length - 1) {
|
||||
// Store current month (latest order)
|
||||
// Store current group (latest order)
|
||||
investments.push({
|
||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||
investment: investmentByMonth
|
||||
date: format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
),
|
||||
investment: investmentByGroup
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return investments;
|
||||
// Fill in the missing dates with investment = 0
|
||||
const startDate = parseDate(first(this.orders).date);
|
||||
const endDate = parseDate(last(this.orders).date);
|
||||
|
||||
const allDates: string[] = [];
|
||||
currentDate = startDate;
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
allDates.push(
|
||||
format(
|
||||
set(currentDate, {
|
||||
date: 1,
|
||||
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||
}),
|
||||
DATE_FORMAT
|
||||
)
|
||||
);
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
}
|
||||
|
||||
for (const date of allDates) {
|
||||
const existingInvestment = investments.find((investment) => {
|
||||
return investment.date === date;
|
||||
});
|
||||
|
||||
if (!existingInvestment) {
|
||||
investments.push({ date, investment: new Big(0) });
|
||||
}
|
||||
}
|
||||
|
||||
return sortBy(investments, (investment) => {
|
||||
return investment.date;
|
||||
});
|
||||
}
|
||||
|
||||
public async calculateTimeline(
|
||||
@ -656,7 +784,7 @@ export class PortfolioCalculator {
|
||||
);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
Logger.warn(
|
||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
|
||||
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
|
||||
'PortfolioCalculator'
|
||||
);
|
||||
hasErrors = true;
|
||||
@ -710,7 +838,7 @@ export class PortfolioCalculator {
|
||||
let marketSymbols: GetValueObject[] = [];
|
||||
if (dataGatheringItems.length > 0) {
|
||||
try {
|
||||
marketSymbols = await this.currentRateService.getValues({
|
||||
const { values } = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
@ -719,6 +847,7 @@ export class PortfolioCalculator {
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
marketSymbols = values;
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Failed to fetch info for date ${startDate} with exception`,
|
||||
@ -852,12 +981,16 @@ export class PortfolioCalculator {
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
currentValues: {},
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
hasErrors: false,
|
||||
initialValue: new Big(0),
|
||||
investmentValues: {},
|
||||
maxInvestmentValues: {},
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0)
|
||||
netPerformanceValues: {}
|
||||
};
|
||||
}
|
||||
|
||||
@ -892,14 +1025,12 @@ export class PortfolioCalculator {
|
||||
let grossPerformanceFromSells = new Big(0);
|
||||
let initialValue: Big;
|
||||
let investmentAtStartDate: Big;
|
||||
const currentValues: { [date: string]: Big } = {};
|
||||
const investmentValues: { [date: string]: Big } = {};
|
||||
const maxInvestmentValues: { [date: string]: Big } = {};
|
||||
let lastAveragePrice = new Big(0);
|
||||
let lastTransactionInvestment = new Big(0);
|
||||
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
||||
let maxTotalInvestment = new Big(0);
|
||||
const netPerformanceValues: { [date: string]: Big } = {};
|
||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
||||
let totalInvestment = new Big(0);
|
||||
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
||||
let totalUnits = new Big(0);
|
||||
@ -994,6 +1125,12 @@ export class PortfolioCalculator {
|
||||
for (let i = 0; i < orders.length; i += 1) {
|
||||
const order = orders[i];
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log();
|
||||
console.log();
|
||||
console.log(i + 1, order.type, order.itemType);
|
||||
}
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
// Take the unit price of the order as the market price if there are no
|
||||
// orders of this symbol before the start date
|
||||
@ -1021,9 +1158,21 @@ export class PortfolioCalculator {
|
||||
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
||||
}
|
||||
|
||||
const transactionInvestment = order.quantity
|
||||
.mul(order.unitPrice)
|
||||
.mul(this.getFactor(order.type));
|
||||
const transactionInvestment =
|
||||
order.type === 'BUY'
|
||||
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||
: totalUnits.gt(0)
|
||||
? totalInvestment
|
||||
.div(totalUnits)
|
||||
.mul(order.quantity)
|
||||
.mul(this.getFactor(order.type))
|
||||
: new Big(0);
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('totalInvestment', totalInvestment.toNumber());
|
||||
console.log('order.quantity', order.quantity.toNumber());
|
||||
console.log('transactionInvestment', transactionInvestment.toNumber());
|
||||
}
|
||||
|
||||
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||
|
||||
@ -1072,70 +1221,44 @@ export class PortfolioCalculator {
|
||||
? new Big(0)
|
||||
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
||||
|
||||
const newGrossPerformance = valueOfInvestment
|
||||
.minus(totalInvestmentWithGrossPerformanceFromSell)
|
||||
.plus(grossPerformanceFromSells);
|
||||
|
||||
if (
|
||||
i > indexOfStartOrder &&
|
||||
!lastValueOfInvestmentBeforeTransaction
|
||||
.plus(lastTransactionInvestment)
|
||||
.eq(0)
|
||||
) {
|
||||
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.minus(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
);
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.mul(
|
||||
new Big(1).plus(grossHoldingPeriodReturn)
|
||||
);
|
||||
|
||||
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
||||
.minus(fees.minus(feesAtStartDate))
|
||||
.minus(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestmentBeforeTransaction.plus(
|
||||
lastTransactionInvestment
|
||||
)
|
||||
);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.mul(
|
||||
new Big(1).plus(netHoldingPeriodReturn)
|
||||
);
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log(
|
||||
'totalInvestmentWithGrossPerformanceFromSell',
|
||||
totalInvestmentWithGrossPerformanceFromSell.toNumber()
|
||||
);
|
||||
console.log(
|
||||
'grossPerformanceFromSells',
|
||||
grossPerformanceFromSells.toNumber()
|
||||
);
|
||||
}
|
||||
|
||||
const newGrossPerformance = valueOfInvestment
|
||||
.minus(totalInvestment)
|
||||
.plus(grossPerformanceFromSells);
|
||||
|
||||
grossPerformance = newGrossPerformance;
|
||||
|
||||
lastTransactionInvestment = transactionInvestment;
|
||||
|
||||
lastValueOfInvestmentBeforeTransaction =
|
||||
valueOfInvestmentBeforeTransaction;
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
feesAtStartDate = fees;
|
||||
grossPerformanceAtStartDate = grossPerformance;
|
||||
}
|
||||
|
||||
if (isChartMode && i > indexOfStartOrder) {
|
||||
currentValues[order.date] = valueOfInvestment;
|
||||
netPerformanceValues[order.date] = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
investmentValues[order.date] = totalInvestment;
|
||||
maxInvestmentValues[order.date] = maxTotalInvestment;
|
||||
}
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('totalInvestment', totalInvestment.toNumber());
|
||||
console.log(
|
||||
'totalGrossPerformance',
|
||||
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
|
||||
);
|
||||
}
|
||||
|
||||
if (i === indexOfEndOrder) {
|
||||
@ -1143,12 +1266,6 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.minus(1);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.minus(1);
|
||||
|
||||
const totalGrossPerformance = grossPerformance.minus(
|
||||
grossPerformanceAtStartDate
|
||||
);
|
||||
@ -1212,6 +1329,7 @@ export class PortfolioCalculator {
|
||||
Average price: ${averagePriceAtStartDate.toFixed(
|
||||
2
|
||||
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
||||
Total investment: ${totalInvestment.toFixed(2)}
|
||||
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
||||
Gross performance: ${totalGrossPerformance.toFixed(
|
||||
2
|
||||
@ -1224,14 +1342,16 @@ export class PortfolioCalculator {
|
||||
}
|
||||
|
||||
return {
|
||||
initialValue,
|
||||
currentValues,
|
||||
grossPerformancePercentage,
|
||||
initialValue,
|
||||
investmentValues,
|
||||
maxInvestmentValues,
|
||||
netPerformancePercentage,
|
||||
netPerformanceValues,
|
||||
grossPerformance: totalGrossPerformance,
|
||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||
netPerformance: totalNetPerformance,
|
||||
grossPerformance: totalGrossPerformance
|
||||
netPerformance: totalNetPerformance
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -7,20 +7,21 @@ import {
|
||||
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 { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
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';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
HEADER_KEY_IMPERSONATION
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
Filter,
|
||||
PortfolioChart,
|
||||
PortfolioDetails,
|
||||
PortfolioDividends,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport,
|
||||
PortfolioSummary
|
||||
PortfolioReport
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import type {
|
||||
DateRange,
|
||||
GroupBy,
|
||||
@ -40,6 +41,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import Big from 'big.js';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||
@ -48,107 +50,39 @@ import { PortfolioService } from './portfolio.service';
|
||||
|
||||
@Controller('portfolio')
|
||||
export class PortfolioController {
|
||||
private baseCurrency: string;
|
||||
|
||||
public constructor(
|
||||
private readonly accessService: AccessService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
@Get('chart')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getChart(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
): Promise<PortfolioChart> {
|
||||
const historicalDataContainer = await this.portfolioService.getChart(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
let chartData = historicalDataContainer.items;
|
||||
|
||||
let hasError = false;
|
||||
|
||||
chartData.forEach((chartDataItem) => {
|
||||
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
||||
hasError = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
let maxValue = 0;
|
||||
|
||||
chartData.forEach((portfolioItem) => {
|
||||
if (portfolioItem.value > maxValue) {
|
||||
maxValue = portfolioItem.value;
|
||||
}
|
||||
});
|
||||
|
||||
chartData = chartData.map((historicalDataItem) => {
|
||||
return {
|
||||
...historicalDataItem,
|
||||
marketPrice: Number((historicalDataItem.value / maxValue).toFixed(2))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hasError,
|
||||
chart: chartData,
|
||||
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||
};
|
||||
}
|
||||
) {}
|
||||
|
||||
@Get('details')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getDetails(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') range?: DateRange,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||
let hasDetails = true;
|
||||
let hasError = false;
|
||||
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
const filters: Filter[] = [
|
||||
...accountIds.map((accountId) => {
|
||||
return <Filter>{
|
||||
id: accountId,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}),
|
||||
...assetClasses.map((assetClass) => {
|
||||
return <Filter>{
|
||||
id: assetClass,
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
}),
|
||||
...tagIds.map((tagId) => {
|
||||
return <Filter>{
|
||||
id: tagId,
|
||||
type: 'TAG'
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
let portfolioSummary: PortfolioSummary;
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const {
|
||||
accounts,
|
||||
@ -156,20 +90,21 @@ export class PortfolioController {
|
||||
filteredValueInPercentage,
|
||||
hasErrors,
|
||||
holdings,
|
||||
platforms,
|
||||
summary,
|
||||
totalValueInBaseCurrency
|
||||
} = await this.portfolioService.getDetails(
|
||||
} = await this.portfolioService.getDetails({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
this.request.user.id,
|
||||
range,
|
||||
filters
|
||||
);
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
portfolioSummary = summary;
|
||||
let portfolioSummary = summary;
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -197,14 +132,24 @@ export class PortfolioController {
|
||||
portfolioPosition.investment / totalInvestment;
|
||||
portfolioPosition.netPerformance = null;
|
||||
portfolioPosition.quantity = null;
|
||||
portfolioPosition.value = portfolioPosition.value / totalValue;
|
||||
portfolioPosition.valueInPercentage =
|
||||
portfolioPosition.valueInBaseCurrency / totalValue;
|
||||
}
|
||||
|
||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||
accounts[name].current = current / totalValue;
|
||||
accounts[name].original = original / totalInvestment;
|
||||
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
|
||||
accounts[name].valueInPercentage = valueInBaseCurrency / totalValue;
|
||||
}
|
||||
|
||||
for (const [name, { valueInBaseCurrency }] of Object.entries(platforms)) {
|
||||
platforms[name].valueInPercentage = valueInBaseCurrency / totalValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
hasDetails === false ||
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
portfolioSummary = nullifyValuesInObject(summary, [
|
||||
'cash',
|
||||
'committedFunds',
|
||||
@ -215,26 +160,33 @@ export class PortfolioController {
|
||||
'emergencyFund',
|
||||
'excludedAccountsAndActivities',
|
||||
'fees',
|
||||
'fireWealth',
|
||||
'items',
|
||||
'liabilities',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalInvestment',
|
||||
'totalSell'
|
||||
]);
|
||||
}
|
||||
|
||||
let hasDetails = true;
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
holdings[symbol] = {
|
||||
...portfolioPosition,
|
||||
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
|
||||
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
|
||||
assetClass:
|
||||
hasDetails || portfolioPosition.assetClass === 'CASH'
|
||||
? portfolioPosition.assetClass
|
||||
: undefined,
|
||||
assetSubClass:
|
||||
hasDetails || portfolioPosition.assetSubClass === 'CASH'
|
||||
? portfolioPosition.assetSubClass
|
||||
: undefined,
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||
marketsAdvanced: hasDetails
|
||||
? portfolioPosition.marketsAdvanced
|
||||
: undefined,
|
||||
sectors: hasDetails ? portfolioPosition.sectors : []
|
||||
};
|
||||
}
|
||||
@ -245,37 +197,85 @@ export class PortfolioController {
|
||||
filteredValueInPercentage,
|
||||
hasError,
|
||||
holdings,
|
||||
platforms,
|
||||
totalValueInBaseCurrency,
|
||||
summary: hasDetails ? portfolioSummary : undefined
|
||||
summary: portfolioSummary
|
||||
};
|
||||
}
|
||||
|
||||
@Get('dividends')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getDividends(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioDividends> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
let dividends = await this.portfolioService.getDividends({
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const maxDividend = dividends.reduce(
|
||||
(investment, item) => Math.max(investment, item.investment),
|
||||
1
|
||||
);
|
||||
|
||||
dividends = dividends.map((item) => ({
|
||||
date: item.date,
|
||||
investment: item.investment / maxDividend
|
||||
}));
|
||||
}
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
dividends = dividends.map((item) => {
|
||||
return nullifyValuesInObject(item, ['investment']);
|
||||
});
|
||||
}
|
||||
|
||||
return { dividends };
|
||||
}
|
||||
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getInvestments(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('groupBy') groupBy?: GroupBy
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioInvestments> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
let investments: InvestmentItem[];
|
||||
|
||||
if (groupBy === 'month') {
|
||||
investments = await this.portfolioService.getInvestments(
|
||||
impersonationId,
|
||||
'month'
|
||||
);
|
||||
} else {
|
||||
investments = await this.portfolioService.getInvestments(impersonationId);
|
||||
}
|
||||
let { investments, streaks } = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId,
|
||||
savingsRate: this.request.user?.Settings?.settings.savingsRate
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -290,35 +290,28 @@ export class PortfolioController {
|
||||
date: item.date,
|
||||
investment: item.investment / maxInvestment
|
||||
}));
|
||||
|
||||
streaks = nullifyValuesInObject(streaks, [
|
||||
'currentStreak',
|
||||
'longestStreak'
|
||||
]);
|
||||
}
|
||||
|
||||
return { firstOrderDate: parseDate(investments[0]?.date), investments };
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPerformance(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const performanceInformation = await this.portfolioService.getPerformance(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
performanceInformation.performance = nullifyValuesInObject(
|
||||
performanceInformation.performance,
|
||||
['currentGrossPerformance', 'currentValue']
|
||||
);
|
||||
investments = investments.map((item) => {
|
||||
return nullifyValuesInObject(item, ['investment']);
|
||||
});
|
||||
|
||||
streaks = nullifyValuesInObject(streaks, [
|
||||
'currentStreak',
|
||||
'longestStreak'
|
||||
]);
|
||||
}
|
||||
|
||||
return performanceInformation;
|
||||
return { investments, streaks };
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
@ -326,24 +319,64 @@ export class PortfolioController {
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@Version('2')
|
||||
public async getPerformanceV2(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') dateRange
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const performanceInformation = await this.portfolioService.getPerformanceV2(
|
||||
{
|
||||
dateRange,
|
||||
impersonationId
|
||||
}
|
||||
);
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const performanceInformation = await this.portfolioService.getPerformance({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
performanceInformation.chart = performanceInformation.chart.map(
|
||||
({ date, netPerformanceInPercentage, totalInvestment, value }) => {
|
||||
return {
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
totalInvestment: new Big(totalInvestment)
|
||||
.div(performanceInformation.performance.totalInvestment)
|
||||
.toNumber(),
|
||||
valueInPercentage: new Big(value)
|
||||
.div(performanceInformation.performance.currentValue)
|
||||
.toNumber()
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
performanceInformation.performance = nullifyValuesInObject(
|
||||
performanceInformation.performance,
|
||||
['currentGrossPerformance', 'currentNetPerformance', 'currentValue']
|
||||
[
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'totalInvestment'
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
performanceInformation.chart = performanceInformation.chart.map(
|
||||
(item) => {
|
||||
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@ -352,34 +385,32 @@ export class PortfolioController {
|
||||
|
||||
@Get('positions')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPositions(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
@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 result = await this.portfolioService.getPositions(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterBySearchQuery,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
result.positions = result.positions.map((position) => {
|
||||
return nullifyValuesInObject(position, [
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'quantity'
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
return this.portfolioService.getPositions({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId
|
||||
});
|
||||
}
|
||||
|
||||
@Get('public/:accessId')
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPublic(
|
||||
@Param('accessId') accessId
|
||||
): Promise<PortfolioPublicDetails> {
|
||||
@ -400,12 +431,12 @@ export class PortfolioController {
|
||||
hasDetails = user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
const { holdings } = await this.portfolioService.getDetails(
|
||||
access.userId,
|
||||
access.userId,
|
||||
'max',
|
||||
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
|
||||
);
|
||||
const { holdings } = await this.portfolioService.getDetails({
|
||||
dateRange: 'max',
|
||||
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||
impersonationId: access.userId,
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||
hasDetails,
|
||||
@ -418,24 +449,26 @@ export class PortfolioController {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||
portfolioPosition.currency,
|
||||
this.request.user?.Settings?.settings.baseCurrency ??
|
||||
this.baseCurrency
|
||||
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
|
||||
);
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
portfolioPublicDetails.holdings[symbol] = {
|
||||
allocationCurrent: portfolioPosition.value / totalValue,
|
||||
allocationInPercentage:
|
||||
portfolioPosition.valueInBaseCurrency / totalValue,
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||
dataSource: portfolioPosition.dataSource,
|
||||
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||
name: portfolioPosition.name,
|
||||
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||
symbol: portfolioPosition.symbol,
|
||||
url: portfolioPosition.url,
|
||||
value: portfolioPosition.value / totalValue
|
||||
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
|
||||
};
|
||||
}
|
||||
|
||||
@ -443,35 +476,22 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
@Get('position/:dataSource/:symbol')
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('dataSource') dataSource,
|
||||
@Param('symbol') symbol
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
let position = await this.portfolioService.getPosition(
|
||||
const position = await this.portfolioService.getPosition(
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
|
||||
if (position) {
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
position = nullifyValuesInObject(position, [
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'orders',
|
||||
'quantity',
|
||||
'value'
|
||||
]);
|
||||
}
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
@ -484,18 +504,21 @@ export class PortfolioController {
|
||||
@Get('report')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getReport(
|
||||
@Headers('impersonation-id') impersonationId: string
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
||||
): Promise<PortfolioReport> {
|
||||
const report = await this.portfolioService.getReport(impersonationId);
|
||||
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
for (const rule in report.rules) {
|
||||
if (report.rules[rule]) {
|
||||
report.rules[rule] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await this.portfolioService.getReport(impersonationId);
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
@ -2,14 +2,16 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
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.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-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';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
@ -22,6 +24,7 @@ import { RulesService } from './rules.service';
|
||||
exports: [PortfolioService],
|
||||
imports: [
|
||||
AccessModule,
|
||||
ApiModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
@ -34,6 +37,7 @@ import { RulesService } from './rules.service';
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
AccountBalanceService,
|
||||
AccountService,
|
||||
CurrentRateService,
|
||||
PortfolioService,
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,7 @@
|
||||
import { Cache } from 'cache-manager';
|
||||
|
||||
import type { RedisStore } from './redis-store.interface';
|
||||
|
||||
export interface RedisCache extends Cache {
|
||||
store: RedisStore;
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { Store } from 'cache-manager';
|
||||
import { createClient } from 'redis';
|
||||
|
||||
export interface RedisStore extends Store {
|
||||
getClient: () => ReturnType<typeof createClient>;
|
||||
isCacheableValue: (value: any) => boolean;
|
||||
name: 'redis';
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { Module } from '@nestjs/common';
|
||||
import * as redisStore from 'cache-manager-redis-store';
|
||||
import type { RedisClientOptions } from 'redis';
|
||||
|
||||
import { RedisCacheService } from './redis-cache.service';
|
||||
|
||||
@ -11,7 +13,7 @@ import { RedisCacheService } from './redis-cache.service';
|
||||
imports: [ConfigurationModule],
|
||||
inject: [ConfigurationService],
|
||||
useFactory: async (configurationService: ConfigurationService) => {
|
||||
return <CacheManagerOptions>{
|
||||
return <RedisClientOptions>{
|
||||
host: configurationService.get('REDIS_HOST'),
|
||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||
password: configurationService.get('REDIS_PASSWORD'),
|
||||
|
@ -1,18 +1,32 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { RedisCache } from './interfaces/redis-cache.interface';
|
||||
|
||||
@Injectable()
|
||||
export class RedisCacheService {
|
||||
public constructor(
|
||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
||||
@Inject(CACHE_MANAGER) private readonly cache: RedisCache,
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
) {
|
||||
const client = cache.store.getClient();
|
||||
|
||||
client.on('error', (error) => {
|
||||
Logger.error(error, 'RedisCacheService');
|
||||
});
|
||||
}
|
||||
|
||||
public async get(key: string): Promise<string> {
|
||||
return await this.cache.get(key);
|
||||
}
|
||||
|
||||
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
|
||||
}
|
||||
|
||||
public async remove(key: string) {
|
||||
await this.cache.del(key);
|
||||
}
|
||||
@ -22,8 +36,10 @@ export class RedisCacheService {
|
||||
}
|
||||
|
||||
public async set(key: string, value: string, ttlInSeconds?: number) {
|
||||
await this.cache.set(key, value, {
|
||||
ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
||||
});
|
||||
await this.cache.set(
|
||||
key,
|
||||
value,
|
||||
ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
36
apps/api/src/app/sitemap/sitemap.controller.ts
Normal file
36
apps/api/src/app/sitemap/sitemap.controller.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getYesterday,
|
||||
interpolate
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
|
||||
import { format } from 'date-fns';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Controller('sitemap.xml')
|
||||
export class SitemapController {
|
||||
public sitemapXml = '';
|
||||
|
||||
public constructor() {
|
||||
try {
|
||||
this.sitemapXml = fs.readFileSync(
|
||||
path.join(__dirname, 'assets', 'sitemap.xml'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public async flushCache(@Res() response: Response): Promise<void> {
|
||||
response.setHeader('content-type', 'application/xml');
|
||||
response.send(
|
||||
interpolate(this.sitemapXml, {
|
||||
currentDate: format(getYesterday(), DATE_FORMAT)
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
24
apps/api/src/app/sitemap/sitemap.module.ts
Normal file
24
apps/api/src/app/sitemap/sitemap.module.ts
Normal file
@ -0,0 +1,24 @@
|
||||
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';
|
||||
|
||||
import { SitemapController } from './sitemap.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [SitemapController],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
]
|
||||
})
|
||||
export class SitemapModule {}
|
@ -1,4 +1,4 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
@ -21,6 +21,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Request, Response } from 'express';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { SubscriptionService } from './subscription.service';
|
||||
@ -62,6 +63,7 @@ export class SubscriptionController {
|
||||
|
||||
await this.subscriptionService.createSubscription({
|
||||
duration: coupon.duration,
|
||||
price: 0,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
@ -86,9 +88,12 @@ export class SubscriptionController {
|
||||
}
|
||||
|
||||
@Get('stripe/callback')
|
||||
public async stripeCallback(@Req() req, @Res() res) {
|
||||
public async stripeCallback(
|
||||
@Req() request: Request,
|
||||
@Res() response: Response
|
||||
) {
|
||||
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
||||
req.query.checkoutSessionId
|
||||
<string>request.query.checkoutSessionId
|
||||
);
|
||||
|
||||
Logger.log(
|
||||
@ -96,7 +101,7 @@ export class SubscriptionController {
|
||||
'SubscriptionController'
|
||||
);
|
||||
|
||||
res.redirect(
|
||||
response.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
||||
@ -112,7 +117,7 @@ export class SubscriptionController {
|
||||
return await this.subscriptionService.createCheckoutSession({
|
||||
couponId,
|
||||
priceId,
|
||||
userId: this.request.user.id
|
||||
user: this.request.user
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(error, 'SubscriptionController');
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user