Compare commits
692 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 | |||
f4b63b5de5 | |||
e45a0ad068 | |||
81c6cc021d | |||
859b24aa5b | |||
2bc325f182 | |||
a6186c23e2 | |||
cf234003ec | |||
8d3954304e | |||
9562139fa6 | |||
c857ea9a8f | |||
5c9fa71d95 | |||
fefbfa31d1 | |||
93a1fae51c | |||
3715edd9ba | |||
e3916e1ba3 | |||
76ceac4edc | |||
333b63bfe2 | |||
3006c21b12 | |||
f01a3f893d | |||
72974e888f | |||
0cee7a0b35 | |||
f3d337b044 | |||
7667af059c | |||
1095b47f45 | |||
dacd7271eb | |||
e093041184 | |||
8f2caa508a | |||
862f670ccf | |||
54bf4c7a43 | |||
c0ace51ee9 | |||
b1b5689242 | |||
b68cdaf8ea | |||
b387a80a0d | |||
6e4660295a | |||
d4c3a9d1e8 | |||
263f6b32f2 | |||
637f31ae3b | |||
547e27c7a1 | |||
f10dc176f2 | |||
0a966e46cd | |||
4f281d25e1 | |||
aaba8c35c2 | |||
7d27cb3398 | |||
91678028b5 | |||
5e3cac8ac9 | |||
33f20b6b48 | |||
e4fd255dd7 | |||
e320aa91f7 | |||
0fcfa6c1bd | |||
42d32ed652 | |||
21b4b0ef24 | |||
4f8fe83a16 | |||
980ad1028c | |||
0d5bc3f51b | |||
aece76d98f | |||
fc4bb71184 | |||
20bc7ef99c | |||
7a733ae49b | |||
376ce88492 | |||
c4d83aabe7 | |||
d4e2cec77e | |||
75db7bf79a | |||
3ad99c9991 | |||
00e402d286 | |||
4ac0484025 | |||
75d61bff6d | |||
0de28d733e | |||
3b2f13850c | |||
0cc42ffd7c | |||
3ccb812ac3 | |||
0a8549db3e | |||
c95e90ff31 | |||
b59af0d864 | |||
408bdbd187 | |||
a3bfa46fb0 | |||
8cb1b3f925 | |||
15c650f951 | |||
c198bd78da | |||
35963580bc | |||
cf2c5bad02 | |||
f332aea9b4 | |||
7a9fd18407 | |||
ca08d3154a | |||
01d4ae8757 | |||
43ce2786c1 | |||
de2092c4d2 | |||
435a180e54 | |||
0ad30ffabe | |||
0cc5e558f1 | |||
63b183cc6f | |||
10bae24c5c | |||
0e29278e96 | |||
2db46e5bbf | |||
e757e90e5a | |||
184ddc6209 | |||
e3662a143c | |||
25afd7e07b | |||
7fceaa1350 | |||
7c8530483c | |||
539d3ff754 | |||
9d28b63da6 | |||
24abbd85e6 | |||
b6f395fd3b | |||
04d894cf88 | |||
b4d2c4109e | |||
823093f4d7 | |||
56bf422407 | |||
df0e9ad03b | |||
0e3702c2be | |||
11136ae4f8 | |||
2e6a7d5a91 | |||
83845c256a | |||
34c9703716 | |||
48903238c5 | |||
57a14bd945 | |||
4fd0622114 |
@ -12,5 +12,5 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
|||||||
|
|
||||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
ALPHA_VANTAGE_API_KEY=
|
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>
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"root": true,
|
"root": true,
|
||||||
"ignorePatterns": ["**/*"],
|
"ignorePatterns": ["**/*"],
|
||||||
"plugins": ["@nrwl/nx"],
|
"plugins": ["@nx"],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"@nrwl/nx/enforce-module-boundaries": [
|
"@nx/enforce-module-boundaries": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"enforceBuildableLibDependency": true,
|
"enforceBuildableLibDependency": true,
|
||||||
@ -23,12 +23,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["*.ts", "*.tsx"],
|
"files": ["*.ts", "*.tsx"],
|
||||||
"extends": ["plugin:@nrwl/nx/typescript"],
|
"extends": ["plugin:@nx/typescript"],
|
||||||
"rules": {}
|
"rules": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["*.js", "*.jsx"],
|
"files": ["*.js", "*.jsx"],
|
||||||
"extends": ["plugin:@nrwl/nx/javascript"],
|
"extends": ["plugin:@nx/javascript"],
|
||||||
"rules": {}
|
"rules": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -113,5 +113,6 @@
|
|||||||
"radix": "error"
|
"radix": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"extends": [null, "plugin:storybook/recommended"]
|
||||||
}
|
}
|
||||||
|
48
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
48
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
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).
|
||||||
|
|
||||||
|
**Bug Description**
|
||||||
|
|
||||||
|
<!-- A clear and concise description of what the bug is. -->
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
|
||||||
|
<!-- Steps to reproduce the behavior -->
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
**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 -->
|
||||||
|
|
||||||
|
- [ ] Cloud
|
||||||
|
- [ ] Self-hosted
|
||||||
|
|
||||||
|
- Ghostfolio Version X.Y.Z
|
||||||
|
- Browser
|
||||||
|
- OS
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
|
||||||
|
<!-- Add any other context about the problem here. -->
|
36
.github/workflows/build-code.yml
vendored
Normal file
36
.github/workflows/build-code.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
name: Build code
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node_version:
|
||||||
|
- 18
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Use Node.js ${{ matrix.node_version }}
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node_version }}
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: yarn format:check
|
||||||
|
|
||||||
|
- name: Execute tests
|
||||||
|
run: yarn test
|
||||||
|
|
||||||
|
- name: Build application
|
||||||
|
run: yarn build:all
|
49
.github/workflows/docker-image.yml
vendored
Normal file
49
.github/workflows/docker-image.yml
vendored
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
name: Docker image CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*.*.*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_and_push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: ghostfolio/ghostfolio
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v3
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.output.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,6 +5,7 @@
|
|||||||
/tmp
|
/tmp
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
|
/.yarn
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
@ -24,6 +25,7 @@
|
|||||||
|
|
||||||
# misc
|
# misc
|
||||||
/.angular/cache
|
/.angular/cache
|
||||||
|
.env
|
||||||
.env.prod
|
.env.prod
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
|
@ -1,4 +1,13 @@
|
|||||||
{
|
{
|
||||||
|
"attributeGroups": [
|
||||||
|
"$ANGULAR_ELEMENT_REF",
|
||||||
|
"$ANGULAR_STRUCTURAL_DIRECTIVE",
|
||||||
|
"$DEFAULT",
|
||||||
|
"$ANGULAR_INPUT",
|
||||||
|
"$ANGULAR_TWO_WAY_BINDING",
|
||||||
|
"$ANGULAR_OUTPUT"
|
||||||
|
],
|
||||||
|
"attributeSort": "ASC",
|
||||||
"endOfLine": "auto",
|
"endOfLine": "auto",
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
|
@ -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": ["../**/*"]
|
|
||||||
}
|
|
30
.travis.yml
30
.travis.yml
@ -1,30 +0,0 @@
|
|||||||
language: node_js
|
|
||||||
git:
|
|
||||||
depth: false
|
|
||||||
node_js:
|
|
||||||
- 16
|
|
||||||
|
|
||||||
services:
|
|
||||||
- docker
|
|
||||||
|
|
||||||
cache: yarn
|
|
||||||
|
|
||||||
if: (type = pull_request) OR (tag IS present)
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
include:
|
|
||||||
- stage: Install dependencies
|
|
||||||
if: type = pull_request
|
|
||||||
script: yarn --frozen-lockfile
|
|
||||||
- stage: Check formatting
|
|
||||||
if: type = pull_request
|
|
||||||
script: yarn format:check
|
|
||||||
- stage: Execute tests
|
|
||||||
if: type = pull_request
|
|
||||||
script: yarn test
|
|
||||||
- stage: Build application
|
|
||||||
if: type = pull_request
|
|
||||||
script: yarn build:all
|
|
||||||
- stage: Build and publish docker image
|
|
||||||
if: tag IS present
|
|
||||||
script: ./publish-docker-image.sh
|
|
29
.vscode/launch.json
vendored
29
.vscode/launch.json
vendored
@ -2,32 +2,33 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Debug Jest File",
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng",
|
|
||||||
"args": [
|
"args": [
|
||||||
"test",
|
"test",
|
||||||
"--codeCoverage=false",
|
"--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}",
|
"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,
|
"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": [
|
"skipFiles": [
|
||||||
"${workspaceFolder}/node_modules/**/*.js",
|
"${workspaceFolder}/node_modules/**/*.js",
|
||||||
"<node_internals>/**/*.js"
|
"<node_internals>/**/*.js"
|
||||||
],
|
],
|
||||||
"console": "integratedTerminal"
|
"type": "node"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
1392
CHANGELOG.md
1392
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
31
DEVELOPMENT.md
Normal file
31
DEVELOPMENT.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Ghostfolio Development Guide
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
### Rebase
|
||||||
|
|
||||||
|
`git rebase -i --autosquash main`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Nx
|
||||||
|
|
||||||
|
#### Upgrade
|
||||||
|
|
||||||
|
1. Run `yarn nx migrate latest`
|
||||||
|
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||||
|
1. Run `yarn nx migrate --run-migrations`
|
||||||
|
|
||||||
|
### Prisma
|
||||||
|
|
||||||
|
#### Synchronize schema with database for prototyping
|
||||||
|
|
||||||
|
Run `yarn database:push`
|
||||||
|
|
||||||
|
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||||
|
|
||||||
|
#### Create schema migration
|
||||||
|
|
||||||
|
Run `yarn prisma migrate dev --name added_job_title`
|
||||||
|
|
||||||
|
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
23
Dockerfile
23
Dockerfile
@ -1,7 +1,6 @@
|
|||||||
FROM node:16-alpine as builder
|
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||||
|
|
||||||
# Build application and add additional files
|
# Build application and add additional files
|
||||||
|
|
||||||
WORKDIR /ghostfolio
|
WORKDIR /ghostfolio
|
||||||
|
|
||||||
# Only add basic files without the application itself to avoid rebuilding
|
# Only add basic files without the application itself to avoid rebuilding
|
||||||
@ -10,16 +9,22 @@ COPY ./CHANGELOG.md CHANGELOG.md
|
|||||||
COPY ./LICENSE LICENSE
|
COPY ./LICENSE LICENSE
|
||||||
COPY ./package.json package.json
|
COPY ./package.json package.json
|
||||||
COPY ./yarn.lock yarn.lock
|
COPY ./yarn.lock yarn.lock
|
||||||
|
COPY ./.yarnrc .yarnrc
|
||||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
RUN apk add --no-cache python3 g++ make openssl git
|
RUN apt update && apt install -y \
|
||||||
|
git \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
openssl \
|
||||||
|
python3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
RUN yarn install
|
RUN yarn install
|
||||||
|
|
||||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||||
RUN node decorate-angular-cli.js
|
RUN node decorate-angular-cli.js
|
||||||
|
|
||||||
COPY ./angular.json angular.json
|
|
||||||
COPY ./nx.json nx.json
|
COPY ./nx.json nx.json
|
||||||
COPY ./replace.build.js replace.build.js
|
COPY ./replace.build.js replace.build.js
|
||||||
COPY ./jest.preset.js jest.preset.js
|
COPY ./jest.preset.js jest.preset.js
|
||||||
@ -45,8 +50,12 @@ COPY package.json /ghostfolio/dist/apps/api
|
|||||||
RUN yarn database:generate-typings
|
RUN yarn database:generate-typings
|
||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:16-alpine
|
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
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
WORKDIR /ghostfolio/apps/api
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE 3333
|
EXPOSE ${PORT:-3333}
|
||||||
CMD [ "node", "main" ]
|
CMD [ "yarn", "start:prod" ]
|
||||||
|
158
README.md
158
README.md
@ -1,39 +1,31 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://ghostfol.io">
|
|
||||||
<img
|
|
||||||
alt="Ghostfolio Logo"
|
|
||||||
src="https://avatars.githubusercontent.com/u/82473144?s=200"
|
|
||||||
width="100"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<h1>Ghostfolio</h1>
|
[<img src="https://avatars.githubusercontent.com/u/82473144?s=200" width="100" alt="Ghostfolio logo">](https://ghostfol.io)
|
||||||
<p>
|
|
||||||
<strong>Open Source Wealth Management Software</strong>
|
# Ghostfolio
|
||||||
</p>
|
|
||||||
<p>
|
**Open Source Wealth Management Software**
|
||||||
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
|
||||||
</p>
|
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||||
<p>
|
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
|
||||||
<a href="#contributing">
|
|
||||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
|
[](https://www.buymeacoffee.com/ghostfolio)
|
||||||
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
|
[](#contributing)
|
||||||
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
|
||||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[<img src="./apps/client/src/assets/images/video-preview.jpg" width="600" alt="Preview image of the Ghostfolio video trailer">](https://www.youtube.com/watch?v=yY6ObSQVJZk)
|
||||||
|
|
||||||
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
|
||||||
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
|
||||||
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Ghostfolio Premium
|
## Ghostfolio Premium
|
||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/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. The revenue is used for covering the hosting costs.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
||||||
|
|
||||||
@ -48,23 +40,25 @@ Ghostfolio is for you if you are...
|
|||||||
- 🧘 into minimalism
|
- 🧘 into minimalism
|
||||||
- 🧺 caring about diversifying your financial resources
|
- 🧺 caring about diversifying your financial resources
|
||||||
- 🆓 interested in financial independence
|
- 🆓 interested in financial independence
|
||||||
- 🙅 saying no to spreadsheets in 2022
|
- 🙅 saying no to spreadsheets
|
||||||
- 😎 still reading this list
|
- 😎 still reading this list
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- ✅ Create, update and delete transactions
|
- ✅ Create, update and delete transactions
|
||||||
- ✅ Multi account management
|
- ✅ 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
|
- ✅ Various charts
|
||||||
- ✅ Static analysis to identify potential risks in your portfolio
|
- ✅ Static analysis to identify potential risks in your portfolio
|
||||||
- ✅ Import and export transactions
|
- ✅ Import and export transactions
|
||||||
- ✅ Dark Mode
|
- ✅ Dark Mode
|
||||||
- ✅ Zen 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>
|
</div>
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
@ -81,12 +75,19 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
||||||
|
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `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
|
### Supported Environment Variables
|
||||||
|
|
||||||
| Name | Default Value | Description |
|
| Name | Default Value | Description |
|
||||||
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||||
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. 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` |
|
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
@ -94,9 +95,9 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||||
| `REDIS_HOST` | `localhost` | The host where _Redis_ is running |
|
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||||
| `REDIS_PORT` | `6379` | The port where _Redis_ is running |
|
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||||
|
|
||||||
### Run with Docker Compose
|
### Run with Docker Compose
|
||||||
|
|
||||||
@ -104,7 +105,8 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
|
|
||||||
- Basic knowledge of Docker
|
- Basic knowledge of Docker
|
||||||
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- Local copy of this Git repository (clone)
|
- Create a local copy of this Git repository (clone)
|
||||||
|
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||||
|
|
||||||
#### a. Run environment
|
#### a. Run environment
|
||||||
|
|
||||||
@ -114,14 +116,6 @@ Run the following command to start the Docker images from [Docker Hub](https://h
|
|||||||
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Setup Database
|
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
#### b. Build and run environment
|
#### b. Build and run environment
|
||||||
|
|
||||||
Run the following commands to build and start the Docker images:
|
Run the following commands to build and start the Docker images:
|
||||||
@ -131,27 +125,16 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
|||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Setup Database
|
#### Setup
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Fetch Historical Data
|
|
||||||
|
|
||||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
|
||||||
|
|
||||||
|
1. Open http://localhost:3333 in your browser
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
|
||||||
|
|
||||||
#### Upgrade Version
|
#### Upgrade Version
|
||||||
|
|
||||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||||
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||||
1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
|
At each start, the container will automatically apply the database schema migrations if needed.
|
||||||
|
|
||||||
### Run with _Unraid_ (Community)
|
### Run with _Unraid_ (Community)
|
||||||
|
|
||||||
@ -162,30 +145,34 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 16+)
|
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- A local copy of this Git repository (clone)
|
- Create a local copy of this Git repository (clone)
|
||||||
|
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
|
1. Run `yarn build:dev` to build the source code including the assets
|
||||||
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
1. Run `yarn database:setup` to initialize the database schema
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
|
1. Open http://localhost:4200/en in your browser
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
|
||||||
|
|
||||||
### Start Server
|
### Start Server
|
||||||
|
|
||||||
<ol type="a">
|
#### Debug
|
||||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
|
|
||||||
<li>Serve: Run <code>yarn start:server</code></li>
|
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||||
</ol>
|
|
||||||
|
#### Serve
|
||||||
|
|
||||||
|
Run `yarn start:server`
|
||||||
|
|
||||||
### Start Client
|
### Start Client
|
||||||
|
|
||||||
Run `yarn start:client`
|
Run `yarn start:client` and open http://localhost:4200/en in your browser
|
||||||
|
|
||||||
### Start _Storybook_
|
### Start _Storybook_
|
||||||
|
|
||||||
@ -205,20 +192,24 @@ Run `yarn test`
|
|||||||
|
|
||||||
## Public API
|
## 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
|
### Import Activities
|
||||||
|
|
||||||
#### Request
|
#### Request
|
||||||
|
|
||||||
`POST http://localhost:3333/api/v1/import`
|
`POST http://localhost:3333/api/v1/import`
|
||||||
|
|
||||||
#### Authorization: Bearer Token
|
|
||||||
|
|
||||||
Set the header as follows:
|
|
||||||
|
|
||||||
```
|
|
||||||
"Authorization": "Bearer eyJh..."
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Body
|
#### Body
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -230,7 +221,7 @@ Set the header as follows:
|
|||||||
"date": "2021-09-15T00:00:00.000Z",
|
"date": "2021-09-15T00:00:00.000Z",
|
||||||
"fee": 19,
|
"fee": 19,
|
||||||
"quantity": 5,
|
"quantity": 5,
|
||||||
"symbol": "MSFT"
|
"symbol": "MSFT",
|
||||||
"type": "BUY",
|
"type": "BUY",
|
||||||
"unitPrice": 298.58
|
"unitPrice": 298.58
|
||||||
}
|
}
|
||||||
@ -241,6 +232,7 @@ Set the header as follows:
|
|||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------- | ------------------- | -------------------------------------------------- |
|
| ---------- | ------------------- | -------------------------------------------------- |
|
||||||
| accountId | string (`optional`) | Id of the account |
|
| accountId | string (`optional`) | Id of the account |
|
||||||
|
| comment | string (`optional`) | Comment of the activity |
|
||||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||||
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||||
| date | string | Date in the format `ISO-8601` |
|
| date | string | Date in the format `ISO-8601` |
|
||||||
@ -269,16 +261,20 @@ Set the header as follows:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Community Projects
|
||||||
|
|
||||||
|
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
|
||||||
|
|
||||||
## Contributing
|
## 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.
|
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/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
|
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
© 2021 - 2023 [Ghostfolio](https://ghostfol.io)
|
||||||
|
|
||||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||||
|
350
angular.json
350
angular.json
@ -1,350 +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-en": {
|
|
||||||
"baseHref": "/en/",
|
|
||||||
"localize": ["en"]
|
|
||||||
},
|
|
||||||
"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-en": {
|
|
||||||
"browserTarget": "client:build:development-en"
|
|
||||||
},
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,13 +1,15 @@
|
|||||||
|
/* eslint-disable */
|
||||||
export default {
|
export default {
|
||||||
displayName: 'api',
|
displayName: 'api',
|
||||||
|
|
||||||
globals: {
|
globals: {},
|
||||||
'ts-jest': {
|
|
||||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.[tj]s$': 'ts-jest'
|
'^.+\\.[tj]s$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
|
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/node: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": []
|
||||||
|
}
|
@ -42,14 +42,16 @@ export class AccessController {
|
|||||||
return accessesWithGranteeUser.map((access) => {
|
return accessesWithGranteeUser.map((access) => {
|
||||||
if (access.GranteeUser) {
|
if (access.GranteeUser) {
|
||||||
return {
|
return {
|
||||||
granteeAlias: access.GranteeUser?.alias,
|
alias: access.alias,
|
||||||
|
grantee: access.GranteeUser?.id,
|
||||||
id: access.id,
|
id: access.id,
|
||||||
type: 'RESTRICTED_VIEW'
|
type: 'RESTRICTED_VIEW'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
granteeAlias: 'Public',
|
alias: access.alias,
|
||||||
|
grantee: 'Public',
|
||||||
id: access.id,
|
id: access.id,
|
||||||
type: 'PUBLIC'
|
type: 'PUBLIC'
|
||||||
};
|
};
|
||||||
@ -71,6 +73,10 @@ export class AccessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.accessService.createAccess({
|
return this.accessService.createAccess({
|
||||||
|
alias: data.alias || undefined,
|
||||||
|
GranteeUser: data.granteeUserId
|
||||||
|
? { connect: { id: data.granteeUserId } }
|
||||||
|
: undefined,
|
||||||
User: { connect: { id: this.request.user.id } }
|
User: { connect: { id: this.request.user.id } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AccessController } from './access.controller';
|
import { AccessController } from './access.controller';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Access, Prisma } from '@prisma/client';
|
import { Access, Prisma } from '@prisma/client';
|
||||||
|
@ -1 +1,11 @@
|
|||||||
export class CreateAccessDto {}
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAccessDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
alias?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
granteeUserId?: string;
|
||||||
|
}
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import {
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
nullifyValuesInObject,
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
nullifyValuesInObjects
|
|
||||||
} from '@ghostfolio/api/helper/object.helper';
|
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
|
||||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
@ -22,7 +19,8 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
@ -39,8 +37,7 @@ export class AccountController {
|
|||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
private readonly userService: UserService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ -85,84 +82,35 @@ export class AccountController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||||
): Promise<Accounts> {
|
): Promise<Accounts> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
impersonationId,
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
let accountsWithAggregations =
|
return this.portfolioService.getAccountsWithAggregations({
|
||||||
await this.portfolioService.getAccountsWithAggregations(
|
userId: impersonationUserId || this.request.user.id,
|
||||||
impersonationUserId || this.request.user.id
|
withExcludedAccounts: true
|
||||||
);
|
});
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationUserId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
accountsWithAggregations = {
|
|
||||||
...nullifyValuesInObject(accountsWithAggregations, [
|
|
||||||
'totalBalanceInBaseCurrency',
|
|
||||||
'totalValueInBaseCurrency'
|
|
||||||
]),
|
|
||||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
|
||||||
'balance',
|
|
||||||
'balanceInBaseCurrency',
|
|
||||||
'convertedBalance',
|
|
||||||
'fee',
|
|
||||||
'quantity',
|
|
||||||
'unitPrice',
|
|
||||||
'value',
|
|
||||||
'valueInBaseCurrency'
|
|
||||||
])
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return accountsWithAggregations;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAccountById(
|
public async getAccountById(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
): Promise<AccountWithValue> {
|
): Promise<AccountWithValue> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
impersonationId,
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
let accountsWithAggregations =
|
const accountsWithAggregations =
|
||||||
await this.portfolioService.getAccountsWithAggregations(
|
await this.portfolioService.getAccountsWithAggregations({
|
||||||
impersonationUserId || this.request.user.id,
|
filters: [{ id, type: 'ACCOUNT' }],
|
||||||
[{ id, type: 'ACCOUNT' }]
|
userId: impersonationUserId || this.request.user.id,
|
||||||
);
|
withExcludedAccounts: true
|
||||||
|
});
|
||||||
if (
|
|
||||||
impersonationUserId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
accountsWithAggregations = {
|
|
||||||
...nullifyValuesInObject(accountsWithAggregations, [
|
|
||||||
'totalBalanceInBaseCurrency',
|
|
||||||
'totalValueInBaseCurrency'
|
|
||||||
]),
|
|
||||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
|
||||||
'balance',
|
|
||||||
'balanceInBaseCurrency',
|
|
||||||
'convertedBalance',
|
|
||||||
'fee',
|
|
||||||
'quantity',
|
|
||||||
'unitPrice',
|
|
||||||
'value',
|
|
||||||
'valueInBaseCurrency'
|
|
||||||
])
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return accountsWithAggregations.accounts[0];
|
return accountsWithAggregations.accounts[0];
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AccountController } from './account.controller';
|
import { AccountController } from './account.controller';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||||
@ -107,15 +107,23 @@ export class AccountService {
|
|||||||
public async getCashDetails({
|
public async getCashDetails({
|
||||||
currency,
|
currency,
|
||||||
filters = [],
|
filters = [],
|
||||||
userId
|
userId,
|
||||||
|
withExcludedAccounts = false
|
||||||
}: {
|
}: {
|
||||||
currency: string;
|
currency: string;
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
userId: string;
|
userId: string;
|
||||||
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<CashDetails> {
|
}): Promise<CashDetails> {
|
||||||
let totalCashBalanceInBaseCurrency = new Big(0);
|
let totalCashBalanceInBaseCurrency = new Big(0);
|
||||||
|
|
||||||
const where: Prisma.AccountWhereInput = { userId };
|
const where: Prisma.AccountWhereInput = {
|
||||||
|
userId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (withExcludedAccounts === false) {
|
||||||
|
where.isExcluded = false;
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
ACCOUNT: filtersByAccount,
|
ACCOUNT: filtersByAccount,
|
||||||
@ -164,4 +172,47 @@ export class AccountService {
|
|||||||
where
|
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.prismaService.account.update({
|
||||||
|
data: {
|
||||||
|
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id_userId: {
|
||||||
|
userId,
|
||||||
|
id: accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,13 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateIf
|
||||||
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -8,9 +16,24 @@ export class CreateAccountDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isExcluded?: boolean;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@ -1,5 +1,13 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateIf
|
||||||
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -8,12 +16,23 @@ export class UpdateAccountDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isExcluded?: boolean;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import {
|
import {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
@ -8,7 +8,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails
|
AdminMarketDataDetails,
|
||||||
|
EnhancedSymbolProfile,
|
||||||
|
Filter
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
@ -20,8 +22,10 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
@ -31,6 +35,7 @@ import { isDate } from 'date-fns';
|
|||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
|
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
||||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
@ -95,16 +100,21 @@ export class AdminController {
|
|||||||
|
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
for (const { dataSource, symbol } of uniqueAssets) {
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
await this.dataGatheringService.addJobToQueue(
|
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
return {
|
||||||
{
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
}
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: `${dataSource}-${symbol}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
this.dataGatheringService.gatherMax();
|
this.dataGatheringService.gatherMax();
|
||||||
}
|
}
|
||||||
@ -126,16 +136,21 @@ export class AdminController {
|
|||||||
|
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
for (const { dataSource, symbol } of uniqueAssets) {
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
await this.dataGatheringService.addJobToQueue(
|
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
return {
|
||||||
{
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
}
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: `${dataSource}-${symbol}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data/:dataSource/:symbol')
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
@ -156,14 +171,17 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue(
|
await this.dataGatheringService.addJobToQueue({
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
data: {
|
||||||
{
|
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: `${dataSource}-${symbol}`
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
@ -226,7 +244,9 @@ export class AdminController {
|
|||||||
|
|
||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getMarketData(): Promise<AdminMarketData> {
|
public async getMarketData(
|
||||||
|
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
||||||
|
): Promise<AdminMarketData> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
this.request.user.permissions,
|
this.request.user.permissions,
|
||||||
@ -239,7 +259,18 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.adminService.getMarketData();
|
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||||
|
|
||||||
|
const filters: Filter[] = [
|
||||||
|
...assetSubClasses.map((assetSubClass) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: assetSubClass,
|
||||||
|
type: 'ASSET_SUB_CLASS'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
return this.adminService.getMarketData(filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@ -286,9 +317,10 @@ export class AdminController {
|
|||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
|
|
||||||
return this.marketDataService.updateMarketData({
|
return this.marketDataService.updateMarketData({
|
||||||
data: { ...data, dataSource },
|
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
|
||||||
where: {
|
where: {
|
||||||
date_symbol: {
|
dataSource_date_symbol: {
|
||||||
|
dataSource,
|
||||||
date,
|
date,
|
||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
@ -317,6 +349,32 @@ export class AdminController {
|
|||||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
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')
|
@Put('settings/:key')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async updateProperty(
|
public async updateProperty(
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
|
@ -1,21 +1,23 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminMarketDataItem,
|
AdminMarketDataItem,
|
||||||
|
Filter,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Property } from '@prisma/client';
|
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
@ -63,14 +65,27 @@ export class AdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketData(): Promise<AdminMarketData> {
|
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
|
||||||
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
|
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||||
|
filters,
|
||||||
|
(filter) => {
|
||||||
|
return filter.type;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const marketData = await this.prismaService.marketData.groupBy({
|
const marketData = await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['dataSource', 'symbol']
|
by: ['dataSource', 'symbol']
|
||||||
});
|
});
|
||||||
|
|
||||||
const currencyPairsToGather: AdminMarketDataItem[] =
|
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
||||||
this.exchangeRateDataService
|
|
||||||
|
if (filtersByAssetSubClass) {
|
||||||
|
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||||
|
} else {
|
||||||
|
currencyPairsToGather = this.exchangeRateDataService
|
||||||
.getCurrencyPairs()
|
.getCurrencyPairs()
|
||||||
.map(({ dataSource, symbol }) => {
|
.map(({ dataSource, symbol }) => {
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
@ -84,17 +99,26 @@ export class AdminService {
|
|||||||
return {
|
return {
|
||||||
dataSource,
|
dataSource,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
symbol
|
symbol,
|
||||||
|
assetClass: 'CASH',
|
||||||
|
countriesCount: 0,
|
||||||
|
sectorsCount: 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||||
await this.prismaService.symbolProfile.findMany({
|
await this.prismaService.symbolProfile.findMany({
|
||||||
|
where,
|
||||||
orderBy: [{ symbol: 'asc' }],
|
orderBy: [{ symbol: 'asc' }],
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { Order: true }
|
select: { Order: true }
|
||||||
},
|
},
|
||||||
|
assetClass: true,
|
||||||
|
assetSubClass: true,
|
||||||
|
comment: true,
|
||||||
|
countries: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
Order: {
|
Order: {
|
||||||
orderBy: [{ date: 'asc' }],
|
orderBy: [{ date: 'asc' }],
|
||||||
@ -102,10 +126,14 @@ export class AdminService {
|
|||||||
take: 1
|
take: 1
|
||||||
},
|
},
|
||||||
scraperConfiguration: true,
|
scraperConfiguration: true,
|
||||||
|
sectors: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).map((symbolProfile) => {
|
).map((symbolProfile) => {
|
||||||
|
const countriesCount = symbolProfile.countries
|
||||||
|
? Object.keys(symbolProfile.countries).length
|
||||||
|
: 0;
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
marketData.find((marketDataItem) => {
|
marketData.find((marketDataItem) => {
|
||||||
return (
|
return (
|
||||||
@ -113,10 +141,18 @@ export class AdminService {
|
|||||||
marketDataItem.symbol === symbolProfile.symbol
|
marketDataItem.symbol === symbolProfile.symbol
|
||||||
);
|
);
|
||||||
})?._count ?? 0;
|
})?._count ?? 0;
|
||||||
|
const sectorsCount = symbolProfile.sectors
|
||||||
|
? Object.keys(symbolProfile.sectors).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
countriesCount,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
activityCount: symbolProfile._count.Order,
|
sectorsCount,
|
||||||
|
activitiesCount: symbolProfile._count.Order,
|
||||||
|
assetClass: symbolProfile.assetClass,
|
||||||
|
assetSubClass: symbolProfile.assetSubClass,
|
||||||
|
comment: symbolProfile.comment,
|
||||||
dataSource: symbolProfile.dataSource,
|
dataSource: symbolProfile.dataSource,
|
||||||
date: symbolProfile.Order?.[0]?.date,
|
date: symbolProfile.Order?.[0]?.date,
|
||||||
symbol: symbolProfile.symbol
|
symbol: symbolProfile.symbol
|
||||||
@ -132,8 +168,14 @@ export class AdminService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||||
return {
|
const [[assetProfile], marketData] = await Promise.all([
|
||||||
marketData: await this.marketDataService.marketDataItems({
|
this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
this.marketDataService.marketDataItems({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
date: 'asc'
|
date: 'asc'
|
||||||
},
|
},
|
||||||
@ -142,16 +184,47 @@ export class AdminService {
|
|||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketData,
|
||||||
|
assetProfile: assetProfile ?? {
|
||||||
|
symbol,
|
||||||
|
currency: '-'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async patchAssetProfileData({
|
||||||
|
comment,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
symbolMapping
|
||||||
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
|
await this.symbolProfileService.updateSymbolProfile({
|
||||||
|
comment,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
symbolMapping
|
||||||
|
});
|
||||||
|
|
||||||
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
return symbolProfile;
|
||||||
|
}
|
||||||
|
|
||||||
public async putSetting(key: string, value: string) {
|
public async putSetting(key: string, value: string) {
|
||||||
let response: Property;
|
let response: Property;
|
||||||
|
|
||||||
if (value === '') {
|
if (value) {
|
||||||
response = await this.propertyService.delete({ key });
|
|
||||||
} else {
|
|
||||||
response = await this.propertyService.put({ key, value });
|
response = await this.propertyService.put({ key, value });
|
||||||
|
} else {
|
||||||
|
response = await this.propertyService.delete({ key });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === PROPERTY_CURRENCIES) {
|
if (key === PROPERTY_CURRENCIES) {
|
||||||
@ -162,12 +235,27 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
let orderBy: any = {
|
||||||
orderBy: {
|
createdAt: 'desc'
|
||||||
|
};
|
||||||
|
let where;
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
orderBy = {
|
||||||
Analytics: {
|
Analytics: {
|
||||||
updatedAt: 'desc'
|
updatedAt: 'desc'
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
|
where = {
|
||||||
|
NOT: {
|
||||||
|
Analytics: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||||
|
orderBy,
|
||||||
|
where,
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { Account: true, Order: true }
|
select: { Account: true, Order: true }
|
||||||
@ -175,6 +263,7 @@ export class AdminService {
|
|||||||
Analytics: {
|
Analytics: {
|
||||||
select: {
|
select: {
|
||||||
activityCount: true,
|
activityCount: true,
|
||||||
|
country: true,
|
||||||
updatedAt: true
|
updatedAt: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -182,19 +271,16 @@ export class AdminService {
|
|||||||
id: true,
|
id: true,
|
||||||
Subscription: true
|
Subscription: true
|
||||||
},
|
},
|
||||||
take: 30,
|
take: 30
|
||||||
where: {
|
|
||||||
NOT: {
|
|
||||||
Analytics: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return usersWithAnalytics.map(
|
return usersWithAnalytics.map(
|
||||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||||
const daysSinceRegistration =
|
const daysSinceRegistration =
|
||||||
differenceInDays(new Date(), createdAt) + 1;
|
differenceInDays(new Date(), createdAt) + 1;
|
||||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
const engagement = Analytics
|
||||||
|
? Analytics.activityCount / daysSinceRegistration
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const subscription = this.configurationService.get(
|
const subscription = this.configurationService.get(
|
||||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||||
@ -208,7 +294,8 @@ export class AdminService {
|
|||||||
id,
|
id,
|
||||||
subscription,
|
subscription,
|
||||||
accountCount: _count.Account || 0,
|
accountCount: _count.Account || 0,
|
||||||
lastActivity: Analytics.updatedAt,
|
country: Analytics?.country,
|
||||||
|
lastActivity: Analytics?.updatedAt,
|
||||||
transactionCount: _count.Order || 0
|
transactionCount: _count.Order || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { QueueController } from './queue.controller';
|
import { QueueController } from './queue.controller';
|
||||||
|
@ -4,7 +4,7 @@ import {
|
|||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { JobStatus, Queue } from 'bull';
|
import { JobStatus, Queue } from 'bull';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -23,14 +23,11 @@ export class QueueService {
|
|||||||
}: {
|
}: {
|
||||||
status?: JobStatus[];
|
status?: JobStatus[];
|
||||||
}) {
|
}) {
|
||||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
for (const statusItem of status) {
|
||||||
|
await this.dataGatheringQueue.clean(
|
||||||
for (const job of jobs) {
|
300,
|
||||||
try {
|
statusItem === 'waiting' ? 'wait' : statusItem
|
||||||
await job.remove();
|
);
|
||||||
} catch (error) {
|
|
||||||
Logger.warn(error, 'QueueService');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,18 +41,23 @@ export class QueueService {
|
|||||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||||
|
|
||||||
const jobsWithState = await Promise.all(
|
const jobsWithState = await Promise.all(
|
||||||
jobs.slice(0, limit).map(async (job) => {
|
jobs
|
||||||
return {
|
.filter((job) => {
|
||||||
attemptsMade: job.attemptsMade + 1,
|
return job;
|
||||||
data: job.data,
|
})
|
||||||
finishedOn: job.finishedOn,
|
.slice(0, limit)
|
||||||
id: job.id,
|
.map(async (job) => {
|
||||||
name: job.name,
|
return {
|
||||||
stacktrace: job.stacktrace,
|
attemptsMade: job.attemptsMade + 1,
|
||||||
state: await job.getState(),
|
data: job.data,
|
||||||
timestamp: job.timestamp
|
finishedOn: job.finishedOn,
|
||||||
};
|
id: job.id,
|
||||||
})
|
name: job.name,
|
||||||
|
stacktrace: job.stacktrace,
|
||||||
|
state: await job.getState(),
|
||||||
|
timestamp: job.timestamp
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
13
apps/api/src/app/admin/update-asset-profile.dto.ts
Normal file
13
apps/api/src/app/admin/update-asset-profile.dto.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateAssetProfileDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
|
@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';
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
|
@ -1,13 +1,11 @@
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
|
||||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
||||||
@ -19,15 +17,21 @@ import { AccessModule } from './access/access.module';
|
|||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
|
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||||
import { CacheModule } from './cache/cache.module';
|
import { CacheModule } from './cache/cache.module';
|
||||||
|
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
import { FrontendMiddleware } from './frontend.middleware';
|
import { FrontendMiddleware } from './frontend.middleware';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { InfoModule } from './info/info.module';
|
import { InfoModule } from './info/info.module';
|
||||||
|
import { LogoModule } from './logo/logo.module';
|
||||||
import { OrderModule } from './order/order.module';
|
import { OrderModule } from './order/order.module';
|
||||||
|
import { PlatformModule } from './platform/platform.module';
|
||||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||||
|
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||||
import { SubscriptionModule } from './subscription/subscription.module';
|
import { SubscriptionModule } from './subscription/subscription.module';
|
||||||
import { SymbolModule } from './symbol/symbol.module';
|
import { SymbolModule } from './symbol/symbol.module';
|
||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
@ -43,7 +47,7 @@ import { UserModule } from './user/user.module';
|
|||||||
BullModule.forRoot({
|
BullModule.forRoot({
|
||||||
redis: {
|
redis: {
|
||||||
host: process.env.REDIS_HOST,
|
host: process.env.REDIS_HOST,
|
||||||
port: parseInt(process.env.REDIS_PORT, 10),
|
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||||
password: process.env.REDIS_PASSWORD
|
password: process.env.REDIS_PASSWORD
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@ -52,11 +56,15 @@ import { UserModule } from './user/user.module';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
ExchangeRateModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ExportModule,
|
ExportModule,
|
||||||
|
HealthModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
InfoModule,
|
InfoModule,
|
||||||
|
LogoModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
|
PlatformModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { AuthDevice, Prisma } from '@prisma/client';
|
import { AuthDevice, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
@ -16,6 +16,7 @@ import {
|
|||||||
Version
|
Version
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
@ -32,8 +33,11 @@ export class AuthController {
|
|||||||
private readonly webAuthService: WebAuthService
|
private readonly webAuthService: WebAuthService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
@Get('anonymous/:accessToken')
|
@Get('anonymous/:accessToken')
|
||||||
public async accessTokenLogin(
|
public async accessTokenLoginGet(
|
||||||
@Param('accessToken') accessToken: string
|
@Param('accessToken') accessToken: string
|
||||||
): Promise<OAuthResponse> {
|
): Promise<OAuthResponse> {
|
||||||
try {
|
try {
|
||||||
@ -49,6 +53,23 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('anonymous')
|
||||||
|
public async accessTokenLogin(
|
||||||
|
@Body() body: { accessToken: string }
|
||||||
|
): Promise<OAuthResponse> {
|
||||||
|
try {
|
||||||
|
const authToken = await this.authService.validateAnonymousLogin(
|
||||||
|
body.accessToken
|
||||||
|
);
|
||||||
|
return { authToken };
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get('google')
|
@Get('google')
|
||||||
@UseGuards(AuthGuard('google'))
|
@UseGuards(AuthGuard('google'))
|
||||||
public googleLogin() {
|
public googleLogin() {
|
||||||
@ -58,18 +79,21 @@ export class AuthController {
|
|||||||
@Get('google/callback')
|
@Get('google/callback')
|
||||||
@UseGuards(AuthGuard('google'))
|
@UseGuards(AuthGuard('google'))
|
||||||
@Version(VERSION_NEUTRAL)
|
@Version(VERSION_NEUTRAL)
|
||||||
public googleLoginCallback(@Req() req, @Res() res) {
|
public googleLoginCallback(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
// Handles the Google OAuth2 callback
|
// Handles the Google OAuth2 callback
|
||||||
const jwt: string = req.user.jwt;
|
const jwt: string = (<any>request.user).jwt;
|
||||||
|
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
res.redirect(
|
response.redirect(
|
||||||
`${this.configurationService.get(
|
`${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
res.redirect(
|
response.redirect(
|
||||||
`${this.configurationService.get(
|
`${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
||||||
@ -77,13 +101,13 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('internet-identity/:principalId')
|
@Post('internet-identity')
|
||||||
public async internetIdentityLogin(
|
public async internetIdentityLogin(
|
||||||
@Param('principalId') principalId: string
|
@Body() body: { principalId: string }
|
||||||
): Promise<OAuthResponse> {
|
): Promise<OAuthResponse> {
|
||||||
try {
|
try {
|
||||||
const authToken = await this.authService.validateInternetIdentityLogin(
|
const authToken = await this.authService.validateInternetIdentityLogin(
|
||||||
principalId
|
body.principalId
|
||||||
);
|
);
|
||||||
return { authToken };
|
return { authToken };
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -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 { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -21,6 +22,7 @@ import { JwtStrategy } from './jwt.strategy';
|
|||||||
signOptions: { expiresIn: '180 days' }
|
signOptions: { expiresIn: '180 days' }
|
||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
PropertyModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
@ -11,6 +12,7 @@ export class AuthService {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -50,10 +52,19 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
const isUserSignupEnabled =
|
||||||
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
|
if (!isUserSignupEnabled) {
|
||||||
|
throw new Error('Sign up forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Create new user if not found
|
// Create new user if not found
|
||||||
user = await this.userService.createUser({
|
user = await this.userService.createUser({
|
||||||
provider,
|
data: {
|
||||||
thirdPartyId: principalId
|
provider,
|
||||||
|
thirdPartyId: principalId
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,10 +89,19 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
const isUserSignupEnabled =
|
||||||
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
|
if (!isUserSignupEnabled) {
|
||||||
|
throw new Error('Sign up forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Create new user if not found
|
// Create new user if not found
|
||||||
user = await this.userService.createUser({
|
user = await this.userService.createUser({
|
||||||
provider,
|
data: {
|
||||||
thirdPartyId
|
provider,
|
||||||
|
thirdPartyId
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
|
@ -1,33 +1,46 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
public constructor(
|
public constructor(
|
||||||
readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
passReqToCallback: true,
|
||||||
secretOrKey: configurationService.get('JWT_SECRET_KEY')
|
secretOrKey: configurationService.get('JWT_SECRET_KEY')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async validate({ id }: { id: string }) {
|
public async validate(request: Request, { id }: { id: string }) {
|
||||||
try {
|
try {
|
||||||
|
const timezone = request.headers[HEADER_KEY_TIMEZONE.toLowerCase()];
|
||||||
const user = await this.userService.user({ id });
|
const user = await this.userService.user({ id });
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
await this.prismaService.analytics.upsert({
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
create: { User: { connect: { id: user.id } } },
|
const country =
|
||||||
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
|
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||||
where: { userId: user.id }
|
|
||||||
});
|
await this.prismaService.analytics.upsert({
|
||||||
|
create: { country, User: { connect: { id: user.id } } },
|
||||||
|
update: {
|
||||||
|
country,
|
||||||
|
activityCount: { increment: 1 },
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
where: { userId: user.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Inject,
|
Inject,
|
||||||
@ -54,7 +54,7 @@ export class WebAuthService {
|
|||||||
rpName: 'Ghostfolio',
|
rpName: 'Ghostfolio',
|
||||||
rpID: this.rpID,
|
rpID: this.rpID,
|
||||||
userID: user.id,
|
userID: user.id,
|
||||||
userName: user.alias,
|
userName: '',
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
attestationType: 'indirect',
|
attestationType: 'indirect',
|
||||||
authenticatorSelection: {
|
authenticatorSelection: {
|
||||||
|
@ -1,9 +1,27 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import type {
|
||||||
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
|
BenchmarkMarketDataDetails,
|
||||||
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
|
BenchmarkResponse,
|
||||||
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
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';
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
@ -11,20 +29,69 @@ import { BenchmarkService } from './benchmark.service';
|
|||||||
export class BenchmarkController {
|
export class BenchmarkController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly benchmarkService: BenchmarkService,
|
private readonly benchmarkService: BenchmarkService,
|
||||||
private readonly propertyService: PropertyService
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getBenchmark(): Promise<BenchmarkResponse> {
|
public async getBenchmark(): Promise<BenchmarkResponse> {
|
||||||
const benchmarkAssets: UniqueAsset[] =
|
|
||||||
((await this.propertyService.getByKey(
|
|
||||||
PROPERTY_BENCHMARKS
|
|
||||||
)) as UniqueAsset[]) ?? [];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
benchmarks: await this.benchmarkService.getBenchmarks(benchmarkAssets)
|
benchmarks: await this.benchmarkService.getBenchmarks()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':dataSource/:symbol/:startDateString')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async getBenchmarkMarketDataBySymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('startDateString') startDateString: string,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<BenchmarkMarketDataDetails> {
|
||||||
|
const startDate = new Date(startDateString);
|
||||||
|
|
||||||
|
return this.benchmarkService.getMarketDataBySymbol({
|
||||||
|
dataSource,
|
||||||
|
startDate,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { BenchmarkController } from './benchmark.controller';
|
import { BenchmarkController } from './benchmark.controller';
|
||||||
@ -16,8 +18,10 @@ import { BenchmarkService } from './benchmark.service';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
|
SymbolModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
providers: [BenchmarkService]
|
providers: [BenchmarkService]
|
||||||
|
23
apps/api/src/app/benchmark/benchmark.service.spec.ts
Normal file
23
apps/api/src/app/benchmark/benchmark.service.spec.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
|
describe('BenchmarkService', () => {
|
||||||
|
let benchmarkService: BenchmarkService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
benchmarkService = new BenchmarkService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculateChangeInPercentage', async () => {
|
||||||
|
expect(benchmarkService.calculateChangeInPercentage(1, 2)).toEqual(1);
|
||||||
|
expect(benchmarkService.calculateChangeInPercentage(2, 2)).toEqual(0);
|
||||||
|
expect(benchmarkService.calculateChangeInPercentage(2, 1)).toEqual(-0.5);
|
||||||
|
});
|
||||||
|
});
|
@ -1,10 +1,27 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
|
import {
|
||||||
|
MAX_CHART_ITEMS,
|
||||||
|
PROPERTY_BENCHMARKS
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
BenchmarkMarketDataDetails,
|
||||||
|
BenchmarkProperty,
|
||||||
|
BenchmarkResponse,
|
||||||
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { uniqBy } from 'lodash';
|
||||||
|
import ms from 'ms';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BenchmarkService {
|
export class BenchmarkService {
|
||||||
@ -13,72 +30,222 @@ export class BenchmarkService {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService,
|
||||||
|
private readonly symbolService: SymbolService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async getBenchmarks(
|
public calculateChangeInPercentage(baseValue: number, currentValue: number) {
|
||||||
benchmarkAssets: UniqueAsset[]
|
if (baseValue && currentValue) {
|
||||||
): Promise<BenchmarkResponse['benchmarks']> {
|
return new Big(currentValue).div(baseValue).minus(1).toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBenchmarks({ useCache = true } = {}): Promise<
|
||||||
|
BenchmarkResponse['benchmarks']
|
||||||
|
> {
|
||||||
let benchmarks: BenchmarkResponse['benchmarks'];
|
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||||
|
|
||||||
try {
|
if (useCache) {
|
||||||
benchmarks = JSON.parse(
|
try {
|
||||||
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
benchmarks = JSON.parse(
|
||||||
);
|
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
||||||
|
);
|
||||||
|
|
||||||
if (benchmarks) {
|
if (benchmarks) {
|
||||||
return benchmarks;
|
return benchmarks;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
||||||
|
|
||||||
const promises: Promise<number>[] = [];
|
const promises: Promise<number>[] = [];
|
||||||
|
|
||||||
const [quotes, assetProfiles] = await Promise.all([
|
const quotes = await this.dataProviderService.getQuotes(
|
||||||
this.dataProviderService.getQuotes(benchmarkAssets),
|
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
this.symbolProfileService.getSymbolProfiles(benchmarkAssets)
|
return { dataSource, symbol };
|
||||||
]);
|
})
|
||||||
|
);
|
||||||
|
|
||||||
for (const benchmarkAsset of benchmarkAssets) {
|
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||||
promises.push(this.marketDataService.getMax(benchmarkAsset));
|
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const allTimeHighs = await Promise.all(promises);
|
const allTimeHighs = await Promise.all(promises);
|
||||||
|
let storeInCache = true;
|
||||||
|
|
||||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||||
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
|
const { marketPrice } =
|
||||||
|
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
|
||||||
|
|
||||||
const performancePercentFromAllTimeHigh = new Big(marketPrice)
|
let performancePercentFromAllTimeHigh = 0;
|
||||||
.div(allTimeHigh)
|
|
||||||
.minus(1);
|
if (allTimeHigh && marketPrice) {
|
||||||
|
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||||
|
allTimeHigh,
|
||||||
|
marketPrice
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
storeInCache = false;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketCondition: this.getMarketCondition(
|
marketCondition: this.getMarketCondition(
|
||||||
performancePercentFromAllTimeHigh
|
performancePercentFromAllTimeHigh
|
||||||
),
|
),
|
||||||
name: assetProfiles.find(({ dataSource, symbol }) => {
|
name: benchmarkAssetProfiles[index].name,
|
||||||
return (
|
|
||||||
dataSource === benchmarkAssets[index].dataSource &&
|
|
||||||
symbol === benchmarkAssets[index].symbol
|
|
||||||
);
|
|
||||||
})?.name,
|
|
||||||
performances: {
|
performances: {
|
||||||
allTimeHigh: {
|
allTimeHigh: {
|
||||||
performancePercent: performancePercentFromAllTimeHigh.toNumber()
|
performancePercent: performancePercentFromAllTimeHigh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.redisCacheService.set(
|
if (storeInCache) {
|
||||||
this.CACHE_KEY_BENCHMARKS,
|
await this.redisCacheService.set(
|
||||||
JSON.stringify(benchmarks)
|
this.CACHE_KEY_BENCHMARKS,
|
||||||
);
|
JSON.stringify(benchmarks),
|
||||||
|
ms('4 hours') / 1000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return benchmarks;
|
return benchmarks;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMarketCondition(aPerformanceInPercent: Big) {
|
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||||
return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
const symbolProfileIds: string[] = (
|
||||||
|
((await this.propertyService.getByKey(
|
||||||
|
PROPERTY_BENCHMARKS
|
||||||
|
)) as BenchmarkProperty[]) ?? []
|
||||||
|
).map(({ symbolProfileId }) => {
|
||||||
|
return symbolProfileId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const assetProfiles =
|
||||||
|
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
||||||
|
|
||||||
|
return assetProfiles
|
||||||
|
.map(({ dataSource, id, name, symbol }) => {
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
symbol
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMarketDataBySymbol({
|
||||||
|
dataSource,
|
||||||
|
startDate,
|
||||||
|
symbol
|
||||||
|
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||||
|
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||||
|
this.symbolService.get({
|
||||||
|
dataGatheringItem: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
this.marketDataService.marketDataItems({
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: {
|
||||||
|
gte: startDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const step = Math.round(
|
||||||
|
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
||||||
|
);
|
||||||
|
|
||||||
|
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
|
||||||
|
const response = {
|
||||||
|
marketData: [
|
||||||
|
...marketDataItems
|
||||||
|
.filter((marketDataItem, index) => {
|
||||||
|
return index % step === 0;
|
||||||
|
})
|
||||||
|
.map((marketDataItem) => {
|
||||||
|
return {
|
||||||
|
date: format(marketDataItem.date, DATE_FORMAT),
|
||||||
|
value:
|
||||||
|
marketPriceAtStartDate === 0
|
||||||
|
? 0
|
||||||
|
: this.calculateChangeInPercentage(
|
||||||
|
marketPriceAtStartDate,
|
||||||
|
marketDataItem.marketPrice
|
||||||
|
) * 100
|
||||||
|
};
|
||||||
|
})
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentSymbolItem?.marketPrice) {
|
||||||
|
response.marketData.push({
|
||||||
|
date: format(new Date(), DATE_FORMAT),
|
||||||
|
value:
|
||||||
|
this.calculateChangeInPercentage(
|
||||||
|
marketPriceAtStartDate,
|
||||||
|
currentSymbolItem.marketPrice
|
||||||
|
) * 100
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
|
42
apps/api/src/app/exchange-rate/exchange-rate.controller.ts
Normal file
42
apps/api/src/app/exchange-rate/exchange-rate.controller.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Param,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
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 = new Date(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,8 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExportController } from './export.controller';
|
import { ExportController } from './export.controller';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { Export } from '@ghostfolio/common/interfaces';
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@ -14,6 +14,23 @@ export class ExportService {
|
|||||||
activityIds?: string[];
|
activityIds?: string[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Export> {
|
}): Promise<Export> {
|
||||||
|
const accounts = await this.prismaService.account.findMany({
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc'
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
accountType: true,
|
||||||
|
balance: true,
|
||||||
|
comment: true,
|
||||||
|
currency: true,
|
||||||
|
id: true,
|
||||||
|
isExcluded: true,
|
||||||
|
name: true,
|
||||||
|
platformId: true
|
||||||
|
},
|
||||||
|
where: { userId }
|
||||||
|
});
|
||||||
|
|
||||||
let activities = await this.prismaService.order.findMany({
|
let activities = await this.prismaService.order.findMany({
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
@ -38,6 +55,7 @@ export class ExportService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
meta: { date: new Date().toISOString(), version: environment.version },
|
meta: { date: new Date().toISOString(), version: environment.version },
|
||||||
|
accounts,
|
||||||
activities: activities.map(
|
activities: activities.map(
|
||||||
({
|
({
|
||||||
accountId,
|
accountId,
|
||||||
|
@ -1,54 +1,205 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
|
import { format } from 'date-fns';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FrontendMiddleware implements NestMiddleware {
|
export class FrontendMiddleware implements NestMiddleware {
|
||||||
public indexHtmlDe = fs.readFileSync(
|
public indexHtmlDe = '';
|
||||||
this.getPathOfIndexHtmlFile('de'),
|
public indexHtmlEn = '';
|
||||||
'utf8'
|
public indexHtmlEs = '';
|
||||||
);
|
public indexHtmlFr = '';
|
||||||
public indexHtmlEn = fs.readFileSync(
|
public indexHtmlIt = '';
|
||||||
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
public indexHtmlNl = '';
|
||||||
'utf8'
|
public indexHtmlPt = '';
|
||||||
);
|
|
||||||
|
private static readonly DEFAULT_DESCRIPTION =
|
||||||
|
'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService
|
||||||
) {}
|
) {
|
||||||
|
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.indexHtmlFr = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('fr'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
this.indexHtmlIt = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('it'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
this.indexHtmlNl = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('nl'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
this.indexHtmlPt = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('pt'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
public use(req: Request, res: Response, next: NextFunction) {
|
public use(request: Request, response: Response, next: NextFunction) {
|
||||||
|
const currentDate = format(new Date(), DATE_FORMAT);
|
||||||
let featureGraphicPath = 'assets/cover.png';
|
let featureGraphicPath = 'assets/cover.png';
|
||||||
|
let title = 'Ghostfolio – Open Source Wealth Management Software';
|
||||||
|
|
||||||
if (
|
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
|
||||||
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';
|
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||||
|
title = `500 Stars - ${title}`;
|
||||||
|
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
||||||
|
title = `Hacktoberfest 2022 - ${title}`;
|
||||||
|
} else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
|
||||||
|
title = `Black Friday 2022 - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith(
|
||||||
|
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/20221226.jpg';
|
||||||
|
title = `The importance of tracking your personal finances - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith(
|
||||||
|
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
|
||||||
|
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith('/en/blog/2023/02/ghostfolio-meets-umbrel')
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
|
||||||
|
title = `Ghostfolio meets Umbrel - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith(
|
||||||
|
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
|
||||||
|
title = `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith(
|
||||||
|
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/20230520.jpg';
|
||||||
|
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.path.startsWith('/api/') || this.isFileRequest(req.url)) {
|
if (
|
||||||
|
request.path.startsWith('/api/') ||
|
||||||
|
this.isFileRequest(request.url) ||
|
||||||
|
!environment.production
|
||||||
|
) {
|
||||||
// Skip
|
// Skip
|
||||||
next();
|
next();
|
||||||
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
|
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
|
||||||
res.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlDe, {
|
this.interpolate(this.indexHtmlDe, {
|
||||||
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
description:
|
||||||
|
'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
|
||||||
languageCode: 'de',
|
languageCode: 'de',
|
||||||
path: req.path,
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (request.path === '/es' || request.path.startsWith('/es/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlEs, {
|
||||||
|
currentDate,
|
||||||
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
|
||||||
|
languageCode: 'es',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlFr, {
|
||||||
|
currentDate,
|
||||||
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
|
||||||
|
languageCode: 'fr',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlIt, {
|
||||||
|
currentDate,
|
||||||
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
|
||||||
|
languageCode: 'it',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlNl, {
|
||||||
|
currentDate,
|
||||||
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.',
|
||||||
|
languageCode: 'nl',
|
||||||
|
path: request.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.indexHtmlPt, {
|
||||||
|
currentDate,
|
||||||
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
|
||||||
|
languageCode: 'pt',
|
||||||
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
res.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlEn, {
|
this.interpolate(this.indexHtmlEn, {
|
||||||
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
description: FrontendMiddleware.DEFAULT_DESCRIPTION,
|
||||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||||
path: req.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
44
apps/api/src/app/health/health.controller.ts
Normal file
44
apps/api/src/app/health/health.controller.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Param,
|
||||||
|
UseInterceptors
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
public constructor(private readonly healthService: HealthService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
public async getHealth() {}
|
||||||
|
|
||||||
|
@Get('data-provider/:dataSource')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async getHealthOfDataProvider(
|
||||||
|
@Param('dataSource') dataSource: DataSource
|
||||||
|
) {
|
||||||
|
if (!DataSource[dataSource]) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasResponse = await this.healthService.hasResponseFromDataProvider(
|
||||||
|
dataSource
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasResponse !== true) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||||
|
StatusCodes.SERVICE_UNAVAILABLE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/health/health.module.ts
Normal file
13
apps/api/src/app/health/health.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
imports: [ConfigurationModule, DataProviderModule],
|
||||||
|
providers: [HealthService]
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
14
apps/api/src/app/health/health.service.ts
Normal file
14
apps/api/src/app/health/health.service.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HealthService {
|
||||||
|
public constructor(
|
||||||
|
private readonly dataProviderService: DataProviderService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async hasResponseFromDataProvider(aDataSource: DataSource) {
|
||||||
|
return this.dataProviderService.checkQuote(aDataSource);
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,15 @@
|
|||||||
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, ValidateNested } from 'class-validator';
|
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
export class ImportDataDto {
|
export class ImportDataDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@Type(() => CreateAccountDto)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
accounts: CreateAccountDto[];
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@Type(() => CreateOrderDto)
|
@Type(() => CreateOrderDto)
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
@ -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 type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Logger,
|
Logger,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
UseGuards
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { ImportDataDto } from './import-data.dto';
|
import { ImportDataDto } from './import-data.dto';
|
||||||
@ -26,8 +35,19 @@ export class ImportController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async import(@Body() importData: ImportDataDto): Promise<void> {
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
@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(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -45,12 +65,19 @@ export class ImportController {
|
|||||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.importService.import({
|
const activities = await this.importService.import({
|
||||||
|
isDryRun,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
activities: importData.activities,
|
userCurrency,
|
||||||
|
accountsDto: importData.accounts ?? [],
|
||||||
|
activitiesDto: importData.activities,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { activities };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, ImportController);
|
Logger.error(error, ImportController);
|
||||||
|
|
||||||
@ -63,4 +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 { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
|
import { 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 { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { 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 { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ImportController } from './import.controller';
|
import { ImportController } from './import.controller';
|
||||||
@ -19,9 +23,13 @@ import { ImportService } from './import.service';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
|
PlatformModule,
|
||||||
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
providers: [ImportService]
|
providers: [ImportService]
|
||||||
})
|
})
|
||||||
|
@ -1,149 +1,493 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ActivityError
|
||||||
|
} from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
AccountWithPlatform,
|
||||||
|
OrderWithAccount
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isSameDay, parseISO } from 'date-fns';
|
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
|
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportService {
|
export class ImportService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly orderService: OrderService
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly orderService: OrderService,
|
||||||
|
private readonly platformService: PlatformService,
|
||||||
|
private readonly portfolioService: PortfolioService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async getDividends({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
userCurrency
|
||||||
|
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
|
||||||
|
try {
|
||||||
|
const { firstBuyDate, historicalData, orders } =
|
||||||
|
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||||
|
|
||||||
|
const [[assetProfile], dividends] = await Promise.all([
|
||||||
|
this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
await this.dataProviderService.getDividends({
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
from: parseDate(firstBuyDate),
|
||||||
|
granularity: 'day',
|
||||||
|
to: new Date()
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const accounts = orders.map((order) => {
|
||||||
|
return order.Account;
|
||||||
|
});
|
||||||
|
|
||||||
|
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||||
|
|
||||||
|
return Object.entries(dividends).map(([dateString, { marketPrice }]) => {
|
||||||
|
const quantity =
|
||||||
|
historicalData.find((historicalDataItem) => {
|
||||||
|
return historicalDataItem.date === dateString;
|
||||||
|
})?.quantity ?? 0;
|
||||||
|
|
||||||
|
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||||
|
|
||||||
|
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({
|
public async import({
|
||||||
activities,
|
accountsDto,
|
||||||
|
activitiesDto,
|
||||||
|
isDryRun = false,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activities: Partial<CreateOrderDto>[];
|
accountsDto: Partial<CreateAccountDto>[];
|
||||||
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
|
isDryRun?: boolean;
|
||||||
maxActivitiesToImport: number;
|
maxActivitiesToImport: number;
|
||||||
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<void> {
|
}): Promise<Activity[]> {
|
||||||
for (const activity of activities) {
|
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||||
if (!activity.dataSource) {
|
|
||||||
if (activity.type === 'ITEM') {
|
if (!isDryRun && accountsDto?.length) {
|
||||||
activity.dataSource = 'MANUAL';
|
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||||
} else {
|
this.accountService.accounts({
|
||||||
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
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({
|
for (const activity of activitiesDto) {
|
||||||
activities,
|
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,
|
maxActivitiesToImport,
|
||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||||
(account) => {
|
activitiesDto,
|
||||||
return account.id;
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const accounts = (await this.accountService.getAccounts(userId)).map(
|
||||||
|
({ id, name }) => {
|
||||||
|
return { id, name };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isDryRun) {
|
||||||
|
accountsDto.forEach(({ id, name }) => {
|
||||||
|
accounts.push({ id, name });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const activities: Activity[] = [];
|
||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
accountId,
|
accountId,
|
||||||
comment,
|
comment,
|
||||||
currency,
|
|
||||||
dataSource,
|
|
||||||
date,
|
date,
|
||||||
|
error,
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
symbol,
|
SymbolProfile: assetProfile,
|
||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
} of activities) {
|
} of activitiesExtendedWithErrors) {
|
||||||
await this.orderService.createOrder({
|
const validatedAccount = accounts.find(({ id }) => {
|
||||||
comment,
|
return id === accountId;
|
||||||
fee,
|
});
|
||||||
quantity,
|
|
||||||
type,
|
let order:
|
||||||
unitPrice,
|
| OrderWithAccount
|
||||||
userId,
|
| (Omit<OrderWithAccount, 'Account'> & {
|
||||||
accountId: accountIds.includes(accountId) ? accountId : undefined,
|
Account?: { id: string; name: string };
|
||||||
date: parseISO(<string>(<unknown>date)),
|
});
|
||||||
SymbolProfile: {
|
|
||||||
connectOrCreate: {
|
if (isDryRun) {
|
||||||
create: {
|
order = {
|
||||||
currency,
|
comment,
|
||||||
dataSource,
|
date,
|
||||||
symbol
|
fee,
|
||||||
},
|
quantity,
|
||||||
where: {
|
type,
|
||||||
dataSource_symbol: {
|
unitPrice,
|
||||||
dataSource,
|
userId,
|
||||||
symbol
|
accountId: validatedAccount?.id,
|
||||||
|
accountUserId: undefined,
|
||||||
|
createdAt: new Date(),
|
||||||
|
id: uuidv4(),
|
||||||
|
isDraft: isAfter(date, endOfToday()),
|
||||||
|
SymbolProfile: {
|
||||||
|
assetClass: assetProfile.assetClass,
|
||||||
|
assetSubClass: assetProfile.assetSubClass,
|
||||||
|
comment: assetProfile.comment,
|
||||||
|
countries: assetProfile.countries,
|
||||||
|
createdAt: assetProfile.createdAt,
|
||||||
|
currency: assetProfile.currency,
|
||||||
|
dataSource: assetProfile.dataSource,
|
||||||
|
id: assetProfile.id,
|
||||||
|
isin: assetProfile.isin,
|
||||||
|
name: assetProfile.name,
|
||||||
|
scraperConfiguration: assetProfile.scraperConfiguration,
|
||||||
|
sectors: assetProfile.sectors,
|
||||||
|
symbol: assetProfile.currency,
|
||||||
|
symbolMapping: assetProfile.symbolMapping,
|
||||||
|
updatedAt: assetProfile.updatedAt,
|
||||||
|
url: assetProfile.url,
|
||||||
|
...assetProfiles[assetProfile.symbol]
|
||||||
|
},
|
||||||
|
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: assetProfile.currency,
|
||||||
|
dataSource: assetProfile.dataSource,
|
||||||
|
symbol: assetProfile.symbol
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource_symbol: {
|
||||||
|
dataSource: assetProfile.dataSource,
|
||||||
|
symbol: assetProfile.symbol
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
updateAccountBalance: false,
|
||||||
User: { connect: { id: userId } }
|
User: { connect: { id: userId } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
activities.push({
|
||||||
|
...order,
|
||||||
|
error,
|
||||||
|
value,
|
||||||
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
fee,
|
||||||
|
assetProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
assetProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return activities;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateActivities({
|
private async extendActivitiesWithErrors({
|
||||||
activities,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activities: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
maxActivitiesToImport: number;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}): Promise<Partial<Activity>[]> {
|
||||||
if (activities?.length > maxActivitiesToImport) {
|
|
||||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingActivities = await this.orderService.orders({
|
const existingActivities = await this.orderService.orders({
|
||||||
include: { SymbolProfile: true },
|
include: { SymbolProfile: true },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
where: { userId }
|
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,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
|
maxActivitiesToImport: number;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||||
|
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetProfiles: {
|
||||||
|
[symbol: string]: Partial<SymbolProfile>;
|
||||||
|
} = {};
|
||||||
|
|
||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
{ currency, dataSource, symbol }
|
||||||
] of activities.entries()) {
|
] of activitiesDto.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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dataSource !== 'MANUAL') {
|
if (dataSource !== 'MANUAL') {
|
||||||
const quotes = await this.dataProviderService.getQuotes([
|
const assetProfile = (
|
||||||
{ dataSource, symbol }
|
await this.dataProviderService.getAssetProfiles([
|
||||||
]);
|
{ dataSource, symbol }
|
||||||
|
])
|
||||||
|
)?.[symbol];
|
||||||
|
|
||||||
if (quotes[symbol] === undefined) {
|
if (assetProfile === undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quotes[symbol].currency !== currency) {
|
if (assetProfile.currency !== currency) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
|
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assetProfiles[symbol] = assetProfile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return assetProfiles;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
||||||
|
|
||||||
import { InfoService } from './info.service';
|
import { InfoService } from './info.service';
|
||||||
|
|
||||||
@ -8,6 +9,7 @@ export class InfoController {
|
|||||||
public constructor(private readonly infoService: InfoService) {}
|
public constructor(private readonly infoService: InfoService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getInfo(): Promise<InfoItem> {
|
public async getInfo(): Promise<InfoItem> {
|
||||||
return this.infoService.get();
|
return this.infoService.get();
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
|
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 { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
@ -16,6 +18,7 @@ import { InfoService } from './info.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [InfoController],
|
controllers: [InfoController],
|
||||||
imports: [
|
imports: [
|
||||||
|
BenchmarkModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
@ -24,6 +27,7 @@ import { InfoService } from './info.service';
|
|||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
}),
|
}),
|
||||||
|
PlatformModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
|
@ -1,37 +1,49 @@
|
|||||||
|
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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
DEMO_USER_ID,
|
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||||
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||||
|
PROPERTY_DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
PROPERTY_STRIPE_CONFIG,
|
||||||
PROPERTY_SYSTEM_MESSAGE,
|
PROPERTY_SYSTEM_MESSAGE,
|
||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
import {
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
DATE_FORMAT,
|
||||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
encodeDataSource,
|
||||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
extractNumberFromString
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
InfoItem,
|
||||||
|
Statistics,
|
||||||
|
Subscription
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import { subDays } from 'date-fns';
|
import * as cheerio from 'cheerio';
|
||||||
|
import { format, subDays } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly benchmarkService: BenchmarkService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly platformService: PlatformService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
@ -41,9 +53,12 @@ export class InfoService {
|
|||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
const info: Partial<InfoItem> = {};
|
const info: Partial<InfoItem> = {};
|
||||||
let isReadOnlyMode: boolean;
|
let isReadOnlyMode: boolean;
|
||||||
const platforms = await this.prismaService.platform.findMany({
|
const platforms = (
|
||||||
orderBy: { name: 'asc' },
|
await this.platformService.getPlatforms({
|
||||||
select: { id: true, name: true }
|
orderBy: { name: 'asc' }
|
||||||
|
})
|
||||||
|
).map(({ id, name }) => {
|
||||||
|
return { id, name };
|
||||||
});
|
});
|
||||||
let systemMessage: string;
|
let systemMessage: string;
|
||||||
|
|
||||||
@ -54,9 +69,7 @@ export class InfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
if (
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
|
||||||
) {
|
|
||||||
info.fearAndGreedDataSource = encodeDataSource(
|
info.fearAndGreedDataSource = encodeDataSource(
|
||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
);
|
);
|
||||||
@ -67,10 +80,6 @@ export class InfoService {
|
|||||||
globalPermissions.push(permissions.enableFearAndGreedIndex);
|
globalPermissions.push(permissions.enableFearAndGreedIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
|
||||||
globalPermissions.push(permissions.enableImport);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||||
isReadOnlyMode = (await this.propertyService.getByKey(
|
isReadOnlyMode = (await this.propertyService.getByKey(
|
||||||
PROPERTY_IS_READ_ONLY_MODE
|
PROPERTY_IS_READ_ONLY_MODE
|
||||||
@ -88,6 +97,10 @@ export class InfoService {
|
|||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
globalPermissions.push(permissions.enableSubscription);
|
globalPermissions.push(permissions.enableSubscription);
|
||||||
|
|
||||||
|
info.countriesOfSubscribers =
|
||||||
|
((await this.propertyService.getByKey(
|
||||||
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
|
||||||
|
)) as string[]) ?? [];
|
||||||
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,18 +112,35 @@ export class InfoService {
|
|||||||
)) as string;
|
)) 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 {
|
return {
|
||||||
...info,
|
...info,
|
||||||
|
benchmarks,
|
||||||
|
demoAuthToken,
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
isReadOnlyMode,
|
isReadOnlyMode,
|
||||||
platforms,
|
platforms,
|
||||||
|
statistics,
|
||||||
|
subscriptions,
|
||||||
systemMessage,
|
systemMessage,
|
||||||
|
tags,
|
||||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
currencies: this.exchangeRateDataService.getCurrencies()
|
||||||
demoAuthToken: this.getDemoAuthToken(),
|
|
||||||
statistics: await this.getStatistics(),
|
|
||||||
subscriptions: await this.getSubscriptions(),
|
|
||||||
tags: await this.tagService.get()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,10 +170,10 @@ export class InfoService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countGitHubContributors(): Promise<number> {
|
private async countDockerHubPulls(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const get = bent(
|
||||||
`https://api.github.com/repos/ghostfolio/ghostfolio/contributors`,
|
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||||
'GET',
|
'GET',
|
||||||
'json',
|
'json',
|
||||||
200,
|
200,
|
||||||
@ -152,8 +182,33 @@ export class InfoService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const contributors = await get();
|
const { pull_count } = await get();
|
||||||
return contributors?.length;
|
return pull_count;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'InfoService');
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async countGitHubContributors(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const get = bent(
|
||||||
|
'https://github.com/ghostfolio/ghostfolio',
|
||||||
|
'GET',
|
||||||
|
'string',
|
||||||
|
200,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = await get();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
return extractNumberFromString(
|
||||||
|
$(
|
||||||
|
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
||||||
|
).text()
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService');
|
||||||
|
|
||||||
@ -210,10 +265,18 @@ export class InfoService {
|
|||||||
)) as string;
|
)) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDemoAuthToken() {
|
private async getDemoAuthToken() {
|
||||||
return this.jwtService.sign({
|
const demoUserId = (await this.propertyService.getByKey(
|
||||||
id: DEMO_USER_ID
|
PROPERTY_DEMO_USER_ID
|
||||||
});
|
)) as string;
|
||||||
|
|
||||||
|
if (demoUserId) {
|
||||||
|
return this.jwtService.sign({
|
||||||
|
id: demoUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getStatistics() {
|
private async getStatistics() {
|
||||||
@ -236,17 +299,22 @@ export class InfoService {
|
|||||||
const activeUsers1d = await this.countActiveUsers(1);
|
const activeUsers1d = await this.countActiveUsers(1);
|
||||||
const activeUsers30d = await this.countActiveUsers(30);
|
const activeUsers30d = await this.countActiveUsers(30);
|
||||||
const newUsers30d = await this.countNewUsers(30);
|
const newUsers30d = await this.countNewUsers(30);
|
||||||
|
|
||||||
|
const dockerHubPulls = await this.countDockerHubPulls();
|
||||||
const gitHubContributors = await this.countGitHubContributors();
|
const gitHubContributors = await this.countGitHubContributors();
|
||||||
const gitHubStargazers = await this.countGitHubStargazers();
|
const gitHubStargazers = await this.countGitHubStargazers();
|
||||||
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||||
|
const uptime = await this.getUptime();
|
||||||
|
|
||||||
statistics = {
|
statistics = {
|
||||||
activeUsers1d,
|
activeUsers1d,
|
||||||
activeUsers30d,
|
activeUsers30d,
|
||||||
|
dockerHubPulls,
|
||||||
gitHubContributors,
|
gitHubContributors,
|
||||||
gitHubStargazers,
|
gitHubStargazers,
|
||||||
newUsers30d,
|
newUsers30d,
|
||||||
slackCommunityUsers
|
slackCommunityUsers,
|
||||||
|
uptime
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.redisCacheService.set(
|
await this.redisCacheService.set(
|
||||||
@ -257,19 +325,49 @@ export class InfoService {
|
|||||||
return statistics;
|
return statistics;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSubscriptions(): Promise<Subscription[]> {
|
private async getSubscriptions(): Promise<{
|
||||||
|
[offer in SubscriptionOffer]: Subscription;
|
||||||
|
}> {
|
||||||
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeConfig = await this.prismaService.property.findUnique({
|
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||||
});
|
})) ?? { value: '{}' };
|
||||||
|
|
||||||
if (stripeConfig) {
|
return JSON.parse(stripeConfig.value);
|
||||||
return [JSON.parse(stripeConfig.value)];
|
}
|
||||||
|
|
||||||
|
private async getUptime(): Promise<number> {
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
const monitorId = (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
||||||
|
)) as string;
|
||||||
|
|
||||||
|
const get = bent(
|
||||||
|
`https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||||
|
subDays(new Date(), 90),
|
||||||
|
DATE_FORMAT
|
||||||
|
)}&to${format(new Date(), DATE_FORMAT)}`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
Authorization: `Bearer ${this.configurationService.get(
|
||||||
|
'BETTER_UPTIME_API_KEY'
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data } = await get();
|
||||||
|
return data.attributes.availability / 100;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'InfoService');
|
||||||
|
|
||||||
|
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 {}
|
55
apps/api/src/app/logo/logo.service.ts
Normal file
55
apps/api/src/app/logo/logo.service.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import * as bent from 'bent';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LogoService {
|
||||||
|
public constructor(
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async getLogoByDataSourceAndSymbol({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset) {
|
||||||
|
if (!DataSource[dataSource]) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!assetProfile) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getBuffer(assetProfile.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLogoByUrl(aUrl: string) {
|
||||||
|
return this.getBuffer(aUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBuffer(aUrl: string) {
|
||||||
|
const get = bent(
|
||||||
|
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||||
|
'GET',
|
||||||
|
'buffer',
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
'User-Agent': 'request'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return get();
|
||||||
|
}
|
||||||
|
}
|
@ -8,6 +8,7 @@ import {
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@ -64,4 +65,8 @@ export class CreateOrderDto {
|
|||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
updateAccountBalance?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,14 @@ export interface Activities {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity extends OrderWithAccount {
|
export interface Activity extends OrderWithAccount {
|
||||||
|
error?: ActivityError;
|
||||||
feeInBaseCurrency: number;
|
feeInBaseCurrency: number;
|
||||||
|
updateAccountBalance?: boolean;
|
||||||
|
value: number;
|
||||||
valueInBaseCurrency: number;
|
valueInBaseCurrency: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActivityError {
|
||||||
|
code: 'IS_DUPLICATE';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|
||||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
@ -36,12 +35,29 @@ import { UpdateOrderDto } from './update-order.dto';
|
|||||||
@Controller('order')
|
@Controller('order')
|
||||||
export class OrderController {
|
export class OrderController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly apiService: ApiService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly orderService: OrderService,
|
private readonly orderService: OrderService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
private readonly userService: UserService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Delete()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteOrders(): Promise<number> {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.orderService.deleteOrders({
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||||
@ -68,64 +84,29 @@ export class OrderController {
|
|||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getAllOrders(
|
public async getAllOrders(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<Activities> {
|
): Promise<Activities> {
|
||||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
filterByAccounts,
|
||||||
const tagIds = filterByTags?.split(',') ?? [];
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
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 impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
impersonationId,
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
|
||||||
|
|
||||||
let activities = await this.orderService.getOrders({
|
const activities = await this.orderService.getOrders({
|
||||||
filters,
|
filters,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
userId: impersonationUserId || this.request.user.id
|
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 };
|
return { activities };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,13 +2,14 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
|||||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { OrderController } from './order.controller';
|
import { OrderController } from './order.controller';
|
||||||
@ -18,6 +19,7 @@ import { OrderService } from './order.service';
|
|||||||
controllers: [OrderController],
|
controllers: [OrderController],
|
||||||
exports: [OrderService],
|
exports: [OrderService],
|
||||||
imports: [
|
imports: [
|
||||||
|
ApiModule,
|
||||||
CacheModule,
|
CacheModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
@ -73,35 +73,37 @@ export class OrderService {
|
|||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
|
updateAccountBalance?: boolean;
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
): Promise<Order> {
|
): Promise<Order> {
|
||||||
const defaultAccount = (
|
let Account;
|
||||||
await this.accountService.getAccounts(data.userId)
|
|
||||||
).find((account) => {
|
|
||||||
return account.isDefault === true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const tags = data.tags ?? [];
|
if (data.accountId) {
|
||||||
|
Account = {
|
||||||
let Account = {
|
connect: {
|
||||||
connect: {
|
id_userId: {
|
||||||
id_userId: {
|
userId: data.userId,
|
||||||
userId: data.userId,
|
id: data.accountId
|
||||||
id: data.accountId ?? defaultAccount?.id
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
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 === 'ITEM' || data.type === 'LIABILITY') {
|
||||||
const assetClass = data.assetClass;
|
const assetClass = data.assetClass;
|
||||||
const assetSubClass = data.assetSubClass;
|
const assetSubClass = data.assetSubClass;
|
||||||
const currency = data.SymbolProfile.connectOrCreate.create.currency;
|
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||||
const dataSource: DataSource = 'MANUAL';
|
const dataSource: DataSource = 'MANUAL';
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||||
|
|
||||||
Account = undefined;
|
|
||||||
data.id = id;
|
data.id = id;
|
||||||
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
||||||
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
||||||
@ -113,21 +115,24 @@ export class OrderService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol: id
|
symbol: id
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
data.SymbolProfile.connectOrCreate.create.symbol =
|
|
||||||
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue(
|
await this.dataGatheringService.addJobToQueue({
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
data: {
|
||||||
{
|
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
const isDraft =
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
? false
|
||||||
|
: isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
if (!isDraft) {
|
if (!isDraft) {
|
||||||
// Gather symbol data of order in the background, if not draft
|
// Gather symbol data of order in the background, if not draft
|
||||||
@ -152,11 +157,12 @@ export class OrderService {
|
|||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
delete data.tags;
|
delete data.tags;
|
||||||
|
delete data.updateAccountBalance;
|
||||||
delete data.userId;
|
delete data.userId;
|
||||||
|
|
||||||
const orderData: Prisma.OrderCreateInput = data;
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
|
|
||||||
return this.prismaService.order.create({
|
const order = await this.prismaService.order.create({
|
||||||
data: {
|
data: {
|
||||||
...orderData,
|
...orderData,
|
||||||
Account,
|
Account,
|
||||||
@ -168,6 +174,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(
|
public async deleteOrder(
|
||||||
@ -177,25 +204,35 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
if (order.type === 'ITEM') {
|
if (order.type === 'ITEM' || order.type === 'LIABILITY') {
|
||||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> {
|
||||||
|
const { count } = await this.prismaService.order.deleteMany({
|
||||||
|
where
|
||||||
|
});
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
public async getOrders({
|
public async getOrders({
|
||||||
filters,
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
types,
|
types,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId,
|
||||||
|
withExcludedAccounts = false
|
||||||
}: {
|
}: {
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
types?: TypeOfOrder[];
|
types?: TypeOfOrder[];
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<Activity[]> {
|
}): Promise<Activity[]> {
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
@ -284,24 +321,32 @@ export class OrderService {
|
|||||||
},
|
},
|
||||||
orderBy: { date: 'asc' }
|
orderBy: { date: 'asc' }
|
||||||
})
|
})
|
||||||
).map((order) => {
|
)
|
||||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
.filter((order) => {
|
||||||
|
return (
|
||||||
|
withExcludedAccounts ||
|
||||||
|
!order.Account ||
|
||||||
|
order.Account?.isExcluded === false
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((order) => {
|
||||||
|
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...order,
|
...order,
|
||||||
value,
|
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
|
||||||
order.fee,
|
|
||||||
order.SymbolProfile.currency,
|
|
||||||
userCurrency
|
|
||||||
),
|
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
|
||||||
value,
|
value,
|
||||||
order.SymbolProfile.currency,
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
userCurrency
|
order.fee,
|
||||||
)
|
order.SymbolProfile.currency,
|
||||||
};
|
userCurrency
|
||||||
});
|
),
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
value,
|
||||||
|
order.SymbolProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateOrder({
|
public async updateOrder({
|
||||||
@ -330,7 +375,7 @@ export class OrderService {
|
|||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
||||||
delete data.SymbolProfile.connect;
|
delete data.SymbolProfile.connect;
|
||||||
} else {
|
} else {
|
||||||
delete data.SymbolProfile.update;
|
delete data.SymbolProfile.update;
|
||||||
@ -356,6 +401,12 @@ export class OrderService {
|
|||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
delete data.tags;
|
delete data.tags;
|
||||||
|
|
||||||
|
// Remove existing tags
|
||||||
|
await this.prismaService.order.update({
|
||||||
|
data: { tags: { set: [] } },
|
||||||
|
where
|
||||||
|
});
|
||||||
|
|
||||||
return this.prismaService.order.update({
|
return this.prismaService.order.update({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
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;
|
||||||
|
}
|
114
apps/api/src/app/platform/platform.controller.ts
Normal file
114
apps/api/src/app/platform/platform.controller.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
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 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 createPlatform(data: Prisma.PlatformCreateInput) {
|
||||||
|
return this.prismaService.platform.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updatePlatform({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
}: {
|
||||||
|
data: Prisma.PlatformUpdateInput;
|
||||||
|
where: Prisma.PlatformWhereUniqueInput;
|
||||||
|
}): Promise<Platform> {
|
||||||
|
return this.prismaService.platform.update({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deletePlatform(
|
||||||
|
where: Prisma.PlatformWhereUniqueInput
|
||||||
|
): Promise<Platform> {
|
||||||
|
return this.prismaService.platform.delete({ 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 { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||||
|
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
|
|
||||||
function mockGetValue(symbol: string, date: Date) {
|
function mockGetValue(symbol: string, date: Date) {
|
||||||
@ -21,6 +22,17 @@ function mockGetValue(symbol: string, date: Date) {
|
|||||||
|
|
||||||
return { marketPrice: 0 };
|
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':
|
case 'NOVN.SW':
|
||||||
if (isSameDay(parseDate('2022-04-11'), date)) {
|
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||||
return { marketPrice: 87.8 };
|
return { marketPrice: 87.8 };
|
||||||
@ -37,8 +49,9 @@ export const CurrentRateServiceMock = {
|
|||||||
getValues: ({
|
getValues: ({
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery
|
dateQuery
|
||||||
}: GetValuesParams): Promise<GetValueObject[]> => {
|
}: GetValuesParams): Promise<GetValuesObject> => {
|
||||||
const result: GetValueObject[] = [];
|
const values: GetValueObject[] = [];
|
||||||
|
|
||||||
if (dateQuery.lt) {
|
if (dateQuery.lt) {
|
||||||
for (
|
for (
|
||||||
let date = resetHours(dateQuery.gte);
|
let date = resetHours(dateQuery.gte);
|
||||||
@ -46,7 +59,7 @@ export const CurrentRateServiceMock = {
|
|||||||
date = addDays(date, 1)
|
date = addDays(date, 1)
|
||||||
) {
|
) {
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
values.push({
|
||||||
date,
|
date,
|
||||||
marketPriceInBaseCurrency: mockGetValue(
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
dataGatheringItem.symbol,
|
dataGatheringItem.symbol,
|
||||||
@ -59,7 +72,7 @@ export const CurrentRateServiceMock = {
|
|||||||
} else {
|
} else {
|
||||||
for (const date of dateQuery.in) {
|
for (const date of dateQuery.in) {
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
values.push({
|
||||||
date,
|
date,
|
||||||
marketPriceInBaseCurrency: mockGetValue(
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
dataGatheringItem.symbol,
|
dataGatheringItem.symbol,
|
||||||
@ -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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
|
||||||
return {
|
return {
|
||||||
MarketDataService: jest.fn().mockImplementation(() => {
|
MarketDataService: jest.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
@ -17,7 +18,8 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
|||||||
createdAt: date,
|
createdAt: date,
|
||||||
dataSource: DataSource.YAHOO,
|
dataSource: DataSource.YAHOO,
|
||||||
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
||||||
marketPrice: 1847.839966
|
marketPrice: 1847.839966,
|
||||||
|
state: 'CLOSE'
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getRange: ({
|
getRange: ({
|
||||||
@ -36,6 +38,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
|||||||
date: dateRangeStart,
|
date: dateRangeStart,
|
||||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||||
marketPrice: 1841.823902,
|
marketPrice: 1841.823902,
|
||||||
|
state: 'CLOSE',
|
||||||
symbol: symbols[0]
|
symbol: symbols[0]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -44,6 +47,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
|||||||
date: dateRangeEnd,
|
date: dateRangeEnd,
|
||||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||||
marketPrice: 1847.839966,
|
marketPrice: 1847.839966,
|
||||||
|
state: 'CLOSE',
|
||||||
symbol: symbols[0]
|
symbol: symbols[0]
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
@ -53,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 {
|
return {
|
||||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
PropertyService: jest.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
initialize: () => Promise.resolve(),
|
getByKey: (key: string) => Promise.resolve({})
|
||||||
toCurrency: (value: number) => {
|
|
||||||
return 1 * value;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@ -71,13 +88,23 @@ describe('CurrentRateService', () => {
|
|||||||
let dataProviderService: DataProviderService;
|
let dataProviderService: DataProviderService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
let marketDataService: MarketDataService;
|
let marketDataService: MarketDataService;
|
||||||
|
let propertyService: PropertyService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
dataProviderService = new DataProviderService(null, [], null);
|
propertyService = new PropertyService(null);
|
||||||
|
|
||||||
|
dataProviderService = new DataProviderService(
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
propertyService
|
||||||
|
);
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
marketDataService = new MarketDataService(null);
|
marketDataService = new MarketDataService(null);
|
||||||
@ -102,17 +129,16 @@ describe('CurrentRateService', () => {
|
|||||||
},
|
},
|
||||||
userCurrency: 'CHF'
|
userCurrency: 'CHF'
|
||||||
})
|
})
|
||||||
).toMatchObject<GetValueObject[]>([
|
).toMatchObject<GetValuesObject>({
|
||||||
{
|
dataProviderInfos: [],
|
||||||
date: undefined,
|
errors: [],
|
||||||
marketPriceInBaseCurrency: 1841.823902,
|
values: [
|
||||||
symbol: 'AMZN'
|
{
|
||||||
},
|
date: undefined,
|
||||||
{
|
marketPriceInBaseCurrency: 1841.823902,
|
||||||
date: undefined,
|
symbol: 'AMZN'
|
||||||
marketPriceInBaseCurrency: 1847.839966,
|
}
|
||||||
symbol: 'AMZN'
|
]
|
||||||
}
|
});
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isBefore, isToday } from 'date-fns';
|
import { isBefore, isToday } from 'date-fns';
|
||||||
import { flatten } from 'lodash';
|
import { flatten, isEmpty, uniqBy } from 'lodash';
|
||||||
|
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -22,34 +24,52 @@ export class CurrentRateService {
|
|||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery,
|
dateQuery,
|
||||||
userCurrency
|
userCurrency
|
||||||
}: GetValuesParams): Promise<GetValueObject[]> {
|
}: GetValuesParams): Promise<GetValuesObject> {
|
||||||
|
const dataProviderInfos: DataProviderInfo[] = [];
|
||||||
const includeToday =
|
const includeToday =
|
||||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||||
|
|
||||||
const promises: Promise<GetValueObject[]>[] = [];
|
const promises: Promise<GetValueObject[]>[] = [];
|
||||||
|
const quoteErrors: ResponseError['errors'] = [];
|
||||||
|
const today = resetHours(new Date());
|
||||||
|
|
||||||
if (includeToday) {
|
if (includeToday) {
|
||||||
const today = resetHours(new Date());
|
|
||||||
promises.push(
|
promises.push(
|
||||||
this.dataProviderService
|
this.dataProviderService
|
||||||
.getQuotes(dataGatheringItems)
|
.getQuotes(dataGatheringItems)
|
||||||
.then((dataResultProvider) => {
|
.then((dataResultProvider) => {
|
||||||
const result: GetValueObject[] = [];
|
const result: GetValueObject[] = [];
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
if (
|
||||||
date: today,
|
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
|
||||||
marketPriceInBaseCurrency:
|
) {
|
||||||
this.exchangeRateDataService.toCurrency(
|
dataProviderInfos.push(
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]
|
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
|
||||||
?.marketPrice ?? 0,
|
);
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
}
|
||||||
userCurrency
|
|
||||||
),
|
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
|
||||||
symbol: dataGatheringItem.symbol
|
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;
|
return result;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -81,7 +101,60 @@ export class CurrentRateService {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return flatten(await Promise.all(promises));
|
const values = flatten(await Promise.all(promises));
|
||||||
|
|
||||||
|
const response: GetValuesObject = {
|
||||||
|
dataProviderInfos,
|
||||||
|
errors: quoteErrors.map(({ dataSource, symbol }) => {
|
||||||
|
return { dataSource, symbol };
|
||||||
|
}),
|
||||||
|
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isEmpty(quoteErrors)) {
|
||||||
|
for (const { symbol } of quoteErrors) {
|
||||||
|
try {
|
||||||
|
// If missing quote, fallback to the latest available historical market price
|
||||||
|
let value: GetValueObject = response.values.find((currentValue) => {
|
||||||
|
return currentValue.symbol === symbol && isToday(currentValue.date);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
value = {
|
||||||
|
symbol,
|
||||||
|
date: today,
|
||||||
|
marketPriceInBaseCurrency: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
response.values.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [latestValue] = response.values
|
||||||
|
.filter((currentValue) => {
|
||||||
|
return (
|
||||||
|
currentValue.symbol === symbol &&
|
||||||
|
currentValue.marketPriceInBaseCurrency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.date < b.date) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.date > b.date) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
value.marketPriceInBaseCurrency =
|
||||||
|
latestValue.marketPriceInBaseCurrency;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private containsToday(dates: Date[]): boolean {
|
private containsToday(dates: Date[]): boolean {
|
||||||
|
@ -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,5 @@
|
|||||||
import {
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile,
|
||||||
HistoricalDataItem
|
HistoricalDataItem
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
@ -7,6 +8,9 @@ import { Tag } from '@prisma/client';
|
|||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioPositionDetail {
|
||||||
averagePrice: number;
|
averagePrice: number;
|
||||||
|
dataProviderInfo: DataProviderInfo;
|
||||||
|
dividendInBaseCurrency: number;
|
||||||
|
feeInBaseCurrency: number;
|
||||||
firstBuyDate: string;
|
firstBuyDate: string;
|
||||||
grossPerformance: number;
|
grossPerformance: number;
|
||||||
grossPerformancePercent: number;
|
grossPerformancePercent: number;
|
||||||
|
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('0'),
|
averagePrice: new Big('0'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('3.2'),
|
||||||
firstBuyDate: '2021-11-22',
|
firstBuyDate: '2021-11-22',
|
||||||
grossPerformance: new Big('-12.6'),
|
grossPerformance: new Big('-12.6'),
|
||||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||||
|
@ -53,7 +53,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
@ -70,6 +71,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('136.6'),
|
averagePrice: new Big('136.6'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('1.55'),
|
||||||
firstBuyDate: '2021-11-30',
|
firstBuyDate: '2021-11-30',
|
||||||
grossPerformance: new Big('24.6'),
|
grossPerformance: new Big('24.6'),
|
||||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||||
|
@ -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 investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
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({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
@ -64,7 +64,8 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments();
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
@ -81,6 +82,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
averagePrice: new Big('75.80'),
|
averagePrice: new Big('75.80'),
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('4.25'),
|
||||||
firstBuyDate: '2022-03-07',
|
firstBuyDate: '2022-03-07',
|
||||||
grossPerformance: new Big('21.93'),
|
grossPerformance: new Big('21.93'),
|
||||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||||
|
@ -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 { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
|
ResponseError,
|
||||||
|
TimelinePosition
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { GroupBy } from '@ghostfolio/common/types';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Type as TypeOfOrder } from '@prisma/client';
|
import { Type as TypeOfOrder } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
@ -14,13 +19,15 @@ import {
|
|||||||
format,
|
format,
|
||||||
isAfter,
|
isAfter,
|
||||||
isBefore,
|
isBefore,
|
||||||
|
isSameDay,
|
||||||
isSameMonth,
|
isSameMonth,
|
||||||
isSameYear,
|
isSameYear,
|
||||||
max,
|
max,
|
||||||
min,
|
min,
|
||||||
set
|
set,
|
||||||
|
subDays
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { first, flatten, isNumber, sortBy } from 'lodash';
|
import { first, flatten, isNumber, last, sortBy, uniq } from 'lodash';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||||
@ -43,6 +50,7 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
private currency: string;
|
private currency: string;
|
||||||
private currentRateService: CurrentRateService;
|
private currentRateService: CurrentRateService;
|
||||||
|
private dataProviderInfos: DataProviderInfo[];
|
||||||
private orders: PortfolioOrder[];
|
private orders: PortfolioOrder[];
|
||||||
private transactionPoints: TransactionPoint[];
|
private transactionPoints: TransactionPoint[];
|
||||||
|
|
||||||
@ -167,13 +175,180 @@ export class PortfolioCalculator {
|
|||||||
this.transactionPoints = transactionPoints;
|
this.transactionPoints = transactionPoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
public async getChartData(start: Date, end = new Date(Date.now()), step = 1) {
|
||||||
if (!this.transactionPoints?.length) {
|
const symbols: { [symbol: string]: boolean } = {};
|
||||||
|
|
||||||
|
const transactionPointsBeforeEndDate =
|
||||||
|
this.transactionPoints?.filter((transactionPoint) => {
|
||||||
|
return isBefore(parseDate(transactionPoint.date), end);
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
|
const currencies: { [symbol: string]: string } = {};
|
||||||
|
const dates: Date[] = [];
|
||||||
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
|
const firstIndex = transactionPointsBeforeEndDate.length;
|
||||||
|
|
||||||
|
let day = start;
|
||||||
|
|
||||||
|
while (isBefore(day, end)) {
|
||||||
|
dates.push(resetHours(day));
|
||||||
|
day = addDays(day, step);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSameDay(last(dates), end)) {
|
||||||
|
dates.push(resetHours(end));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
|
||||||
|
dataGatheringItems.push({
|
||||||
|
dataSource: item.dataSource,
|
||||||
|
symbol: item.symbol
|
||||||
|
});
|
||||||
|
currencies[item.symbol] = item.currency;
|
||||||
|
symbols[item.symbol] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
for (const marketSymbol of marketSymbols) {
|
||||||
|
const dateString = format(marketSymbol.date, DATE_FORMAT);
|
||||||
|
if (!marketSymbolMap[dateString]) {
|
||||||
|
marketSymbolMap[dateString] = {};
|
||||||
|
}
|
||||||
|
if (marketSymbol.marketPriceInBaseCurrency) {
|
||||||
|
marketSymbolMap[dateString][marketSymbol.symbol] = new Big(
|
||||||
|
marketSymbol.marketPriceInBaseCurrency
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const valuesByDate: {
|
||||||
|
[date: string]: {
|
||||||
|
maxTotalInvestmentValue: Big;
|
||||||
|
totalCurrentValue: Big;
|
||||||
|
totalInvestmentValue: Big;
|
||||||
|
totalNetPerformanceValue: Big;
|
||||||
|
};
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
const valuesBySymbol: {
|
||||||
|
[symbol: string]: {
|
||||||
|
currentValues: { [date: string]: Big };
|
||||||
|
investmentValues: { [date: string]: Big };
|
||||||
|
maxInvestmentValues: { [date: string]: Big };
|
||||||
|
netPerformanceValues: { [date: string]: Big };
|
||||||
|
};
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
for (const symbol of Object.keys(symbols)) {
|
||||||
|
const {
|
||||||
|
currentValues,
|
||||||
|
investmentValues,
|
||||||
|
maxInvestmentValues,
|
||||||
|
netPerformanceValues
|
||||||
|
} = this.getSymbolMetrics({
|
||||||
|
end,
|
||||||
|
marketSymbolMap,
|
||||||
|
start,
|
||||||
|
step,
|
||||||
|
symbol,
|
||||||
|
isChartMode: true
|
||||||
|
});
|
||||||
|
|
||||||
|
valuesBySymbol[symbol] = {
|
||||||
|
currentValues,
|
||||||
|
investmentValues,
|
||||||
|
maxInvestmentValues,
|
||||||
|
netPerformanceValues
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const currentDate of dates) {
|
||||||
|
const dateString = format(currentDate, DATE_FORMAT);
|
||||||
|
|
||||||
|
for (const symbol of Object.keys(valuesBySymbol)) {
|
||||||
|
const symbolValues = valuesBySymbol[symbol];
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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.entries(valuesByDate).map(([date, values]) => {
|
||||||
|
const {
|
||||||
|
maxTotalInvestmentValue,
|
||||||
|
totalCurrentValue,
|
||||||
|
totalInvestmentValue,
|
||||||
|
totalNetPerformanceValue
|
||||||
|
} = values;
|
||||||
|
|
||||||
|
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
|
||||||
|
? 0
|
||||||
|
: totalNetPerformanceValue
|
||||||
|
.div(maxTotalInvestmentValue)
|
||||||
|
.mul(100)
|
||||||
|
.toNumber();
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
netPerformanceInPercentage,
|
||||||
|
netPerformance: totalNetPerformanceValue.toNumber(),
|
||||||
|
totalInvestment: totalInvestmentValue.toNumber(),
|
||||||
|
value: totalCurrentValue.toNumber()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCurrentPositions(
|
||||||
|
start: Date,
|
||||||
|
end = new Date(Date.now())
|
||||||
|
): Promise<CurrentPositions> {
|
||||||
|
const transactionPointsBeforeEndDate =
|
||||||
|
this.transactionPoints?.filter((transactionPoint) => {
|
||||||
|
return isBefore(parseDate(transactionPoint.date), end);
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
|
if (!transactionPointsBeforeEndDate.length) {
|
||||||
return {
|
return {
|
||||||
currentValue: new Big(0),
|
currentValue: new Big(0),
|
||||||
hasErrors: false,
|
|
||||||
grossPerformance: new Big(0),
|
grossPerformance: new Big(0),
|
||||||
grossPerformancePercentage: new Big(0),
|
grossPerformancePercentage: new Big(0),
|
||||||
|
hasErrors: false,
|
||||||
netPerformance: new Big(0),
|
netPerformance: new Big(0),
|
||||||
netPerformancePercentage: new Big(0),
|
netPerformancePercentage: new Big(0),
|
||||||
positions: [],
|
positions: [],
|
||||||
@ -182,41 +357,63 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lastTransactionPoint =
|
const lastTransactionPoint =
|
||||||
this.transactionPoints[this.transactionPoints.length - 1];
|
transactionPointsBeforeEndDate[transactionPointsBeforeEndDate.length - 1];
|
||||||
|
|
||||||
// use Date.now() to use the mock for today
|
|
||||||
const today = new Date(Date.now());
|
|
||||||
|
|
||||||
let firstTransactionPoint: TransactionPoint = null;
|
let firstTransactionPoint: TransactionPoint = null;
|
||||||
let firstIndex = this.transactionPoints.length;
|
let firstIndex = transactionPointsBeforeEndDate.length;
|
||||||
const dates = [];
|
let dates = [];
|
||||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
const currencies: { [symbol: string]: string } = {};
|
const currencies: { [symbol: string]: string } = {};
|
||||||
|
|
||||||
dates.push(resetHours(start));
|
dates.push(resetHours(start));
|
||||||
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
|
||||||
dataGatheringItems.push({
|
dataGatheringItems.push({
|
||||||
dataSource: item.dataSource,
|
dataSource: item.dataSource,
|
||||||
symbol: item.symbol
|
symbol: item.symbol
|
||||||
});
|
});
|
||||||
currencies[item.symbol] = item.currency;
|
currencies[item.symbol] = item.currency;
|
||||||
}
|
}
|
||||||
for (let i = 0; i < this.transactionPoints.length; i++) {
|
for (let i = 0; i < transactionPointsBeforeEndDate.length; i++) {
|
||||||
if (
|
if (
|
||||||
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
|
!isBefore(parseDate(transactionPointsBeforeEndDate[i].date), start) &&
|
||||||
firstTransactionPoint === null
|
firstTransactionPoint === null
|
||||||
) {
|
) {
|
||||||
firstTransactionPoint = this.transactionPoints[i];
|
firstTransactionPoint = transactionPointsBeforeEndDate[i];
|
||||||
firstIndex = i;
|
firstIndex = i;
|
||||||
}
|
}
|
||||||
if (firstTransactionPoint !== null) {
|
if (firstTransactionPoint !== null) {
|
||||||
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
|
dates.push(
|
||||||
|
resetHours(parseDate(transactionPointsBeforeEndDate[i].date))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dates.push(resetHours(today));
|
dates.push(resetHours(end));
|
||||||
|
|
||||||
const marketSymbols = await this.currentRateService.getValues({
|
// Add dates of last week for fallback
|
||||||
|
dates.push(subDays(resetHours(new Date()), 7));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 6));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 5));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 4));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 3));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 2));
|
||||||
|
dates.push(subDays(resetHours(new Date()), 1));
|
||||||
|
dates.push(resetHours(new Date()));
|
||||||
|
|
||||||
|
dates = uniq(
|
||||||
|
dates.map((date) => {
|
||||||
|
return date.getTime();
|
||||||
|
})
|
||||||
|
).map((timestamp) => {
|
||||||
|
return new Date(timestamp);
|
||||||
|
});
|
||||||
|
dates.sort((a, b) => a.getTime() - b.getTime());
|
||||||
|
|
||||||
|
const {
|
||||||
|
dataProviderInfos,
|
||||||
|
errors: currentRateErrors,
|
||||||
|
values: marketSymbols
|
||||||
|
} = await this.currentRateService.getValues({
|
||||||
currencies,
|
currencies,
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery: {
|
dateQuery: {
|
||||||
@ -225,6 +422,8 @@ export class PortfolioCalculator {
|
|||||||
userCurrency: this.currency
|
userCurrency: this.currency
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.dataProviderInfos = dataProviderInfos;
|
||||||
|
|
||||||
const marketSymbolMap: {
|
const marketSymbolMap: {
|
||||||
[date: string]: { [symbol: string]: Big };
|
[date: string]: { [symbol: string]: Big };
|
||||||
} = {};
|
} = {};
|
||||||
@ -241,7 +440,7 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const todayString = format(today, DATE_FORMAT);
|
const endDateString = format(end, DATE_FORMAT);
|
||||||
|
|
||||||
if (firstIndex > 0) {
|
if (firstIndex > 0) {
|
||||||
firstIndex--;
|
firstIndex--;
|
||||||
@ -254,7 +453,7 @@ export class PortfolioCalculator {
|
|||||||
const errors: ResponseError['errors'] = [];
|
const errors: ResponseError['errors'] = [];
|
||||||
|
|
||||||
for (const item of lastTransactionPoint.items) {
|
for (const item of lastTransactionPoint.items) {
|
||||||
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
const marketValue = marketSymbolMap[endDateString]?.[item.symbol];
|
||||||
|
|
||||||
const {
|
const {
|
||||||
grossPerformance,
|
grossPerformance,
|
||||||
@ -264,6 +463,7 @@ export class PortfolioCalculator {
|
|||||||
netPerformance,
|
netPerformance,
|
||||||
netPerformancePercentage
|
netPerformancePercentage
|
||||||
} = this.getSymbolMetrics({
|
} = this.getSymbolMetrics({
|
||||||
|
end,
|
||||||
marketSymbolMap,
|
marketSymbolMap,
|
||||||
start,
|
start,
|
||||||
symbol: item.symbol
|
symbol: item.symbol
|
||||||
@ -278,6 +478,7 @@ export class PortfolioCalculator {
|
|||||||
: item.investment.div(item.quantity),
|
: item.investment.div(item.quantity),
|
||||||
currency: item.currency,
|
currency: item.currency,
|
||||||
dataSource: item.dataSource,
|
dataSource: item.dataSource,
|
||||||
|
fee: item.fee,
|
||||||
firstBuyDate: item.firstBuyDate,
|
firstBuyDate: item.firstBuyDate,
|
||||||
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
||||||
grossPerformancePercentage: !hasErrors
|
grossPerformancePercentage: !hasErrors
|
||||||
@ -294,7 +495,13 @@ export class PortfolioCalculator {
|
|||||||
transactionCount: item.transactionCount
|
transactionCount: item.transactionCount
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasErrors) {
|
if (
|
||||||
|
(hasErrors ||
|
||||||
|
currentRateErrors.find(({ dataSource, symbol }) => {
|
||||||
|
return dataSource === item.dataSource && symbol === item.symbol;
|
||||||
|
})) &&
|
||||||
|
item.investment.gt(0)
|
||||||
|
) {
|
||||||
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -309,6 +516,10 @@ export class PortfolioCalculator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getDataProviderInfos() {
|
||||||
|
return this.dataProviderInfos;
|
||||||
|
}
|
||||||
|
|
||||||
public getInvestments(): { date: string; investment: Big }[] {
|
public getInvestments(): { date: string; investment: Big }[] {
|
||||||
if (this.transactionPoints.length === 0) {
|
if (this.transactionPoints.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
@ -326,51 +537,95 @@ export class PortfolioCalculator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
|
public getInvestmentsByGroup(
|
||||||
|
groupBy: GroupBy
|
||||||
|
): { date: string; investment: Big }[] {
|
||||||
if (this.orders.length === 0) {
|
if (this.orders.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const investments = [];
|
const investments: { date: string; investment: Big }[] = [];
|
||||||
let currentDate: Date;
|
let currentDate: Date;
|
||||||
let investmentByMonth = new Big(0);
|
let investmentByGroup = new Big(0);
|
||||||
|
|
||||||
for (const [index, order] of this.orders.entries()) {
|
for (const [index, order] of this.orders.entries()) {
|
||||||
if (
|
if (
|
||||||
isSameMonth(parseDate(order.date), currentDate) &&
|
isSameYear(parseDate(order.date), currentDate) &&
|
||||||
isSameYear(parseDate(order.date), currentDate)
|
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
||||||
) {
|
) {
|
||||||
// Same month: Add up investments
|
// Same group: Add up investments
|
||||||
|
investmentByGroup = investmentByGroup.plus(
|
||||||
investmentByMonth = investmentByMonth.plus(
|
|
||||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// New month: Store previous month and reset
|
// New group: Store previous group and reset
|
||||||
|
|
||||||
if (currentDate) {
|
if (currentDate) {
|
||||||
investments.push({
|
investments.push({
|
||||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
date: format(
|
||||||
investment: investmentByMonth
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
),
|
||||||
|
investment: investmentByGroup
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
currentDate = parseDate(order.date);
|
currentDate = parseDate(order.date);
|
||||||
investmentByMonth = order.quantity
|
investmentByGroup = order.quantity
|
||||||
.mul(order.unitPrice)
|
.mul(order.unitPrice)
|
||||||
.mul(this.getFactor(order.type));
|
.mul(this.getFactor(order.type));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index === this.orders.length - 1) {
|
if (index === this.orders.length - 1) {
|
||||||
// Store current month (latest order)
|
// Store current group (latest order)
|
||||||
investments.push({
|
investments.push({
|
||||||
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
date: format(
|
||||||
investment: investmentByMonth
|
set(currentDate, {
|
||||||
|
date: 1,
|
||||||
|
month: groupBy === 'year' ? 0 : currentDate.getMonth()
|
||||||
|
}),
|
||||||
|
DATE_FORMAT
|
||||||
|
),
|
||||||
|
investment: investmentByGroup
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
public async calculateTimeline(
|
||||||
@ -432,30 +687,36 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let minNetPerformance = new Big(0);
|
||||||
|
let maxNetPerformance = new Big(0);
|
||||||
|
|
||||||
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
||||||
timelinePeriodPromises
|
timelinePeriodPromises
|
||||||
);
|
);
|
||||||
const minNetPerformance = timelineInfoInterfaces
|
|
||||||
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
|
||||||
.filter((performance) => performance !== null)
|
|
||||||
.reduce((minPerformance, current) => {
|
|
||||||
if (minPerformance.lt(current)) {
|
|
||||||
return minPerformance;
|
|
||||||
} else {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxNetPerformance = timelineInfoInterfaces
|
try {
|
||||||
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
minNetPerformance = timelineInfoInterfaces
|
||||||
.filter((performance) => performance !== null)
|
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
||||||
.reduce((maxPerformance, current) => {
|
.filter((performance) => performance !== null)
|
||||||
if (maxPerformance.gt(current)) {
|
.reduce((minPerformance, current) => {
|
||||||
return maxPerformance;
|
if (minPerformance.lt(current)) {
|
||||||
} else {
|
return minPerformance;
|
||||||
return current;
|
} else {
|
||||||
}
|
return current;
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
maxNetPerformance = timelineInfoInterfaces
|
||||||
|
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
||||||
|
.filter((performance) => performance !== null)
|
||||||
|
.reduce((maxPerformance, current) => {
|
||||||
|
if (maxPerformance.gt(current)) {
|
||||||
|
return maxPerformance;
|
||||||
|
} else {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const timelinePeriods = timelineInfoInterfaces.map(
|
const timelinePeriods = timelineInfoInterfaces.map(
|
||||||
(timelineInfo) => timelineInfo.timelinePeriods
|
(timelineInfo) => timelineInfo.timelinePeriods
|
||||||
@ -520,7 +781,7 @@ export class PortfolioCalculator {
|
|||||||
);
|
);
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
Logger.warn(
|
Logger.warn(
|
||||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
|
`Missing historical market data for symbol ${currentPosition.symbol}`,
|
||||||
'PortfolioCalculator'
|
'PortfolioCalculator'
|
||||||
);
|
);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
@ -574,7 +835,7 @@ export class PortfolioCalculator {
|
|||||||
let marketSymbols: GetValueObject[] = [];
|
let marketSymbols: GetValueObject[] = [];
|
||||||
if (dataGatheringItems.length > 0) {
|
if (dataGatheringItems.length > 0) {
|
||||||
try {
|
try {
|
||||||
marketSymbols = await this.currentRateService.getValues({
|
const { values } = await this.currentRateService.getValues({
|
||||||
currencies,
|
currencies,
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery: {
|
dateQuery: {
|
||||||
@ -583,6 +844,7 @@ export class PortfolioCalculator {
|
|||||||
},
|
},
|
||||||
userCurrency: this.currency
|
userCurrency: this.currency
|
||||||
});
|
});
|
||||||
|
marketSymbols = values;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
`Failed to fetch info for date ${startDate} with exception`,
|
`Failed to fetch info for date ${startDate} with exception`,
|
||||||
@ -694,14 +956,20 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getSymbolMetrics({
|
private getSymbolMetrics({
|
||||||
|
end,
|
||||||
|
isChartMode = false,
|
||||||
marketSymbolMap,
|
marketSymbolMap,
|
||||||
start,
|
start,
|
||||||
|
step = 1,
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: {
|
||||||
|
end: Date;
|
||||||
|
isChartMode?: boolean;
|
||||||
marketSymbolMap: {
|
marketSymbolMap: {
|
||||||
[date: string]: { [symbol: string]: Big };
|
[date: string]: { [symbol: string]: Big };
|
||||||
};
|
};
|
||||||
start: Date;
|
start: Date;
|
||||||
|
step?: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}) {
|
}) {
|
||||||
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
|
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
|
||||||
@ -710,23 +978,26 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
if (orders.length <= 0) {
|
if (orders.length <= 0) {
|
||||||
return {
|
return {
|
||||||
|
currentValues: {},
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
initialValue: new Big(0),
|
initialValue: new Big(0),
|
||||||
|
investmentValues: {},
|
||||||
|
maxInvestmentValues: {},
|
||||||
netPerformance: new Big(0),
|
netPerformance: new Big(0),
|
||||||
netPerformancePercentage: new Big(0),
|
netPerformancePercentage: new Big(0),
|
||||||
grossPerformance: new Big(0),
|
netPerformanceValues: {}
|
||||||
grossPerformancePercentage: new Big(0)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateOfFirstTransaction = new Date(first(orders).date);
|
const dateOfFirstTransaction = new Date(first(orders).date);
|
||||||
const endDate = new Date(Date.now());
|
|
||||||
|
|
||||||
const unitPriceAtStartDate =
|
const unitPriceAtStartDate =
|
||||||
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
||||||
|
|
||||||
const unitPriceAtEndDate =
|
const unitPriceAtEndDate =
|
||||||
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
|
marketSymbolMap[format(end, DATE_FORMAT)]?.[symbol];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!unitPriceAtEndDate ||
|
!unitPriceAtEndDate ||
|
||||||
@ -751,12 +1022,12 @@ export class PortfolioCalculator {
|
|||||||
let grossPerformanceFromSells = new Big(0);
|
let grossPerformanceFromSells = new Big(0);
|
||||||
let initialValue: Big;
|
let initialValue: Big;
|
||||||
let investmentAtStartDate: Big;
|
let investmentAtStartDate: Big;
|
||||||
|
const currentValues: { [date: string]: Big } = {};
|
||||||
|
const investmentValues: { [date: string]: Big } = {};
|
||||||
|
const maxInvestmentValues: { [date: string]: Big } = {};
|
||||||
let lastAveragePrice = new Big(0);
|
let lastAveragePrice = new Big(0);
|
||||||
let lastTransactionInvestment = new Big(0);
|
|
||||||
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
|
||||||
let maxTotalInvestment = new Big(0);
|
let maxTotalInvestment = new Big(0);
|
||||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
const netPerformanceValues: { [date: string]: Big } = {};
|
||||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
|
||||||
let totalInvestment = new Big(0);
|
let totalInvestment = new Big(0);
|
||||||
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
||||||
let totalUnits = new Big(0);
|
let totalUnits = new Big(0);
|
||||||
@ -779,7 +1050,7 @@ export class PortfolioCalculator {
|
|||||||
orders.push({
|
orders.push({
|
||||||
symbol,
|
symbol,
|
||||||
currency: null,
|
currency: null,
|
||||||
date: format(endDate, DATE_FORMAT),
|
date: format(end, DATE_FORMAT),
|
||||||
dataSource: null,
|
dataSource: null,
|
||||||
fee: new Big(0),
|
fee: new Big(0),
|
||||||
itemType: 'end',
|
itemType: 'end',
|
||||||
@ -789,6 +1060,41 @@ export class PortfolioCalculator {
|
|||||||
unitPrice: unitPriceAtEndDate
|
unitPrice: unitPriceAtEndDate
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let day = start;
|
||||||
|
let lastUnitPrice: Big;
|
||||||
|
|
||||||
|
if (isChartMode) {
|
||||||
|
const datesWithOrders = {};
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
datesWithOrders[order.date] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (isBefore(day, end)) {
|
||||||
|
const hasDate = datesWithOrders[format(day, DATE_FORMAT)];
|
||||||
|
|
||||||
|
if (!hasDate) {
|
||||||
|
orders.push({
|
||||||
|
symbol,
|
||||||
|
currency: null,
|
||||||
|
date: format(day, DATE_FORMAT),
|
||||||
|
dataSource: null,
|
||||||
|
fee: new Big(0),
|
||||||
|
name: '',
|
||||||
|
quantity: new Big(0),
|
||||||
|
type: TypeOfOrder.BUY,
|
||||||
|
unitPrice:
|
||||||
|
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
|
||||||
|
lastUnitPrice
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUnitPrice = last(orders).unitPrice;
|
||||||
|
|
||||||
|
day = addDays(day, step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sort orders so that the start and end placeholder order are at the right
|
// Sort orders so that the start and end placeholder order are at the right
|
||||||
// position
|
// position
|
||||||
orders = sortBy(orders, (order) => {
|
orders = sortBy(orders, (order) => {
|
||||||
@ -816,6 +1122,12 @@ export class PortfolioCalculator {
|
|||||||
for (let i = 0; i < orders.length; i += 1) {
|
for (let i = 0; i < orders.length; i += 1) {
|
||||||
const order = orders[i];
|
const order = orders[i];
|
||||||
|
|
||||||
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
|
console.log();
|
||||||
|
console.log();
|
||||||
|
console.log(i + 1, order.type, order.itemType);
|
||||||
|
}
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
if (order.itemType === 'start') {
|
||||||
// Take the unit price of the order as the market price if there are no
|
// Take the unit price of the order as the market price if there are no
|
||||||
// orders of this symbol before the start date
|
// orders of this symbol before the start date
|
||||||
@ -843,9 +1155,21 @@ export class PortfolioCalculator {
|
|||||||
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
valueAtStartDate = valueOfInvestmentBeforeTransaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transactionInvestment = order.quantity
|
const transactionInvestment =
|
||||||
.mul(order.unitPrice)
|
order.type === 'BUY'
|
||||||
.mul(this.getFactor(order.type));
|
? 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);
|
totalInvestment = totalInvestment.plus(transactionInvestment);
|
||||||
|
|
||||||
@ -894,71 +1218,51 @@ export class PortfolioCalculator {
|
|||||||
? new Big(0)
|
? new Big(0)
|
||||||
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
||||||
|
|
||||||
const newGrossPerformance = valueOfInvestment
|
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||||
.minus(totalInvestmentWithGrossPerformanceFromSell)
|
console.log(
|
||||||
.plus(grossPerformanceFromSells);
|
'totalInvestmentWithGrossPerformanceFromSell',
|
||||||
|
totalInvestmentWithGrossPerformanceFromSell.toNumber()
|
||||||
if (
|
);
|
||||||
i > indexOfStartOrder &&
|
console.log(
|
||||||
!lastValueOfInvestmentBeforeTransaction
|
'grossPerformanceFromSells',
|
||||||
.plus(lastTransactionInvestment)
|
grossPerformanceFromSells.toNumber()
|
||||||
.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)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newGrossPerformance = valueOfInvestment
|
||||||
|
.minus(totalInvestment)
|
||||||
|
.plus(grossPerformanceFromSells);
|
||||||
|
|
||||||
grossPerformance = newGrossPerformance;
|
grossPerformance = newGrossPerformance;
|
||||||
|
|
||||||
lastTransactionInvestment = transactionInvestment;
|
|
||||||
|
|
||||||
lastValueOfInvestmentBeforeTransaction =
|
|
||||||
valueOfInvestmentBeforeTransaction;
|
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
if (order.itemType === 'start') {
|
||||||
feesAtStartDate = fees;
|
feesAtStartDate = fees;
|
||||||
grossPerformanceAtStartDate = grossPerformance;
|
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) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
timeWeightedGrossPerformancePercentage =
|
|
||||||
timeWeightedGrossPerformancePercentage.minus(1);
|
|
||||||
|
|
||||||
timeWeightedNetPerformancePercentage =
|
|
||||||
timeWeightedNetPerformancePercentage.minus(1);
|
|
||||||
|
|
||||||
const totalGrossPerformance = grossPerformance.minus(
|
const totalGrossPerformance = grossPerformance.minus(
|
||||||
grossPerformanceAtStartDate
|
grossPerformanceAtStartDate
|
||||||
);
|
);
|
||||||
@ -1022,6 +1326,7 @@ export class PortfolioCalculator {
|
|||||||
Average price: ${averagePriceAtStartDate.toFixed(
|
Average price: ${averagePriceAtStartDate.toFixed(
|
||||||
2
|
2
|
||||||
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
)} -> ${averagePriceAtEndDate.toFixed(2)}
|
||||||
|
Total investment: ${totalInvestment.toFixed(2)}
|
||||||
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
Max. total investment: ${maxTotalInvestment.toFixed(2)}
|
||||||
Gross performance: ${totalGrossPerformance.toFixed(
|
Gross performance: ${totalGrossPerformance.toFixed(
|
||||||
2
|
2
|
||||||
@ -1034,12 +1339,16 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialValue,
|
currentValues,
|
||||||
grossPerformancePercentage,
|
grossPerformancePercentage,
|
||||||
|
initialValue,
|
||||||
|
investmentValues,
|
||||||
|
maxInvestmentValues,
|
||||||
netPerformancePercentage,
|
netPerformancePercentage,
|
||||||
|
netPerformanceValues,
|
||||||
|
grossPerformance: totalGrossPerformance,
|
||||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||||
netPerformance: totalNetPerformance,
|
netPerformance: totalNetPerformance
|
||||||
grossPerformance: totalGrossPerformance
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,20 +7,18 @@ import {
|
|||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
Filter,
|
|
||||||
PortfolioChart,
|
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
|
PortfolioDividends,
|
||||||
PortfolioInvestments,
|
PortfolioInvestments,
|
||||||
PortfolioPerformanceResponse,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport
|
||||||
PortfolioSummary
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
|
||||||
import type {
|
import type {
|
||||||
DateRange,
|
DateRange,
|
||||||
GroupBy,
|
GroupBy,
|
||||||
@ -35,11 +33,12 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors,
|
||||||
|
Version
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { ViewMode } from '@prisma/client';
|
import Big from 'big.js';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||||
@ -52,6 +51,7 @@ export class PortfolioController {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
|
private readonly apiService: ApiService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@ -61,105 +61,52 @@ export class PortfolioController {
|
|||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
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')
|
@Get('details')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('range') range?: DateRange,
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||||
|
let hasDetails = true;
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||||
const tagIds = filterByTags?.split(',') ?? [];
|
}
|
||||||
|
|
||||||
const filters: Filter[] = [
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
...accountIds.map((accountId) => {
|
filterByAccounts,
|
||||||
return <Filter>{
|
filterByAssetClasses,
|
||||||
id: accountId,
|
filterByTags
|
||||||
type: 'ACCOUNT'
|
});
|
||||||
};
|
|
||||||
}),
|
|
||||||
...assetClasses.map((assetClass) => {
|
|
||||||
return <Filter>{
|
|
||||||
id: assetClass,
|
|
||||||
type: 'ASSET_CLASS'
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
...tagIds.map((tagId) => {
|
|
||||||
return <Filter>{
|
|
||||||
id: tagId,
|
|
||||||
type: 'TAG'
|
|
||||||
};
|
|
||||||
})
|
|
||||||
];
|
|
||||||
|
|
||||||
const { accounts, holdings, hasErrors } =
|
const {
|
||||||
await this.portfolioService.getDetails(
|
accounts,
|
||||||
impersonationId,
|
filteredValueInBaseCurrency,
|
||||||
this.request.user.id,
|
filteredValueInPercentage,
|
||||||
range,
|
hasErrors,
|
||||||
filters
|
holdings,
|
||||||
);
|
platforms,
|
||||||
|
summary,
|
||||||
|
totalValueInBaseCurrency
|
||||||
|
} = await this.portfolioService.getDetails({
|
||||||
|
dateRange,
|
||||||
|
filters,
|
||||||
|
impersonationId,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let portfolioSummary = summary;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
@ -175,7 +122,7 @@ export class PortfolioController {
|
|||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
portfolioPosition.currency,
|
portfolioPosition.currency,
|
||||||
this.request.user.Settings.currency
|
this.request.user.Settings.settings.baseCurrency
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
@ -186,18 +133,40 @@ export class PortfolioController {
|
|||||||
portfolioPosition.investment / totalInvestment;
|
portfolioPosition.investment / totalInvestment;
|
||||||
portfolioPosition.netPerformance = null;
|
portfolioPosition.netPerformance = null;
|
||||||
portfolioPosition.quantity = null;
|
portfolioPosition.quantity = null;
|
||||||
portfolioPosition.value = portfolioPosition.value / totalValue;
|
portfolioPosition.valueInPercentage =
|
||||||
|
portfolioPosition.value / totalValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
|
||||||
accounts[name].current = current / totalValue;
|
accounts[name].valueInPercentage = valueInBaseCurrency / totalValue;
|
||||||
accounts[name].original = original / totalInvestment;
|
}
|
||||||
|
|
||||||
|
for (const [name, { valueInBaseCurrency }] of Object.entries(platforms)) {
|
||||||
|
platforms[name].valueInPercentage = valueInBaseCurrency / totalValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasDetails = true;
|
if (
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
hasDetails === false ||
|
||||||
hasDetails = this.request.user.subscription.type === 'Premium';
|
impersonationId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
portfolioSummary = nullifyValuesInObject(summary, [
|
||||||
|
'cash',
|
||||||
|
'committedFunds',
|
||||||
|
'currentGrossPerformance',
|
||||||
|
'currentNetPerformance',
|
||||||
|
'currentValue',
|
||||||
|
'dividend',
|
||||||
|
'emergencyFund',
|
||||||
|
'excludedAccountsAndActivities',
|
||||||
|
'fees',
|
||||||
|
'items',
|
||||||
|
'liabilities',
|
||||||
|
'netWorth',
|
||||||
|
'totalBuy',
|
||||||
|
'totalSell'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
@ -214,37 +183,89 @@ export class PortfolioController {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
accounts,
|
accounts,
|
||||||
|
filteredValueInBaseCurrency,
|
||||||
|
filteredValueInPercentage,
|
||||||
hasError,
|
hasError,
|
||||||
holdings
|
holdings,
|
||||||
|
platforms,
|
||||||
|
totalValueInBaseCurrency,
|
||||||
|
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')
|
@Get('investments')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('groupBy') groupBy?: GroupBy
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('groupBy') groupBy?: GroupBy,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioInvestments> {
|
): Promise<PortfolioInvestments> {
|
||||||
if (
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
filterByAccounts,
|
||||||
this.request.user.subscription.type === 'Basic'
|
filterByAssetClasses,
|
||||||
) {
|
filterByTags
|
||||||
throw new HttpException(
|
});
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let investments: InvestmentItem[];
|
let { investments, streaks } = await this.portfolioService.getInvestments({
|
||||||
|
dateRange,
|
||||||
if (groupBy === 'month') {
|
filters,
|
||||||
investments = await this.portfolioService.getInvestments(
|
groupBy,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
'month'
|
savingsRate: this.request.user?.Settings?.settings.savingsRate
|
||||||
);
|
});
|
||||||
} else {
|
|
||||||
investments = await this.portfolioService.getInvestments(impersonationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -259,31 +280,93 @@ export class PortfolioController {
|
|||||||
date: item.date,
|
date: item.date,
|
||||||
investment: item.investment / maxInvestment
|
investment: item.investment / maxInvestment
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
streaks = nullifyValuesInObject(streaks, [
|
||||||
|
'currentStreak',
|
||||||
|
'longestStreak'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { firstOrderDate: parseDate(investments[0]?.date), investments };
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
investments = investments.map((item) => {
|
||||||
|
return nullifyValuesInObject(item, ['investment']);
|
||||||
|
});
|
||||||
|
|
||||||
|
streaks = nullifyValuesInObject(streaks, [
|
||||||
|
'currentStreak',
|
||||||
|
'longestStreak'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { investments, streaks };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPerformance(
|
@Version('2')
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
public async getPerformanceV2(
|
||||||
@Query('range') range
|
@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> {
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
const performanceInformation = await this.portfolioService.getPerformance(
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
|
filterByAccounts,
|
||||||
|
filterByAssetClasses,
|
||||||
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
|
const performanceInformation = await this.portfolioService.getPerformance({
|
||||||
|
dateRange,
|
||||||
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
range
|
userId: this.request.user.id
|
||||||
);
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.request.user.Settings.viewMode === ViewMode.ZEN ||
|
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
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 = nullifyValuesInObject(
|
||||||
performanceInformation.performance,
|
performanceInformation.performance,
|
||||||
['currentGrossPerformance', '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']);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -292,34 +375,30 @@ export class PortfolioController {
|
|||||||
|
|
||||||
@Get('positions')
|
@Get('positions')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPositions(
|
public async getPositions(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('range') range
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
const result = await this.portfolioService.getPositions(
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
impersonationId,
|
filterByAccounts,
|
||||||
range
|
filterByAssetClasses,
|
||||||
);
|
filterByTags
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
return this.portfolioService.getPositions({
|
||||||
impersonationId ||
|
dateRange,
|
||||||
this.userService.isRestrictedView(this.request.user)
|
filters,
|
||||||
) {
|
impersonationId
|
||||||
result.positions = result.positions.map((position) => {
|
});
|
||||||
return nullifyValuesInObject(position, [
|
|
||||||
'grossPerformance',
|
|
||||||
'investment',
|
|
||||||
'netPerformance',
|
|
||||||
'quantity'
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('public/:accessId')
|
@Get('public/:accessId')
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPublic(
|
public async getPublic(
|
||||||
@Param('accessId') accessId
|
@Param('accessId') accessId
|
||||||
): Promise<PortfolioPublicDetails> {
|
): Promise<PortfolioPublicDetails> {
|
||||||
@ -340,15 +419,16 @@ export class PortfolioController {
|
|||||||
hasDetails = user.subscription.type === 'Premium';
|
hasDetails = user.subscription.type === 'Premium';
|
||||||
}
|
}
|
||||||
|
|
||||||
const { holdings } = await this.portfolioService.getDetails(
|
const { holdings } = await this.portfolioService.getDetails({
|
||||||
access.userId,
|
dateRange: 'max',
|
||||||
access.userId,
|
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||||
'max',
|
impersonationId: access.userId,
|
||||||
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
|
userId: user.id
|
||||||
);
|
});
|
||||||
|
|
||||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
hasDetails,
|
hasDetails,
|
||||||
|
alias: access.alias,
|
||||||
holdings: {}
|
holdings: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -357,99 +437,49 @@ export class PortfolioController {
|
|||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
portfolioPosition.currency,
|
portfolioPosition.currency,
|
||||||
this.request.user?.Settings?.currency ?? this.baseCurrency
|
this.request.user?.Settings?.settings.baseCurrency ??
|
||||||
|
this.baseCurrency
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
portfolioPublicDetails.holdings[symbol] = {
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
allocationCurrent: portfolioPosition.value / totalValue,
|
allocationInPercentage: portfolioPosition.value / totalValue,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
|
dataSource: portfolioPosition.dataSource,
|
||||||
|
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
name: portfolioPosition.name,
|
name: portfolioPosition.name,
|
||||||
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
symbol: portfolioPosition.symbol,
|
symbol: portfolioPosition.symbol,
|
||||||
url: portfolioPosition.url,
|
url: portfolioPosition.url,
|
||||||
value: portfolioPosition.value / totalValue
|
valueInPercentage: portfolioPosition.value / totalValue
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return portfolioPublicDetails;
|
return portfolioPublicDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('summary')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async getSummary(
|
|
||||||
@Headers('impersonation-id') impersonationId
|
|
||||||
): Promise<PortfolioSummary> {
|
|
||||||
if (
|
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
||||||
this.request.user.subscription.type === 'Basic'
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let summary = await this.portfolioService.getSummary(impersonationId);
|
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
summary = nullifyValuesInObject(summary, [
|
|
||||||
'cash',
|
|
||||||
'committedFunds',
|
|
||||||
'currentGrossPerformance',
|
|
||||||
'currentNetPerformance',
|
|
||||||
'currentValue',
|
|
||||||
'dividend',
|
|
||||||
'emergencyFund',
|
|
||||||
'fees',
|
|
||||||
'items',
|
|
||||||
'netWorth',
|
|
||||||
'totalBuy',
|
|
||||||
'totalSell'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('position/:dataSource/:symbol')
|
@Get('position/:dataSource/:symbol')
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Param('dataSource') dataSource,
|
@Param('dataSource') dataSource,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
let position = await this.portfolioService.getPosition(
|
const position = await this.portfolioService.getPosition(
|
||||||
dataSource,
|
dataSource,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
symbol
|
symbol
|
||||||
);
|
);
|
||||||
|
|
||||||
if (position) {
|
if (position) {
|
||||||
if (
|
|
||||||
impersonationId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
position = nullifyValuesInObject(position, [
|
|
||||||
'grossPerformance',
|
|
||||||
'investment',
|
|
||||||
'netPerformance',
|
|
||||||
'orders',
|
|
||||||
'quantity',
|
|
||||||
'value'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return position;
|
return position;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -462,18 +492,21 @@ export class PortfolioController {
|
|||||||
@Get('report')
|
@Get('report')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers('impersonation-id') impersonationId: string
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
||||||
): Promise<PortfolioReport> {
|
): Promise<PortfolioReport> {
|
||||||
|
const report = await this.portfolioService.getReport(impersonationId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
this.request.user.subscription.type === 'Basic'
|
this.request.user.subscription.type === 'Basic'
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
for (const rule in report.rules) {
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
if (report.rules[rule]) {
|
||||||
StatusCodes.FORBIDDEN
|
report.rules[rule] = [];
|
||||||
);
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.portfolioService.getReport(impersonationId);
|
return report;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,14 +2,15 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
@ -22,6 +23,7 @@ import { RulesService } from './rules.service';
|
|||||||
exports: [PortfolioService],
|
exports: [PortfolioService],
|
||||||
imports: [
|
imports: [
|
||||||
AccessModule,
|
AccessModule,
|
||||||
|
ApiModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { Rule } from '@ghostfolio/api/models/rule';
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
|
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -8,7 +9,7 @@ export class RulesService {
|
|||||||
|
|
||||||
public async evaluate<T extends RuleSettings>(
|
public async evaluate<T extends RuleSettings>(
|
||||||
aRules: Rule<T>[],
|
aRules: Rule<T>[],
|
||||||
aUserSettings: { baseCurrency: string }
|
aUserSettings: UserSettings
|
||||||
) {
|
) {
|
||||||
return aRules
|
return aRules
|
||||||
.filter((rule) => {
|
.filter((rule) => {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { CacheModule, Module } from '@nestjs/common';
|
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
import * as redisStore from 'cache-manager-redis-store';
|
import * as redisStore from 'cache-manager-redis-store';
|
||||||
|
|
||||||
import { RedisCacheService } from './redis-cache.service';
|
import { RedisCacheService } from './redis-cache.service';
|
||||||
@ -9,16 +8,18 @@ import { RedisCacheService } from './redis-cache.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
CacheModule.registerAsync({
|
CacheModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigurationModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigurationService],
|
||||||
useFactory: async (configurationService: ConfigurationService) => ({
|
useFactory: async (configurationService: ConfigurationService) => {
|
||||||
host: configurationService.get('REDIS_HOST'),
|
return <CacheManagerOptions>{
|
||||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
host: configurationService.get('REDIS_HOST'),
|
||||||
password: configurationService.get('REDIS_PASSWORD'),
|
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||||
port: configurationService.get('REDIS_PORT'),
|
password: configurationService.get('REDIS_PASSWORD'),
|
||||||
store: redisStore,
|
port: configurationService.get('REDIS_PORT'),
|
||||||
ttl: configurationService.get('CACHE_TTL')
|
store: redisStore,
|
||||||
})
|
ttl: configurationService.get('CACHE_TTL')
|
||||||
|
};
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
ConfigurationModule
|
ConfigurationModule
|
||||||
],
|
],
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
||||||
import { Cache } from 'cache-manager';
|
import { Cache } from 'cache-manager';
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
DEFAULT_LANGUAGE_CODE,
|
||||||
@ -21,6 +21,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
@ -62,6 +63,7 @@ export class SubscriptionController {
|
|||||||
|
|
||||||
await this.subscriptionService.createSubscription({
|
await this.subscriptionService.createSubscription({
|
||||||
duration: coupon.duration,
|
duration: coupon.duration,
|
||||||
|
price: 0,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -86,9 +88,12 @@ export class SubscriptionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('stripe/callback')
|
@Get('stripe/callback')
|
||||||
public async stripeCallback(@Req() req, @Res() res) {
|
public async stripeCallback(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res() response: Response
|
||||||
|
) {
|
||||||
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
||||||
req.query.checkoutSessionId
|
<string>request.query.checkoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
Logger.log(
|
Logger.log(
|
||||||
@ -96,7 +101,7 @@ export class SubscriptionController {
|
|||||||
'SubscriptionController'
|
'SubscriptionController'
|
||||||
);
|
);
|
||||||
|
|
||||||
res.redirect(
|
response.redirect(
|
||||||
`${this.configurationService.get(
|
`${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
||||||
@ -112,7 +117,7 @@ export class SubscriptionController {
|
|||||||
return await this.subscriptionService.createCheckoutSession({
|
return await this.subscriptionService.createCheckoutSession({
|
||||||
couponId,
|
couponId,
|
||||||
priceId,
|
priceId,
|
||||||
userId: this.request.user.id
|
user: this.request.user
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'SubscriptionController');
|
Logger.error(error, 'SubscriptionController');
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user