Compare commits
443 Commits
Author | SHA1 | Date | |
---|---|---|---|
3f8a2b47f9 | |||
e2e4c9be3c | |||
0f7c6ff0fe | |||
703a96f4db | |||
42c0560422 | |||
eb63802d01 | |||
6d9191a46f | |||
6744245d8b | |||
8f64a77a9d | |||
0d5fc7655b | |||
c511ec7e33 | |||
b12349a148 | |||
f7e3a4c727 | |||
5f276469b7 | |||
69e1d92ed3 | |||
ef2849aa6c | |||
c668d7b456 | |||
e23bf62859 | |||
54c5746d21 | |||
7130ac7565 | |||
1851ae137f | |||
6f6ff94979 | |||
7f25066f0f | |||
fc795aaa8c | |||
d0112968e8 | |||
522025ffa0 | |||
27bf662281 | |||
93c27277c6 | |||
5e6adfcef5 | |||
ab691bb27a | |||
8fc5676443 | |||
1fe1e2fe0c | |||
921d38a706 | |||
6161d5e77c | |||
369386f976 | |||
41437636b1 | |||
b21884eb66 | |||
1c5437e1fd | |||
58278ba5e6 | |||
921f3e9807 | |||
75ca125a70 | |||
a1fd4e7a38 | |||
0d5a8eb33e | |||
b088df2fa3 | |||
f45d8f616a | |||
d8300502ce | |||
502d51ad29 | |||
bc33e5f147 | |||
48ba8f936b | |||
05ec4cce05 | |||
d74f283707 | |||
0f8bc7db32 | |||
431500f28a | |||
9672de174e | |||
c6aa06b933 | |||
1f46a6b6f3 | |||
1bed940bc0 | |||
f9eb3cc3c5 | |||
2519c3ffb0 | |||
91013d1d10 | |||
6deefb9c43 | |||
d0744e07df | |||
93e1ee3ba7 | |||
dceaa55a6c | |||
8b4d55925d | |||
754b49e50f | |||
6ccbda8169 | |||
b0fb986208 | |||
0b59fc639d | |||
7ddd6f27b5 | |||
c5d56f4b47 | |||
2f2b712999 | |||
c2fd31f5e5 | |||
f2d70f9070 | |||
f41dd9cd8e | |||
7d238b4935 | |||
da6591fca0 | |||
1f9b9e9998 | |||
49c4ea306d | |||
ccb5c664ef | |||
97e165ff69 | |||
45aefb6a45 | |||
2435535975 | |||
bd3d43bf05 | |||
02dc7c52b1 | |||
ff59fd4196 | |||
4955555ddd | |||
a98c788a26 | |||
9c16af81c7 | |||
2df27100f0 | |||
6cf6538719 | |||
0fd3db3228 | |||
18835149e2 | |||
6c9779fb0d | |||
3e98f097ef | |||
183ac8fa2b | |||
9036f53e7d | |||
f7c04e469a | |||
b5f01c0d15 | |||
5a23cd34ad | |||
6e87f34c6f | |||
6618aa2e9b | |||
0d25a96f7e | |||
4f6d9d3a76 | |||
928f6f0c45 | |||
09e95ddcee | |||
2d003225bc | |||
de93cabd69 | |||
51489cca81 | |||
f7f4c3afb1 | |||
0821086e41 | |||
7a905fde63 | |||
d2882b1119 | |||
3a500598c5 | |||
42274917e0 | |||
8ba50f2729 | |||
f22071f061 | |||
d2312371a6 | |||
ba837c3c30 | |||
d85d83a0f5 | |||
62e8594c57 | |||
509f95ea30 | |||
43d0b55004 | |||
c0f130a077 | |||
90dc34380e | |||
286e41eb21 | |||
4973d0261d | |||
c4a62dfd68 | |||
4d6be0a507 | |||
b259ab7b0c | |||
e1ac5245c7 | |||
d4fea075af | |||
cef7fa79de | |||
ca05397dcd | |||
2a11977001 | |||
fb1a5c93ef | |||
77e9791e03 | |||
efd9e7a5c7 | |||
d9ced885e1 | |||
5fe07cb85f | |||
af008aa74f | |||
ca7bf27c20 | |||
0866587cab | |||
622bb8b0cf | |||
16b9fbe00e | |||
c9353d0a39 | |||
ea101dd3bd | |||
cd67ce82fa | |||
d5b3c52602 | |||
bdf72164b1 | |||
455a2d2e92 | |||
9c0f46b587 | |||
8533606177 | |||
6728e04ff7 | |||
2bf4f1237a | |||
4857b2e620 | |||
68a9a7f6f9 | |||
81ef95e13e | |||
b633132757 | |||
2b0f961370 | |||
30f1a3514a | |||
ed735e0b29 | |||
b89ccd2dde | |||
df6d39377f | |||
d5d14497d6 | |||
09c300661a | |||
92382e0b4d | |||
c25f532487 | |||
5d26d94586 | |||
73b6784e9f | |||
6159f48a62 | |||
7d34fba7c1 | |||
c434b730a8 | |||
2d23c566f1 | |||
ba220eaee9 | |||
09023214ce | |||
1ceabb6e6b | |||
421072c7fa | |||
0d421e7181 | |||
f5180ce88f | |||
aabf27dc96 | |||
421809ae95 | |||
d3234f9e77 | |||
a40be2f744 | |||
e62da06c5c | |||
b7f635bdfc | |||
0a465f125d | |||
c02e390bc1 | |||
f9bec0d793 | |||
2f44748f79 | |||
97504756be | |||
6a802a62a0 | |||
51ca26bb4d | |||
2ecc8dbc4e | |||
c0e0e2401e | |||
1a30c180bc | |||
39d4f80f36 | |||
3693091ad6 | |||
bf52f1137d | |||
54ea6c84b4 | |||
689e50ae1a | |||
677757fdf0 | |||
58d9816f01 | |||
5f3d445f1d | |||
fce6caebc2 | |||
d0a4f5c000 | |||
b5e2a3aa91 | |||
f47883fb0b | |||
2932744a68 | |||
73c0f02e06 | |||
382fe24f29 | |||
908876ca6e | |||
99cf9f8802 | |||
7444ff97fc | |||
834a48466e | |||
a9526430c2 | |||
fce3b2084e | |||
f5a50a95de | |||
06dfb91f82 | |||
be36050d76 | |||
7931e6950d | |||
04eb452e04 | |||
6f7e370fca | |||
b4a126280f | |||
2d009aacc4 | |||
9116443305 | |||
0adaf12a01 | |||
b6562b6e2c | |||
b0a4b09ef5 | |||
ad8b9ad333 | |||
809956f210 | |||
6077bfa754 | |||
09498bd804 | |||
fd84f4ec14 | |||
c711a11d6e | |||
8232b05f62 | |||
0ea66aebcb | |||
64087de3fc | |||
7082ff12f8 | |||
1c7d92e15e | |||
a53461d257 | |||
d630fb900d | |||
51e8555fa5 | |||
9db675b955 | |||
45bd8ed029 | |||
707fd31550 | |||
6e5f0086a1 | |||
97bcd8ff49 | |||
1809fc8a80 | |||
beb24f9bd4 | |||
ae57a188f5 | |||
23db85e940 | |||
bd8bb1a36a | |||
c48670ccdc | |||
fc019002e2 | |||
4282cb66b8 | |||
1d0ba5fe4b | |||
24cfb26c5b | |||
26a70aa208 | |||
ab7e050066 | |||
26b1fd6572 | |||
d7e682b65a | |||
f589ccb775 | |||
206b6567fd | |||
6857e0314f | |||
c8682a7393 | |||
144b6b2211 | |||
16a5ace4be | |||
b24ddc30c9 | |||
19333ab084 | |||
7529a7a26c | |||
21ebaae6ef | |||
3bc8b3c836 | |||
bb9415cc15 | |||
b3baeb8a5d | |||
1f393e78f6 | |||
215f5eafa6 | |||
1916e5343d | |||
fa9863fc54 | |||
7bf48ef351 | |||
faef3606fd | |||
d0ccd4d238 | |||
51e3650790 | |||
db29e2b666 | |||
655a68a847 | |||
86296b3591 | |||
73c127f10c | |||
cf4c981cd9 | |||
1b9541b933 | |||
5bca8de44e | |||
136c4bf50b | |||
4d700e3b83 | |||
740fa6fc84 | |||
cdb8dc72c7 | |||
4b3afb5c97 | |||
abf208432a | |||
19e6df4fb2 | |||
7fc3fff431 | |||
edd690850c | |||
302339e1cd | |||
739796bc79 | |||
9c30139b86 | |||
0af528b649 | |||
9636c87a2e | |||
ad46fb6d61 | |||
8e000baef2 | |||
a2e1209196 | |||
ef4a75d1f0 | |||
3db20feb54 | |||
b9ec381ea2 | |||
7d6a74a67d | |||
b923cf7752 | |||
e32e457ff8 | |||
32c1e6b390 | |||
b42c0c8355 | |||
7140ed8512 | |||
27d9b075ce | |||
5249257dd8 | |||
606f6159c4 | |||
2e095603b5 | |||
3a99b81ade | |||
577a487301 | |||
086d43376c | |||
31a4c2ff1f | |||
6a1fad611c | |||
e1892d2870 | |||
8ba15f8f72 | |||
876b66f324 | |||
2c5bfb19d3 | |||
1bb94a04e3 | |||
e3c9316486 | |||
c19984c3d0 | |||
9002c20165 | |||
15c96a9757 | |||
1ca3792a4b | |||
90fe467114 | |||
e61b3b34a7 | |||
1326418ffc | |||
a5f0f48ddb | |||
e500ccb61b | |||
4090b03406 | |||
431d1d5fec | |||
d74d79198b | |||
623a284ba4 | |||
f79c36edbb | |||
f4c748f67a | |||
672d8dfab2 | |||
0464adccce | |||
c3df6c3194 | |||
29d53c7df4 | |||
7b77dc044a | |||
67e758365f | |||
475231ffd8 | |||
513a564e2c | |||
cddea0401f | |||
3dafbf7fef | |||
fcd75414be | |||
c1b5bfff8c | |||
3c322cca0d | |||
e965d12e31 | |||
3daf55a0dd | |||
aafedd5f75 | |||
32956ae04c | |||
bfd0241b2d | |||
5eff8402db | |||
ffa020ee2a | |||
80a3668aa9 | |||
7378900050 | |||
9be457943c | |||
93454c6c15 | |||
fccbd76993 | |||
922876a893 | |||
654446f068 | |||
947460abdd | |||
c5635b0050 | |||
8a3a6308a3 | |||
290a07fe79 | |||
4c907d56f0 | |||
56b437ca74 | |||
e23ff33e6f | |||
f2d206262e | |||
1ed5690b33 | |||
4451514ec5 | |||
8f73f85276 | |||
9a5d7b664b | |||
7d2d1d971a | |||
d111493eed | |||
e975f92a96 | |||
739cb4242d | |||
a37eebc9f1 | |||
e92730879e | |||
464973f9b0 | |||
f6228c099f | |||
a57fdfb2bb | |||
24716f0561 | |||
3453100afd | |||
84de2c0c68 | |||
1b7b082003 | |||
1928c2c2cc | |||
52e7a7886d | |||
36298b217e | |||
9bce57894e | |||
9d6bb325cd | |||
a5f833c612 | |||
732b14c6ab | |||
b74a042da8 | |||
d55c052f57 | |||
864f585efa | |||
6d56146054 | |||
2c9f29a3c6 | |||
9bef2e960c | |||
17b8c41673 | |||
f0afbd7346 | |||
5dc7429f6a | |||
7b39b32293 | |||
e5b5a9e7e9 | |||
1f3511368a | |||
b37df2c84f | |||
f92ba54060 | |||
a3bbd4030e | |||
4b30da2d92 | |||
93d082afbb | |||
0c85380dbf | |||
fb576376dc | |||
ff111d4c6c | |||
bc6e9a8b68 | |||
bd1963ec26 | |||
a0bec9e97f | |||
c45df20d88 | |||
fa1d669633 | |||
1009b462e9 | |||
b404858904 | |||
7ec033577f | |||
c8ca82b803 | |||
5db2faa17d | |||
1605fb8d48 | |||
b6a7804a26 | |||
de31381fd9 | |||
0d92b8d8bb | |||
7c6ff776d9 | |||
e37a34ed6c | |||
c4d9c00f92 | |||
3af8be89e3 |
@ -11,6 +11,5 @@ POSTGRES_USER=user
|
|||||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||||
|
|
||||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
ALPHA_VANTAGE_API_KEY=
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?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"]
|
||||||
}
|
}
|
||||||
|
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,7 @@ labels: ''
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
---
|
---
|
||||||
|
|
||||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||||
|
|
||||||
**Bug Description**
|
**Bug Description**
|
||||||
|
|
||||||
@ -36,6 +36,7 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
|||||||
|
|
||||||
<!-- Please complete the following information -->
|
<!-- Please complete the following information -->
|
||||||
|
|
||||||
|
- Cloud or Self-hosted
|
||||||
- Ghostfolio Version X.Y.Z
|
- Ghostfolio Version X.Y.Z
|
||||||
- Browser
|
- Browser
|
||||||
- OS
|
- OS
|
||||||
|
4
.github/workflows/build-code.yml
vendored
4
.github/workflows/build-code.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node_version:
|
node_version:
|
||||||
- 16
|
- 18
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@ -33,4 +33,4 @@ jobs:
|
|||||||
run: yarn test
|
run: yarn test
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: yarn build:all
|
run: yarn build:production
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
],
|
],
|
||||||
"attributeSort": "ASC",
|
"attributeSort": "ASC",
|
||||||
"endOfLine": "auto",
|
"endOfLine": "auto",
|
||||||
|
"plugins": ["prettier-plugin-organize-attributes"],
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
// 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": ["../**/*"]
|
|
||||||
}
|
|
27
.vscode/launch.json
vendored
27
.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/@nrwl/cli/bin/nx",
|
|
||||||
"args": [
|
"args": [
|
||||||
"test",
|
"test",
|
||||||
"--codeCoverage=false",
|
"--codeCoverage=false",
|
||||||
"--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
908
CHANGELOG.md
908
CHANGELOG.md
@ -5,6 +5,893 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 2.5.0 - 2023-09-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for translated activity types in the activities table
|
||||||
|
- Added support for dates in `DD.MM.YYYY` format in the activities import
|
||||||
|
- Set up the language localization for Türkçe (`tr`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Skipped creating queue jobs for asset profiles with `MANUAL` data source on creating a new activity
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the cash position in the holdings table
|
||||||
|
|
||||||
|
## 2.4.0 - 2023-09-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for interest on account level (experimental)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the preselected currency based on the account's currency in the create or edit activity dialog
|
||||||
|
- Unlocked the experimental features setting for all users
|
||||||
|
- Upgraded `prisma` from version `5.2.0` to `5.3.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed a memory leak related to the server's timezone (behind UTC) in the data gathering
|
||||||
|
|
||||||
|
## 2.3.0 - 2023-09-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for fees on account level (experimental)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the export functionality for liabilities
|
||||||
|
|
||||||
|
## 2.2.0 - 2023-09-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced a sidebar navigation on desktop
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the style of the system message
|
||||||
|
- Upgraded _Postgres_ from version `12` to `15` in the `docker-compose` files
|
||||||
|
|
||||||
|
## 2.1.0 - 2023-09-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to drop a file in the import activities dialog
|
||||||
|
- Added a timeout to all data source requests
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Harmonized the style of the user interface for granting and revoking public access to share the portfolio
|
||||||
|
- Removed the account type from the user interface as a preparation to remove it from the `Account` database schema
|
||||||
|
- Improved the logger output of the info service
|
||||||
|
- Harmonized the logger output: `<symbol> (<dataSource>)`
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Improved the language localization for Italian (`it`)
|
||||||
|
- Improved the language localization for Dutch (`nl`)
|
||||||
|
- Improved the read-only mode
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the timeout in _EOD Historical Data_ requests
|
||||||
|
- Fixed an issue with the portfolio summary caused by the language localization for Dutch (`nl`)
|
||||||
|
|
||||||
|
## 2.0.0 - 2023-09-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for the cryptocurrency _CyberConnect_
|
||||||
|
- Added a blog post: _Announcing Ghostfolio 2.0_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Breaking Change**: Removed the deprecated environment variable `BASE_CURRENCY`
|
||||||
|
- Improved the validation in the activities import
|
||||||
|
- Deactivated _Internet Identity_ as a social login provider for the account registration
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Refreshed the cryptocurrencies list
|
||||||
|
- Changed the version in the `docker-compose` files from `3.7` to `3.9`
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.4.4` to `2.5.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the _Yahoo Finance_ data enhancer where countries and sectors have been removed
|
||||||
|
|
||||||
|
## 1.305.0 - 2023-09-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added _Hacker News_ to the _As seen in_ section on the landing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Shortened the page titles
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `prisma` from version `4.16.2` to `5.2.0`
|
||||||
|
- Upgraded `replace-in-file` from version `6.3.5` to `7.0.1`
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.4.3` to `2.4.4`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the alignment in the header navigation
|
||||||
|
- Fixed the alignment in the menu of the impersonation mode
|
||||||
|
|
||||||
|
## 1.304.0 - 2023-08-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added health check endpoints for data enhancers
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `Nx` from version `16.7.2` to `16.7.4`
|
||||||
|
- Upgraded `prettier` from version `2.8.4` to `3.0.2`
|
||||||
|
|
||||||
|
## 1.303.0 - 2023-08-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a blog post: _Ghostfolio joins OSS Friends_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refreshed the cryptocurrencies list
|
||||||
|
- Improved the _OSS Friends_ page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the _Trackinsight_ data enhancer for asset profile data
|
||||||
|
|
||||||
|
## 1.302.0 - 2023-08-20
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `angular` from version `16.1.8` to `16.2.1`
|
||||||
|
- Upgraded `Nx` from version `16.6.0` to `16.7.2`
|
||||||
|
|
||||||
|
## 1.301.1 - 2023-08-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the data export feature to the user account page
|
||||||
|
- Added a currencies preset to the historical market data table of the admin control panel
|
||||||
|
- Added the _OSS Friends_ page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the localized meta data in `html` files
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the rows with cash positions in the holdings table
|
||||||
|
- Fixed an issue with the date parsing in the historical market data editor of the admin control panel
|
||||||
|
|
||||||
|
## 1.300.0 - 2023-08-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added more durations in the coupon system
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Migrated the remaining requests from `bent` to `got`
|
||||||
|
|
||||||
|
## 1.299.1 - 2023-08-10
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Optimized the activities import by allowing a different currency than the asset's official one
|
||||||
|
- Added a timeout to the _EOD Historical Data_ requests
|
||||||
|
- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the editing of the emergency fund
|
||||||
|
- Fixed the historical data gathering interval for asset profiles used as benchmarks having activities
|
||||||
|
|
||||||
|
## 1.298.0 - 2023-08-06
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `ng-extract-i18n-merge` from version `2.6.0` to `2.7.0`
|
||||||
|
- Upgraded `Nx` from version `16.5.5` to `16.6.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the styles of various components (card, progress, tab) after the upgrade to `@angular/material` `16`
|
||||||
|
|
||||||
|
## 1.297.4 - 2023-08-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the footer to the public page
|
||||||
|
- Added a `copy-assets` `Nx` target to the client build
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the alignment of the region percentages on the allocations page
|
||||||
|
- Improved the alignment of the region percentages on the public page
|
||||||
|
- Improved the redirection of the home page to the localized home page
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `angular` from version `15.2.5` to `16.1.8`
|
||||||
|
- Upgraded `nestjs` from version `9.1.4` to `10.1.3`
|
||||||
|
- Upgraded `Nx` from version `16.0.3` to `16.5.5`
|
||||||
|
|
||||||
|
## 1.296.0 - 2023-08-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Optimized the validation in the activities import by reducing the list to unique asset profiles
|
||||||
|
- Optimized the data gathering in the activities import
|
||||||
|
|
||||||
|
## 1.295.0 - 2023-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a step by step introduction for new users
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Removed the _Stay signed in_ setting on _Sign in with fingerprint_ activation
|
||||||
|
|
||||||
|
## 1.294.0 - 2023-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the allocations by market chart on the allocations page by unavailable data
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Considered liabilities in the total account value calculation
|
||||||
|
|
||||||
|
## 1.293.0 - 2023-07-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added error handling for the _Redis_ connections to keep the app running if the connection fails
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Set the `lastmod` dates of `sitemap.xml` dynamically
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the missing values in the holdings table
|
||||||
|
- Fixed the `no such file or directory` error caused by the missing `favicon.ico` file
|
||||||
|
|
||||||
|
## 1.292.0 - 2023-07-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced the allocations by market chart on the allocations page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.4.2` to `2.4.3`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the public page
|
||||||
|
|
||||||
|
## 1.291.0 - 2023-07-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Broken down the emergency fund by cash and assets
|
||||||
|
- Added support for account balance time series
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Renamed queries to presets in the historical market data table of the admin control panel
|
||||||
|
|
||||||
|
## 1.290.0 - 2023-07-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added hints to the activity types in the create or edit activity dialog
|
||||||
|
- Added queries to the historical market data table of the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability of the login dialog
|
||||||
|
- Disabled the caching in the health check endpoints for data providers
|
||||||
|
- Improved the content of the Frequently Asked Questions (FAQ) page
|
||||||
|
- Upgraded `prisma` from version `4.15.0` to `4.16.2`
|
||||||
|
|
||||||
|
## 1.289.0 - 2023-07-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.4.1` to `2.4.2`
|
||||||
|
|
||||||
|
## 1.288.0 - 2023-07-12
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the loading state during filtering on the allocations page
|
||||||
|
- Beautified the names with ampersand (`&`) in the asset profile
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
## 1.287.0 - 2023-07-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Hid the average buy price in the position detail chart if there is no holding
|
||||||
|
- Improved the language localization for French (`fr`)
|
||||||
|
- Refactored the blog articles to standalone components
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the sorting by currency in the activities table
|
||||||
|
|
||||||
|
## 1.286.0 - 2023-07-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the creation of (wealth) items and liabilities
|
||||||
|
|
||||||
|
## 1.285.0 - 2023-07-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a blog post: _Exploring the Path to Financial Independence and Retiring Early (FIRE)_
|
||||||
|
- Added pagination to the historical market data table of the admin control panel
|
||||||
|
- Added the attribute `headers` to the scraper configuration
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the asset profile details dialog in the admin control panel by the scraper configuration
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
## 1.284.0 - 2023-06-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the currency to the cash balance in the create or update account dialog
|
||||||
|
- Added the ability to add an index for benchmarks as an asset profile in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded the _Internet Identity_ dependencies from version `0.15.1` to `0.15.7`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the clone functionality of a transaction caused by the symbol search component
|
||||||
|
|
||||||
|
## 1.283.5 - 2023-06-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the caching for current market prices
|
||||||
|
- Added a loading indicator to the import dividends dialog
|
||||||
|
- Set up the `helmet` middleware to protect the app from web vulnerabilities by setting HTTP headers
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the selected item of the holding selector in the import dividends dialog
|
||||||
|
- Extended the symbol search component by asset sub classes
|
||||||
|
|
||||||
|
## 1.282.0 - 2023-06-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an icon to the external links in the footer navigation
|
||||||
|
- Added the ability to add an asset profile in the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Harmonized the use of permissions on the about page
|
||||||
|
- Harmonized the use of permissions on the landing page
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Improved the language localization for Portuguese (`pt`)
|
||||||
|
- Updated the binary targets of `linux-arm64-openssl` for `prisma`
|
||||||
|
|
||||||
|
## 1.281.0 - 2023-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the feature overview page by liabilities
|
||||||
|
- Set up the language localization for Portuguese (`pt`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extracted the symbol search to a dedicated component
|
||||||
|
- Improved the column headers in the holdings table for mobile
|
||||||
|
- Upgraded `prisma` from version `4.14.1` to `4.15.0`
|
||||||
|
|
||||||
|
## 1.280.1 - 2023-06-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for liabilities
|
||||||
|
|
||||||
|
## 1.279.0 - 2023-06-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Supported a note for accounts
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for French (`fr`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the value nullification related to the investment streaks
|
||||||
|
- Fixed an issue in the public page related to the impersonation service
|
||||||
|
|
||||||
|
## 1.278.0 - 2023-06-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the clone functionality of a transaction by the quantity
|
||||||
|
- Changed the direction of the ellipsis icon in various tables
|
||||||
|
- Extracted the license to a dedicated tab on the about page
|
||||||
|
- Displayed the link to the markets overview in the footer based on a permission
|
||||||
|
- Improved the spacing in the benchmark comparator
|
||||||
|
- Refreshed the cryptocurrencies list
|
||||||
|
- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`)
|
||||||
|
|
||||||
|
## 1.277.0 - 2023-06-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the investment streaks to the analysis page
|
||||||
|
- Added support for a unit in the value component
|
||||||
|
- Added a semantic list structure to the header navigation
|
||||||
|
- Added a default value for the `includeHistoricalData` attribute in the symbol data endpoint
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the date format parsing in the activities import
|
||||||
|
|
||||||
|
## 1.276.0 - 2023-06-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added tabs to the about page
|
||||||
|
- Added the `changefreq` attribute to the sitemap
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the routes of the tabs
|
||||||
|
- Enforced a stricter date format in the activities import: `dd-MM-yyyy` instead of `dd-MM-yy`
|
||||||
|
- Updated the URL of the Ghostfolio Slack channel
|
||||||
|
- Removed the _Ghostfolio in Numbers_ section from the about page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the price when creating a `Subscription`
|
||||||
|
|
||||||
|
## 1.275.0 - 2023-05-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the footer navigation by the localized Ghostfolio versions
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the exchange rate service for a specific date (indirect calculation via base currency) used in activities with a manual currency
|
||||||
|
|
||||||
|
## 1.274.0 - 2023-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the footer by a navigation
|
||||||
|
- Extended the testimonial section on the landing page
|
||||||
|
- Added localized meta descriptions
|
||||||
|
- Added support for localized routes in Spanish (`es`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the activities import dialog
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
## 1.273.0 - 2023-05-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a stepper to the activities import dialog
|
||||||
|
- Added a link to manage the benchmarks to the benchmark comparator
|
||||||
|
- Added support for localized routes
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the data source transformation
|
||||||
|
|
||||||
|
## 1.272.0 - 2023-05-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support to set an asset profile as a benchmark
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Decreased the density of the `@angular/material` tables
|
||||||
|
- Improved the portfolio proportion chart component by supporting case insensitive names
|
||||||
|
- Improved the breadcrumb navigation style in the blog post pages for mobile
|
||||||
|
- Improved the error handling in the delete user endpoint
|
||||||
|
- Improved the style of the _Changelog & License_ button on the about page
|
||||||
|
- Upgraded `ionicons` from version `6.1.2` to `7.1.0`
|
||||||
|
|
||||||
|
## 1.271.0 - 2023-05-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the historical data and search functionality for the `FINANCIAL_MODELING_PREP` data source type
|
||||||
|
- Added a blog post: _Unlock your Financial Potential with Ghostfolio_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the local number formatting in the value component
|
||||||
|
- Changed the uptime to the last 90 days on the _Open Startup_ (`/open`) page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the vertical alignment in the toggle component
|
||||||
|
|
||||||
|
## 1.270.1 - 2023-05-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the cash balance and the value of equity to the account detail dialog
|
||||||
|
- Added a check for duplicates to the preview step of the import dividends dialog
|
||||||
|
- Added an error message for duplicates to the preview step of the activities import
|
||||||
|
- Added a connection timeout to the environment variable `DATABASE_URL`
|
||||||
|
- Introduced the _Open Startup_ (`/open`) page with aggregated key metrics including uptime
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the mobile layout of the portfolio summary tab on the home page
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `prisma` from version `4.13.0` to `4.14.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the _Select all_ activities checkbox state after importing activities including a duplicate
|
||||||
|
- Fixed an issue with the data source transformation in the import dividends dialog
|
||||||
|
- Fixed the _Storybook_ setup
|
||||||
|
|
||||||
|
## 1.269.0 - 2023-05-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `FINANCIAL_MODELING_PREP` as a new data source type
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the market price on the first buy date in the chart of the position detail dialog
|
||||||
|
- Restructured the admin control panel with a new settings tab
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an error that occurred while editing an activity caused by the cash balance update
|
||||||
|
|
||||||
|
## 1.268.0 - 2023-05-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `depends_on` and `healthcheck` for the _Postgres_ and _Redis_ services to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the preview step of the activities import by unchecking duplicates
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.3.10` to `2.4.1`
|
||||||
|
|
||||||
|
## 1.267.0 - 2023-05-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for the _Stripe_ checkout to the pricing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the management of platforms in the admin control panel
|
||||||
|
- Improved the style of the interstitial for the subscription
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Upgraded `Nx` from version `15.9.2` to `16.0.3`
|
||||||
|
|
||||||
|
## 1.266.0 - 2023-05-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced the option to update the cash balance of an account when adding an activity
|
||||||
|
- Added support for the management of platforms in the admin control panel
|
||||||
|
- Added _DEV Community_ to the _As seen in_ section on the landing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `class-transformer` from version `0.3.2` to `0.5.1`
|
||||||
|
- Upgraded `class-validator` from version `0.13.1` to `0.14.0`
|
||||||
|
- Upgraded `prisma` from version `4.12.0` to `4.13.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Added a fallback to use `quoteSummary(symbol)` if `quote(symbols)` fails in the _Yahoo Finance_ service
|
||||||
|
- Added the missing `dataSource` attribute to the activities import
|
||||||
|
|
||||||
|
## 1.265.0 - 2023-05-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the tooltip of the portfolio proportion chart component
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the missing platform name in the allocations by platform chart on the allocations page
|
||||||
|
|
||||||
|
## 1.264.0 - 2023-05-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced the allocations by platform chart on the allocations page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Deprecated the use of the environment variable `BASE_CURRENCY`
|
||||||
|
- Cleaned up initial values from the _X-ray_ section
|
||||||
|
|
||||||
|
## 1.263.0 - 2023-04-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Split the environment variable `DATA_SOURCE_PRIMARY` in `DATA_SOURCE_EXCHANGE_RATES` and `DATA_SOURCE_IMPORT`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the exception on the accounts page
|
||||||
|
|
||||||
|
## 1.262.0 - 2023-04-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the labels to the tabs to increase the usability
|
||||||
|
- Extended the support of the impersonation mode for local development
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the queue jobs implementation by adding / updating historical market data in bulk
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the holdings table by showing the cash position also when the filter contains the accounts, so that we can see the total allocation for that account
|
||||||
|
|
||||||
|
## 1.261.0 - 2023-04-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced a new button to delete all activities from the portfolio activities page
|
||||||
|
- Added `state` to the `MarketData` database schema to distinguish `CLOSE` and `INTRADAY` in the data gathering
|
||||||
|
- Added the distance to now to the subscription expiration date in the users table of the admin control panel
|
||||||
|
|
||||||
|
## 1.260.0 - 2023-04-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `dataSource` as a unique constraint to the `MarketData` database schema
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Removed the unnecessary sort header of the comment column in the historical market data table of the admin control panel
|
||||||
|
|
||||||
|
## 1.259.0 - 2023-04-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a fallback to historical market data if a data provider does not provide live data
|
||||||
|
- Added a general health check endpoint
|
||||||
|
- Added health check endpoints for data providers
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Persisted today's market data continuously
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the alignment of the performance column header in the holdings table
|
||||||
|
- Removed the unnecessary sort header of the comment column in the activities table
|
||||||
|
- Fixed the targets in `proxy.conf.json` from `http://localhost:3333` to `http://0.0.0.0:3333` for local development
|
||||||
|
|
||||||
|
## 1.258.0 - 2023-04-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced a data source mapping
|
||||||
|
|
||||||
|
## 1.257.0 - 2023-04-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Introduced the allocations by ETF provider chart on the allocations page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the global heat map component caused by manipulating an input property
|
||||||
|
- Fixed an issue with the currency inconsistency in the _EOD Historical Data_ service (convert from `GBX` to `GBp`)
|
||||||
|
|
||||||
|
## 1.256.0 - 2023-04-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the _Yahoo Finance_ data enhancer for countries, sectors and urls
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Enabled the configuration to immediately remove queue jobs on complete
|
||||||
|
- Refactored the implementation of removing queue jobs
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the unique job ids of the gather asset profile process
|
||||||
|
- Fixed the style of the button to fetch the current market price
|
||||||
|
|
||||||
|
## 1.255.0 - 2023-04-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Made the system message expandable
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Skipped creating queue jobs for asset profiles with `MANUAL` data source not having a scraper configuration
|
||||||
|
- Reduced the execution interval of the data gathering to every hour
|
||||||
|
- Upgraded `prisma` from version `4.11.0` to `4.12.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the style of the system message
|
||||||
|
|
||||||
|
## 1.254.0 - 2023-04-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the queue jobs implementation by adding in bulk
|
||||||
|
- Improved the queue jobs implementation by introducing unique job ids
|
||||||
|
- Reverted the execution interval of the data gathering from every 12 hours to every 4 hours
|
||||||
|
|
||||||
|
## 1.253.0 - 2023-04-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reduced the execution interval of the data gathering to every 12 hours
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the background color of dialogs in dark mode
|
||||||
|
|
||||||
|
## 1.252.2 - 2023-04-11
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Deprecated the `auth` endpoint of the login with _Security Token_ (`GET`)
|
||||||
|
|
||||||
|
## 1.252.1 - 2023-04-10
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the slide toggles to checkboxes on the user account page
|
||||||
|
- Changed the slide toggles to checkboxes in the admin control panel
|
||||||
|
- Increased the density of the theme
|
||||||
|
- Migrated the style of various components to `@angular/material` `15` (mdc)
|
||||||
|
- Upgraded `@angular/cdk` and `@angular/material` from version `15.2.5` to `15.2.6`
|
||||||
|
- Upgraded `bull` from version `4.10.2` to `4.10.4`
|
||||||
|
|
||||||
|
## 1.251.0 - 2023-04-07
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the activities import for `csv` files exported by _Interactive Brokers_
|
||||||
|
- Improved the rendering of the chart ticks (`0.5K` → `500`)
|
||||||
|
- Increased the historical market data gathering of currency pairs to 10+ years
|
||||||
|
- Improved the content of the Frequently Asked Questions (FAQ) page
|
||||||
|
- Improved the content of the pricing page
|
||||||
|
- Changed the `auth` endpoint of the login with _Security Token_ from `GET` to `POST`
|
||||||
|
- Changed the `auth` endpoint of the _Internet Identity_ login provider from `GET` to `POST`
|
||||||
|
- Migrated the style of the `libs` components to `@angular/material` `15` (mdc)
|
||||||
|
- `ActivitiesFilterComponent`
|
||||||
|
- `ActivitiesTableComponent`
|
||||||
|
- `BenchmarkComponent`
|
||||||
|
- `HoldingsTableComponent`
|
||||||
|
- Upgraded `angular` from version `15.1.5` to `15.2.5`
|
||||||
|
- Upgraded `Nx` from version `15.7.2` to `15.9.2`
|
||||||
|
|
||||||
|
## 1.250.0 - 2023-04-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for multiple subscription offers
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the portfolio evolution chart (ignore first item)
|
||||||
|
- Improved the accounts import by handling the platform
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with more than 50 activities in the activities import (`dryRun`)
|
||||||
|
|
||||||
|
## 1.249.0 - 2023-03-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the testimonial section on the landing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the loading state of the value component on the allocations page
|
||||||
|
- Improved the value component by always showing the label (also while loading)
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the algebraic sign in the value component
|
||||||
|
|
||||||
|
## 1.248.0 - 2023-03-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a blog post: _Ghostfolio reaches 1’000 Stars on GitHub_
|
||||||
|
- Added a breadcrumb navigation to the blog post pages
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored the calculation of the chart
|
||||||
|
- Hid the platform selector if no platforms are available in the create or update account dialog
|
||||||
|
- Upgraded `ng-extract-i18n-merge` from version `2.5.0` to `2.6.0`
|
||||||
|
|
||||||
|
## 1.247.0 - 2023-03-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the asset and asset sub class to the search functionality
|
||||||
|
- Added the subscription expiration date to the users table of the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated the URL of the Ghostfolio Slack channel
|
||||||
|
- Upgraded `prisma` from version `4.10.1` to `4.11.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the total amount calculation in the portfolio evolution chart
|
||||||
|
|
||||||
|
## 1.246.0 - 2023-03-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for asset and asset sub class to the `EOD_HISTORICAL_DATA` data source type
|
||||||
|
- Added `isin` to the asset profile model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the _Trackinsight_ data enhancer for asset profile data by `isin`
|
||||||
|
- Improved the language localization for _Gather Data_
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the border color in the _FIRE_ calculator (dark mode)
|
||||||
|
|
||||||
|
## 1.245.0 - 2023-03-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the search functionality for the `EOD_HISTORICAL_DATA` data source type
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability of the _FIRE_ calculator
|
||||||
|
- Improved the exchange rate service for a specific date used in activities with a manual currency
|
||||||
|
- Upgraded `ngx-device-detector` from version `3.0.0` to `5.0.1`
|
||||||
|
|
||||||
## 1.244.0 - 2023-03-09
|
## 1.244.0 - 2023-03-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -166,7 +1053,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added support to export accounts
|
- Added support to export accounts
|
||||||
- Added suport to import accounts
|
- Added support to import accounts
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@ -313,7 +1200,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Added support for the dividend timeline grouped by year
|
- Added support for the dividend timeline grouped by year
|
||||||
- Added support for the investment timeline grouped by year
|
- Added support for the investment timeline grouped by year
|
||||||
- Set up the language localization for Français (`fr`)
|
- Set up the language localization for Français (`fr`)
|
||||||
- Set up the language localization for Português (`pt`)
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@ -454,7 +1340,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the language selector on the account page
|
- Improved the language selector on the user account page
|
||||||
- Improved the wording in the _X-ray_ section (net worth instead of investment)
|
- Improved the wording in the _X-ray_ section (net worth instead of investment)
|
||||||
- Extended the asset profile details dialog in the admin control panel
|
- Extended the asset profile details dialog in the admin control panel
|
||||||
- Updated the browserslist database
|
- Updated the browserslist database
|
||||||
@ -593,7 +1479,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- Added support to change the appearance (dark mode) in user settings
|
- Added support to change the appearance (dark mode) in user settings
|
||||||
- Added the total amount chart to the investment timeline
|
- Added the total amount chart to the investment timeline
|
||||||
- Setup the `prettier` plugin `prettier-plugin-organize-attributes`
|
- Set up the `prettier` plugin `prettier-plugin-organize-attributes`
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@ -699,7 +1585,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Set up the language localization for Italiano (`it`)
|
- Set up the language localization for Italian (`it`)
|
||||||
- Extended the landing page
|
- Extended the landing page
|
||||||
|
|
||||||
## 1.195.0 - 20.09.2022
|
## 1.195.0 - 20.09.2022
|
||||||
@ -872,7 +1758,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added a language selector to the account page
|
- Added a language selector to the user account page
|
||||||
- Added support for translated labels in the value component
|
- Added support for translated labels in the value component
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@ -1201,7 +2087,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added the user id to the account page
|
- Added the user id to the user account page
|
||||||
- Added a new view with jobs of the queue to the admin control panel
|
- Added a new view with jobs of the queue to the admin control panel
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@ -2122,7 +3008,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- Supported the management of additional currencies in the admin control panel
|
- Supported the management of additional currencies in the admin control panel
|
||||||
- Introduced the system message
|
- Introduced the system message
|
||||||
- Introduced the read only mode
|
- Introduced the read-only mode
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@ -2856,7 +3742,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Respected the cash balance on the analysis page
|
- Respected the cash balance on the analysis page
|
||||||
- Improved the settings selectors on the account page
|
- Improved the settings selectors on the user account page
|
||||||
- Harmonized the slogan to "Open Source Wealth Management Software"
|
- Harmonized the slogan to "Open Source Wealth Management Software"
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@ -3322,7 +4208,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added a gradient to the line charts
|
- Added a gradient to the line charts
|
||||||
- Added a selector to set the base currency on the account page
|
- Added a selector to set the base currency on the user account page
|
||||||
|
|
||||||
## 0.81.0 - 06.04.2021
|
## 0.81.0 - 06.04.2021
|
||||||
|
|
||||||
@ -3636,7 +4522,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Added the membership status to the account page
|
- Added the membership status to the user account page
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
@ -18,7 +18,13 @@
|
|||||||
|
|
||||||
### Prisma
|
### Prisma
|
||||||
|
|
||||||
#### Create schema migration (local)
|
#### 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`
|
Run `yarn prisma migrate dev --name added_job_title`
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
FROM --platform=$BUILDPLATFORM node:16-slim as builder
|
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||||
|
|
||||||
# Build application and add additional files
|
# Build application and add additional files
|
||||||
WORKDIR /ghostfolio
|
WORKDIR /ghostfolio
|
||||||
@ -33,7 +33,7 @@ COPY ./tsconfig.base.json tsconfig.base.json
|
|||||||
COPY ./libs libs
|
COPY ./libs libs
|
||||||
COPY ./apps apps
|
COPY ./apps apps
|
||||||
|
|
||||||
RUN yarn build:all
|
RUN yarn build:production
|
||||||
|
|
||||||
# Prepare the dist image with additional node_modules
|
# Prepare the dist image with additional node_modules
|
||||||
WORKDIR /ghostfolio/dist/apps/api
|
WORKDIR /ghostfolio/dist/apps/api
|
||||||
@ -50,7 +50,7 @@ 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-slim
|
FROM node:18-slim
|
||||||
RUN apt update && apt install -y \
|
RUN apt update && apt install -y \
|
||||||
openssl \
|
openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@ -58,4 +58,4 @@ RUN apt update && apt install -y \
|
|||||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
WORKDIR /ghostfolio/apps/api
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE ${PORT:-3333}
|
EXPOSE ${PORT:-3333}
|
||||||
CMD [ "yarn", "start:prod" ]
|
CMD [ "yarn", "start:production" ]
|
||||||
|
24
README.md
24
README.md
@ -13,6 +13,8 @@
|
|||||||
[](#contributing)
|
[](#contributing)
|
||||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
|
|
||||||
|
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||||
@ -25,7 +27,7 @@
|
|||||||
|
|
||||||
## Ghostfolio Premium
|
## Ghostfolio Premium
|
||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
||||||
|
|
||||||
@ -136,16 +138,16 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.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. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||||
At each start, the container will automatically apply the database schema migrations if needed.
|
At each start, the container will automatically apply the database schema migrations if needed.
|
||||||
|
|
||||||
### Run with _Unraid_ (Community)
|
### Home Server Systems (Community)
|
||||||
|
|
||||||
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
|
Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### 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)
|
||||||
- Create 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`)
|
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||||
@ -153,7 +155,6 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
### 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
|
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))
|
||||||
@ -200,7 +201,9 @@ Set the header for each request as follows:
|
|||||||
"Authorization": "Bearer eyJh..."
|
"Authorization": "Bearer eyJh..."
|
||||||
```
|
```
|
||||||
|
|
||||||
You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
|
||||||
|
|
||||||
|
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||||
|
|
||||||
### Import Activities
|
### Import Activities
|
||||||
|
|
||||||
@ -230,6 +233,7 @@ You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonym
|
|||||||
| 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` |
|
||||||
@ -260,18 +264,20 @@ You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonym
|
|||||||
|
|
||||||
## Community Projects
|
## Community Projects
|
||||||
|
|
||||||
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
|
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
|
||||||
|
|
||||||
|
Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ repository to get listed as well. [Learn more →](https://docs.github.com/en/articles/classifying-your-repository-with-topics)
|
||||||
|
|
||||||
## Contributing
|
## 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) or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
|
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
|
||||||
|
|
||||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2023 [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).
|
||||||
|
@ -2,13 +2,14 @@
|
|||||||
export default {
|
export default {
|
||||||
displayName: 'api',
|
displayName: 'api',
|
||||||
|
|
||||||
globals: {
|
globals: {},
|
||||||
'ts-jest': {
|
|
||||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.[tj]s$': 'ts-jest'
|
'^.+\\.[tj]s$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
"outputs": ["{options.outputPath}"]
|
"outputs": ["{options.outputPath}"]
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"executor": "@nrwl/node:node",
|
"executor": "@nx/js:node",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "api:build"
|
"buildTarget": "api:build"
|
||||||
}
|
}
|
||||||
@ -45,7 +45,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"executor": "@nrwl/jest:jest",
|
"executor": "@nx/jest:jest",
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "apps/api/jest.config.ts",
|
"jestConfig": "apps/api/jest.config.ts",
|
||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
|
@ -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,6 +1,6 @@
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -87,10 +87,7 @@ export class AccountController {
|
|||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) 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
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.portfolioService.getAccountsWithAggregations({
|
return this.portfolioService.getAccountsWithAggregations({
|
||||||
userId: impersonationUserId || this.request.user.id,
|
userId: impersonationUserId || this.request.user.id,
|
||||||
@ -106,10 +103,7 @@ export class AccountController {
|
|||||||
@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
|
|
||||||
);
|
|
||||||
|
|
||||||
const accountsWithAggregations =
|
const accountsWithAggregations =
|
||||||
await this.portfolioService.getAccountsWithAggregations({
|
await this.portfolioService.getAccountsWithAggregations({
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
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 { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { 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';
|
||||||
@ -15,6 +16,7 @@ import { AccountService } from './account.service';
|
|||||||
controllers: [AccountController],
|
controllers: [AccountController],
|
||||||
exports: [AccountService],
|
exports: [AccountService],
|
||||||
imports: [
|
imports: [
|
||||||
|
AccountBalanceModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { 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';
|
||||||
@ -11,16 +12,21 @@ import { CashDetails } from './interfaces/cash-details.interface';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccountService {
|
export class AccountService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async account(
|
public async account({
|
||||||
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
|
id_userId
|
||||||
): Promise<Account | null> {
|
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
||||||
return this.prismaService.account.findUnique({
|
const { id, userId } = id_userId;
|
||||||
where: accountWhereUniqueInput
|
|
||||||
|
const [account] = await this.accounts({
|
||||||
|
where: { id, userId }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async accountWithOrders(
|
public async accountWithOrders(
|
||||||
@ -50,9 +56,11 @@ export class AccountService {
|
|||||||
Platform?: Platform;
|
Platform?: Platform;
|
||||||
})[]
|
})[]
|
||||||
> {
|
> {
|
||||||
const { include, skip, take, cursor, where, orderBy } = params;
|
const { include = {}, skip, take, cursor, where, orderBy } = params;
|
||||||
|
|
||||||
return this.prismaService.account.findMany({
|
include.balances = { orderBy: { date: 'desc' }, take: 1 };
|
||||||
|
|
||||||
|
const accounts = await this.prismaService.account.findMany({
|
||||||
cursor,
|
cursor,
|
||||||
include,
|
include,
|
||||||
orderBy,
|
orderBy,
|
||||||
@ -60,15 +68,36 @@ export class AccountService {
|
|||||||
take,
|
take,
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return accounts.map((account) => {
|
||||||
|
account = { ...account, balance: account.balances[0]?.value ?? 0 };
|
||||||
|
|
||||||
|
delete account.balances;
|
||||||
|
|
||||||
|
return account;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createAccount(
|
public async createAccount(
|
||||||
data: Prisma.AccountCreateInput,
|
data: Prisma.AccountCreateInput,
|
||||||
aUserId: string
|
aUserId: string
|
||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
return this.prismaService.account.create({
|
const account = await this.prismaService.account.create({
|
||||||
data
|
data
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.prismaService.accountBalance.create({
|
||||||
|
data: {
|
||||||
|
Account: {
|
||||||
|
connect: {
|
||||||
|
id_userId: { id: account.id, userId: aUserId }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: data.balance
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteAccount(
|
public async deleteAccount(
|
||||||
@ -167,9 +196,65 @@ export class AccountService {
|
|||||||
aUserId: string
|
aUserId: string
|
||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
const { data, where } = params;
|
const { data, where } = params;
|
||||||
|
|
||||||
|
await this.prismaService.accountBalance.create({
|
||||||
|
data: {
|
||||||
|
Account: {
|
||||||
|
connect: {
|
||||||
|
id_userId: where.id_userId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: <number>data.balance
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return this.prismaService.account.update({
|
return this.prismaService.account.update({
|
||||||
data,
|
data,
|
||||||
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.accountBalanceService.createAccountBalance({
|
||||||
|
date,
|
||||||
|
Account: {
|
||||||
|
connect: {
|
||||||
|
id_userId: {
|
||||||
|
userId,
|
||||||
|
id: accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@ -6,14 +7,23 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
ValidateIf
|
ValidateIf
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
accountType: AccountType;
|
accountType: AccountType;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@ -6,14 +7,23 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
ValidateIf
|
ValidateIf
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
accountType: AccountType;
|
accountType: AccountType;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
@ -13,7 +16,10 @@ import {
|
|||||||
Filter
|
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 {
|
||||||
|
MarketDataPreset,
|
||||||
|
RequestWithUser
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -26,12 +32,13 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import { isDate } from 'date-fns';
|
import { isDate, parseISO } 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';
|
||||||
@ -100,16 +107,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: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
this.dataGatheringService.gatherMax();
|
this.dataGatheringService.gatherMax();
|
||||||
}
|
}
|
||||||
@ -131,16 +143,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: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data/:dataSource/:symbol')
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
@ -161,14 +178,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: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
@ -213,7 +233,7 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
if (!isDate(date)) {
|
if (!isDate(date)) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@ -232,7 +252,12 @@ export class AdminController {
|
|||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getMarketData(
|
public async getMarketData(
|
||||||
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||||
|
@Query('presetId') presetId?: MarketDataPreset,
|
||||||
|
@Query('skip') skip?: number,
|
||||||
|
@Query('sortColumn') sortColumn?: string,
|
||||||
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
|
@Query('take') take?: number
|
||||||
): Promise<AdminMarketData> {
|
): Promise<AdminMarketData> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
@ -257,7 +282,14 @@ export class AdminController {
|
|||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
return this.adminService.getMarketData(filters);
|
return this.adminService.getMarketData({
|
||||||
|
filters,
|
||||||
|
presetId,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
skip: isNaN(skip) ? undefined : skip,
|
||||||
|
take: isNaN(take) ? undefined : take
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@ -301,12 +333,13 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date(dateString);
|
const date = parseISO(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
|
||||||
}
|
}
|
||||||
@ -314,6 +347,28 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async addProfileData(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<SymbolProfile | never> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.addAssetProfile({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('profile-data/:dataSource/:symbol')
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteProfileData(
|
public async deleteProfileData(
|
||||||
|
@ -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,11 +1,17 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { 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 {
|
||||||
|
DEFAULT_CURRENCY,
|
||||||
|
PROPERTY_CURRENCIES,
|
||||||
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
@ -14,25 +20,55 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||||
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
private baseCurrency: string;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {
|
) {}
|
||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
|
||||||
|
public async addAssetProfile({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset): Promise<SymbolProfile | never> {
|
||||||
|
try {
|
||||||
|
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!assetProfiles[symbol]?.currency) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Asset profile not found for ${symbol} (${dataSource})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.symbolProfileService.add(
|
||||||
|
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Asset profile of ${symbol} (${dataSource}) already exists`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
@ -45,15 +81,15 @@ export class AdminService {
|
|||||||
exchangeRates: this.exchangeRateDataService
|
exchangeRates: this.exchangeRateDataService
|
||||||
.getCurrencies()
|
.getCurrencies()
|
||||||
.filter((currency) => {
|
.filter((currency) => {
|
||||||
return currency !== this.baseCurrency;
|
return currency !== DEFAULT_CURRENCY;
|
||||||
})
|
})
|
||||||
.map((currency) => {
|
.map((currency) => {
|
||||||
return {
|
return {
|
||||||
label1: this.baseCurrency,
|
label1: DEFAULT_CURRENCY,
|
||||||
label2: currency,
|
label2: currency,
|
||||||
value: this.exchangeRateDataService.toCurrency(
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
1,
|
1,
|
||||||
this.baseCurrency,
|
DEFAULT_CURRENCY,
|
||||||
currency
|
currency
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@ -65,9 +101,34 @@ export class AdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
|
public async getMarketData({
|
||||||
|
filters,
|
||||||
|
presetId,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
skip,
|
||||||
|
take = Number.MAX_SAFE_INTEGER
|
||||||
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
|
presetId?: MarketDataPreset;
|
||||||
|
skip?: number;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: Prisma.SortOrder;
|
||||||
|
take?: number;
|
||||||
|
}): Promise<AdminMarketData> {
|
||||||
|
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
|
||||||
|
[{ symbol: 'asc' }];
|
||||||
const where: Prisma.SymbolProfileWhereInput = {};
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
|
if (presetId === 'CURRENCIES') {
|
||||||
|
return this.getMarketDataForCurrencies();
|
||||||
|
} else if (
|
||||||
|
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
||||||
|
presetId === 'ETF_WITHOUT_SECTORS'
|
||||||
|
) {
|
||||||
|
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
||||||
|
}
|
||||||
|
|
||||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||||
filters,
|
filters,
|
||||||
(filter) => {
|
(filter) => {
|
||||||
@ -75,41 +136,33 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const marketData = await this.prismaService.marketData.groupBy({
|
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['dataSource', 'symbol']
|
by: ['dataSource', 'symbol']
|
||||||
});
|
});
|
||||||
|
|
||||||
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
|
||||||
|
|
||||||
if (filtersByAssetSubClass) {
|
if (filtersByAssetSubClass) {
|
||||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||||
} else {
|
|
||||||
currencyPairsToGather = this.exchangeRateDataService
|
|
||||||
.getCurrencyPairs()
|
|
||||||
.map(({ dataSource, symbol }) => {
|
|
||||||
const marketDataItemCount =
|
|
||||||
marketData.find((marketDataItem) => {
|
|
||||||
return (
|
|
||||||
marketDataItem.dataSource === dataSource &&
|
|
||||||
marketDataItem.symbol === symbol
|
|
||||||
);
|
|
||||||
})?._count ?? 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
dataSource,
|
|
||||||
marketDataItemCount,
|
|
||||||
symbol,
|
|
||||||
countriesCount: 0,
|
|
||||||
sectorsCount: 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
if (sortColumn) {
|
||||||
await this.prismaService.symbolProfile.findMany({
|
orderBy = [{ [sortColumn]: sortDirection }];
|
||||||
|
|
||||||
|
if (sortColumn === 'activitiesCount') {
|
||||||
|
orderBy = {
|
||||||
|
Order: {
|
||||||
|
_count: sortDirection
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let [assetProfiles, count] = await Promise.all([
|
||||||
|
this.prismaService.symbolProfile.findMany({
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
where,
|
where,
|
||||||
orderBy: [{ symbol: 'asc' }],
|
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { Order: true }
|
select: { Order: true }
|
||||||
@ -128,38 +181,64 @@ export class AdminService {
|
|||||||
sectors: true,
|
sectors: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
).map((symbolProfile) => {
|
this.prismaService.symbolProfile.count({ where })
|
||||||
const countriesCount = symbolProfile.countries
|
]);
|
||||||
? Object.keys(symbolProfile.countries).length
|
|
||||||
: 0;
|
|
||||||
const marketDataItemCount =
|
|
||||||
marketData.find((marketDataItem) => {
|
|
||||||
return (
|
|
||||||
marketDataItem.dataSource === symbolProfile.dataSource &&
|
|
||||||
marketDataItem.symbol === symbolProfile.symbol
|
|
||||||
);
|
|
||||||
})?._count ?? 0;
|
|
||||||
const sectorsCount = symbolProfile.sectors
|
|
||||||
? Object.keys(symbolProfile.sectors).length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
let marketData = assetProfiles.map(
|
||||||
countriesCount,
|
({
|
||||||
marketDataItemCount,
|
_count,
|
||||||
sectorsCount,
|
assetClass,
|
||||||
activitiesCount: symbolProfile._count.Order,
|
assetSubClass,
|
||||||
assetClass: symbolProfile.assetClass,
|
comment,
|
||||||
assetSubClass: symbolProfile.assetSubClass,
|
countries,
|
||||||
comment: symbolProfile.comment,
|
dataSource,
|
||||||
dataSource: symbolProfile.dataSource,
|
Order,
|
||||||
date: symbolProfile.Order?.[0]?.date,
|
sectors,
|
||||||
symbol: symbolProfile.symbol
|
symbol
|
||||||
};
|
}) => {
|
||||||
});
|
const countriesCount = countries ? Object.keys(countries).length : 0;
|
||||||
|
const marketDataItemCount =
|
||||||
|
marketDataItems.find((marketDataItem) => {
|
||||||
|
return (
|
||||||
|
marketDataItem.dataSource === dataSource &&
|
||||||
|
marketDataItem.symbol === symbol
|
||||||
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
comment,
|
||||||
|
countriesCount,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
marketDataItemCount,
|
||||||
|
sectorsCount,
|
||||||
|
activitiesCount: _count.Order,
|
||||||
|
date: Order?.[0]?.date
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (presetId) {
|
||||||
|
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
|
||||||
|
marketData = marketData.filter(({ countriesCount }) => {
|
||||||
|
return countriesCount === 0;
|
||||||
|
});
|
||||||
|
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
|
||||||
|
marketData = marketData.filter(({ sectorsCount }) => {
|
||||||
|
return sectorsCount === 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
count = marketData.length;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
count,
|
||||||
|
marketData
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,20 +265,25 @@ export class AdminService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
assetProfile,
|
marketData,
|
||||||
marketData
|
assetProfile: assetProfile ?? {
|
||||||
|
symbol,
|
||||||
|
currency: '-'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async patchAssetProfileData({
|
public async patchAssetProfileData({
|
||||||
comment,
|
comment,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
await this.symbolProfileService.updateSymbolProfile({
|
await this.symbolProfileService.updateSymbolProfile({
|
||||||
comment,
|
comment,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
});
|
});
|
||||||
@ -223,13 +307,45 @@ export class AdminService {
|
|||||||
response = await this.propertyService.delete({ key });
|
response = await this.propertyService.delete({ key });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === PROPERTY_CURRENCIES) {
|
if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') {
|
||||||
|
await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false');
|
||||||
|
} else if (key === PROPERTY_CURRENCIES) {
|
||||||
await this.exchangeRateDataService.initialize();
|
await this.exchangeRateDataService.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
||||||
|
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||||
|
_count: true,
|
||||||
|
by: ['dataSource', 'symbol']
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService
|
||||||
|
.getCurrencyPairs()
|
||||||
|
.map(({ dataSource, symbol }) => {
|
||||||
|
const marketDataItemCount =
|
||||||
|
marketDataItems.find((marketDataItem) => {
|
||||||
|
return (
|
||||||
|
marketDataItem.dataSource === dataSource &&
|
||||||
|
marketDataItem.symbol === symbol
|
||||||
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
marketDataItemCount,
|
||||||
|
symbol,
|
||||||
|
assetClass: 'CASH',
|
||||||
|
countriesCount: 0,
|
||||||
|
sectorsCount: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { marketData, count: marketData.length };
|
||||||
|
}
|
||||||
|
|
||||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||||
let orderBy: any = {
|
let orderBy: any = {
|
||||||
createdAt: 'desc'
|
createdAt: 'desc'
|
||||||
|
@ -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 {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { IsObject, IsOptional, IsString } from 'class-validator';
|
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAssetProfileDto {
|
export class UpdateAssetProfileDto {
|
||||||
@ -5,6 +6,10 @@ export class UpdateAssetProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsOptional()
|
||||||
|
scraperConfiguration?: Prisma.InputJsonObject;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
symbolMapping?: {
|
symbolMapping?: {
|
||||||
|
@ -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,18 +1,23 @@
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||||
|
import {
|
||||||
|
DEFAULT_LANGUAGE_CODE,
|
||||||
|
SUPPORTED_LANGUAGE_CODES
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
|
import { StatusCodes } from 'http-status-codes';
|
||||||
|
|
||||||
import { ConfigurationModule } from '../services/configuration.module';
|
|
||||||
import { CronService } from '../services/cron.service';
|
|
||||||
import { DataGatheringModule } from '../services/data-gathering.module';
|
|
||||||
import { DataProviderModule } from '../services/data-provider/data-provider.module';
|
|
||||||
import { ExchangeRateDataModule } from '../services/exchange-rate-data.module';
|
|
||||||
import { PrismaModule } from '../services/prisma.module';
|
|
||||||
import { TwitterBotModule } from '../services/twitter-bot/twitter-bot.module';
|
|
||||||
import { AccessModule } from './access/access.module';
|
import { AccessModule } from './access/access.module';
|
||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
@ -23,13 +28,15 @@ import { BenchmarkModule } from './benchmark/benchmark.module';
|
|||||||
import { CacheModule } from './cache/cache.module';
|
import { CacheModule } from './cache/cache.module';
|
||||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
import { FrontendMiddleware } from './frontend.middleware';
|
import { HealthModule } from './health/health.module';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { InfoModule } from './info/info.module';
|
import { InfoModule } from './info/info.module';
|
||||||
import { LogoModule } from './logo/logo.module';
|
import { LogoModule } from './logo/logo.module';
|
||||||
import { OrderModule } from './order/order.module';
|
import { OrderModule } from './order/order.module';
|
||||||
|
import { 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 { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||||
|
import { SitemapModule } from './sitemap/sitemap.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';
|
||||||
@ -57,28 +64,41 @@ import { UserModule } from './user/user.module';
|
|||||||
ExchangeRateModule,
|
ExchangeRateModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ExportModule,
|
ExportModule,
|
||||||
|
HealthModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
InfoModule,
|
InfoModule,
|
||||||
LogoModule,
|
LogoModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
|
PlatformModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
serveStaticOptions: {
|
exclude: ['/api*', '/sitemap.xml'],
|
||||||
/*etag: false // Disable etag header to fix PWA
|
|
||||||
setHeaders: (res, path) => {
|
|
||||||
if (path.includes('ngsw.json')) {
|
|
||||||
// Disable cache (https://stackoverflow.com/questions/22632593/how-to-disable-webpage-caching-in-expressjs-nodejs/39775595)
|
|
||||||
// https://gertjans.home.xs4all.nl/javascript/cache-control.html#no-cache
|
|
||||||
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
},
|
|
||||||
rootPath: join(__dirname, '..', 'client'),
|
rootPath: join(__dirname, '..', 'client'),
|
||||||
exclude: ['/api*']
|
serveStaticOptions: {
|
||||||
|
setHeaders: (res) => {
|
||||||
|
if (res.req?.path === '/') {
|
||||||
|
let languageCode = DEFAULT_LANGUAGE_CODE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const code = res.req.headers['accept-language']
|
||||||
|
.split(',')[0]
|
||||||
|
.split('-')[0];
|
||||||
|
|
||||||
|
if (SUPPORTED_LANGUAGE_CODES.includes(code)) {
|
||||||
|
languageCode = code;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
res.set('Location', `/${languageCode}`);
|
||||||
|
res.statusCode = StatusCodes.MOVED_PERMANENTLY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
|
SitemapModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
TwitterBotModule,
|
TwitterBotModule,
|
||||||
@ -87,10 +107,4 @@ import { UserModule } from './user/user.module';
|
|||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [CronService]
|
providers: [CronService]
|
||||||
})
|
})
|
||||||
export class AppModule {
|
export class AppModule {}
|
||||||
configure(consumer: MiddlewareConsumer) {
|
|
||||||
consumer
|
|
||||||
.apply(FrontendMiddleware)
|
|
||||||
.forRoutes({ path: '*', method: RequestMethod.ALL });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -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 {
|
||||||
@ -33,13 +33,32 @@ 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> {
|
||||||
|
try {
|
||||||
|
const authToken =
|
||||||
|
await this.authService.validateAnonymousLogin(accessToken);
|
||||||
|
return { authToken };
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('anonymous')
|
||||||
|
public async accessTokenLogin(
|
||||||
|
@Body() body: { accessToken: string }
|
||||||
): Promise<OAuthResponse> {
|
): Promise<OAuthResponse> {
|
||||||
try {
|
try {
|
||||||
const authToken = await this.authService.validateAnonymousLogin(
|
const authToken = await this.authService.validateAnonymousLogin(
|
||||||
accessToken
|
body.accessToken
|
||||||
);
|
);
|
||||||
return { authToken };
|
return { authToken };
|
||||||
} catch {
|
} catch {
|
||||||
@ -81,13 +100,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,8 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
@ -55,7 +55,7 @@ export class AuthService {
|
|||||||
const isUserSignupEnabled =
|
const isUserSignupEnabled =
|
||||||
await this.propertyService.isUserSignupEnabled();
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
if (!isUserSignupEnabled) {
|
if (!isUserSignupEnabled || true) {
|
||||||
throw new Error('Sign up forbidden');
|
throw new Error('Sign up forbidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,6 +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 { 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 { 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';
|
||||||
|
@ -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,
|
||||||
|
@ -1,24 +1,36 @@
|
|||||||
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 {
|
import type {
|
||||||
BenchmarkMarketDataDetails,
|
BenchmarkMarketDataDetails,
|
||||||
BenchmarkResponse
|
BenchmarkResponse,
|
||||||
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { BenchmarkService } from './benchmark.service';
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
@Controller('benchmark')
|
@Controller('benchmark')
|
||||||
export class BenchmarkController {
|
export class BenchmarkController {
|
||||||
public constructor(private readonly benchmarkService: BenchmarkService) {}
|
public constructor(
|
||||||
|
private readonly benchmarkService: BenchmarkService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@ -45,4 +57,41 @@ export class BenchmarkController {
|
|||||||
symbol
|
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,10 +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 { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
|
import { 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';
|
||||||
@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
|
@ -4,7 +4,15 @@ describe('BenchmarkService', () => {
|
|||||||
let benchmarkService: BenchmarkService;
|
let benchmarkService: BenchmarkService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
|
benchmarkService = new BenchmarkService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculateChangeInPercentage', async () => {
|
it('calculateChangeInPercentage', async () => {
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
|
import { 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 {
|
import {
|
||||||
MAX_CHART_ITEMS,
|
MAX_CHART_ITEMS,
|
||||||
PROPERTY_BENCHMARKS
|
PROPERTY_BENCHMARKS
|
||||||
@ -11,6 +12,7 @@ import {
|
|||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
BenchmarkMarketDataDetails,
|
BenchmarkMarketDataDetails,
|
||||||
|
BenchmarkProperty,
|
||||||
BenchmarkResponse,
|
BenchmarkResponse,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
@ -18,6 +20,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { uniqBy } from 'lodash';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -27,6 +30,7 @@ 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 propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
private readonly symbolProfileService: SymbolProfileService,
|
private readonly symbolProfileService: SymbolProfileService,
|
||||||
@ -62,11 +66,11 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
const promises: Promise<number>[] = [];
|
const promises: Promise<number>[] = [];
|
||||||
|
|
||||||
const quotes = await this.dataProviderService.getQuotes(
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
return { dataSource, symbol };
|
return { dataSource, symbol };
|
||||||
})
|
})
|
||||||
);
|
});
|
||||||
|
|
||||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||||
@ -116,9 +120,9 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||||
const symbolProfileIds: string[] = (
|
const symbolProfileIds: string[] = (
|
||||||
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
|
((await this.propertyService.getByKey(
|
||||||
symbolProfileId: string;
|
PROPERTY_BENCHMARKS
|
||||||
}[]) ?? []
|
)) as BenchmarkProperty[]) ?? []
|
||||||
).map(({ symbolProfileId }) => {
|
).map(({ symbolProfileId }) => {
|
||||||
return symbolProfileId;
|
return symbolProfileId;
|
||||||
});
|
});
|
||||||
@ -204,6 +208,43 @@ export class BenchmarkService {
|
|||||||
return response;
|
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) {
|
private getMarketCondition(aPerformanceInPercent: number) {
|
||||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
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';
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { ExchangeRateService } from './exchange-rate.service';
|
import { ExchangeRateService } from './exchange-rate.service';
|
||||||
@ -23,7 +24,7 @@ export class ExchangeRateController {
|
|||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<IDataProviderHistoricalResponse> {
|
): Promise<IDataProviderHistoricalResponse> {
|
||||||
const date = new Date(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
const exchangeRate = await this.exchangeRateService.getExchangeRate({
|
const exchangeRate = await this.exchangeRateService.getExchangeRate({
|
||||||
date,
|
date,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExchangeRateController } from './exchange-rate.controller';
|
import { ExchangeRateController } from './exchange-rate.controller';
|
||||||
|
@ -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 { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { 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 { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExportController } from './export.controller';
|
import { ExportController } from './export.controller';
|
||||||
@ -10,10 +11,11 @@ import { ExportService } from './export.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
AccountModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
PrismaModule,
|
OrderModule,
|
||||||
RedisCacheModule
|
RedisCacheModule
|
||||||
],
|
],
|
||||||
controllers: [ExportController],
|
controllers: [ExportController],
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|
||||||
import { Export } from '@ghostfolio/common/interfaces';
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(
|
||||||
|
private readonly accountService: AccountService,
|
||||||
|
private readonly orderService: OrderService
|
||||||
|
) {}
|
||||||
|
|
||||||
public async export({
|
public async export({
|
||||||
activityIds,
|
activityIds,
|
||||||
@ -14,35 +18,30 @@ export class ExportService {
|
|||||||
activityIds?: string[];
|
activityIds?: string[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Export> {
|
}): Promise<Export> {
|
||||||
const accounts = await this.prismaService.account.findMany({
|
const accounts = (
|
||||||
orderBy: {
|
await this.accountService.accounts({
|
||||||
name: 'asc'
|
orderBy: {
|
||||||
},
|
name: 'asc'
|
||||||
select: {
|
},
|
||||||
accountType: true,
|
where: { userId }
|
||||||
balance: true,
|
})
|
||||||
currency: true,
|
).map(
|
||||||
id: true,
|
({ balance, comment, currency, id, isExcluded, name, platformId }) => {
|
||||||
isExcluded: true,
|
return {
|
||||||
name: true,
|
balance,
|
||||||
platformId: true
|
comment,
|
||||||
},
|
currency,
|
||||||
where: { userId }
|
id,
|
||||||
});
|
isExcluded,
|
||||||
|
name,
|
||||||
|
platformId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
let activities = await this.prismaService.order.findMany({
|
let activities = await this.orderService.orders({
|
||||||
|
include: { SymbolProfile: true },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
select: {
|
|
||||||
accountId: true,
|
|
||||||
comment: true,
|
|
||||||
date: true,
|
|
||||||
fee: true,
|
|
||||||
id: true,
|
|
||||||
quantity: true,
|
|
||||||
SymbolProfile: true,
|
|
||||||
type: true,
|
|
||||||
unitPrice: true
|
|
||||||
},
|
|
||||||
where: { userId }
|
where: { userId }
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -78,7 +77,13 @@ export class ExportService {
|
|||||||
currency: SymbolProfile.currency,
|
currency: SymbolProfile.currency,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
date: date.toISOString(),
|
date: date.toISOString(),
|
||||||
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
symbol:
|
||||||
|
type === 'FEE' ||
|
||||||
|
type === 'INTEREST' ||
|
||||||
|
type === 'ITEM' ||
|
||||||
|
type === 'LIABILITY'
|
||||||
|
? SymbolProfile.name
|
||||||
|
: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -1,198 +0,0 @@
|
|||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
|
||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { NextFunction, Request, Response } from 'express';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FrontendMiddleware implements NestMiddleware {
|
|
||||||
public indexHtmlDe = '';
|
|
||||||
public indexHtmlEn = '';
|
|
||||||
public indexHtmlEs = '';
|
|
||||||
public indexHtmlFr = '';
|
|
||||||
public indexHtmlIt = '';
|
|
||||||
public indexHtmlNl = '';
|
|
||||||
public indexHtmlPt = '';
|
|
||||||
|
|
||||||
public constructor(
|
|
||||||
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(request: Request, response: Response, next: NextFunction) {
|
|
||||||
const currentDate = format(new Date(), DATE_FORMAT);
|
|
||||||
let featureGraphicPath = 'assets/cover.png';
|
|
||||||
let title = 'Ghostfolio – Open Source Wealth Management Software';
|
|
||||||
|
|
||||||
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
request.path.startsWith('/api/') ||
|
|
||||||
this.isFileRequest(request.url) ||
|
|
||||||
!environment.production
|
|
||||||
) {
|
|
||||||
// Skip
|
|
||||||
next();
|
|
||||||
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
|
|
||||||
response.send(
|
|
||||||
this.interpolate(this.indexHtmlDe, {
|
|
||||||
currentDate,
|
|
||||||
featureGraphicPath,
|
|
||||||
title,
|
|
||||||
languageCode: 'de',
|
|
||||||
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,
|
|
||||||
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, {
|
|
||||||
featureGraphicPath,
|
|
||||||
languageCode: 'fr',
|
|
||||||
path: request.path,
|
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
|
|
||||||
response.send(
|
|
||||||
this.interpolate(this.indexHtmlIt, {
|
|
||||||
currentDate,
|
|
||||||
featureGraphicPath,
|
|
||||||
title,
|
|
||||||
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,
|
|
||||||
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, {
|
|
||||||
featureGraphicPath,
|
|
||||||
languageCode: 'pt',
|
|
||||||
path: request.path,
|
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
response.send(
|
|
||||||
this.interpolate(this.indexHtmlEn, {
|
|
||||||
currentDate,
|
|
||||||
featureGraphicPath,
|
|
||||||
title,
|
|
||||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
|
||||||
path: request.path,
|
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPathOfIndexHtmlFile(aLocale: string) {
|
|
||||||
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
|
|
||||||
}
|
|
||||||
|
|
||||||
private interpolate(template: string, context: any) {
|
|
||||||
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
|
|
||||||
const properties = objectPath.split('.');
|
|
||||||
return properties.reduce(
|
|
||||||
(previous, current) => previous?.[current],
|
|
||||||
context
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private isFileRequest(filename: string) {
|
|
||||||
if (filename === '/assets/LICENSE') {
|
|
||||||
return true;
|
|
||||||
} else if (filename.includes('auth/ey')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return filename.split('.').pop() !== filename;
|
|
||||||
}
|
|
||||||
}
|
|
56
apps/api/src/app/health/health.controller.ts
Normal file
56
apps/api/src/app/health/health.controller.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Param,
|
||||||
|
UseInterceptors
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
public constructor(private readonly healthService: HealthService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
public async getHealth() {}
|
||||||
|
|
||||||
|
@Get('data-enhancer/:name')
|
||||||
|
public async getHealthOfDataEnhancer(@Param('name') name: string) {
|
||||||
|
const hasResponse =
|
||||||
|
await this.healthService.hasResponseFromDataEnhancer(name);
|
||||||
|
|
||||||
|
if (hasResponse !== true) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||||
|
StatusCodes.SERVICE_UNAVAILABLE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('data-provider/:dataSource')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async getHealthOfDataProvider(
|
||||||
|
@Param('dataSource') dataSource: DataSource
|
||||||
|
) {
|
||||||
|
if (!DataSource[dataSource]) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasResponse =
|
||||||
|
await this.healthService.hasResponseFromDataProvider(dataSource);
|
||||||
|
|
||||||
|
if (hasResponse !== true) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||||
|
StatusCodes.SERVICE_UNAVAILABLE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
14
apps/api/src/app/health/health.module.ts
Normal file
14
apps/api/src/app/health/health.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
|
||||||
|
providers: [HealthService]
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
20
apps/api/src/app/health/health.service.ts
Normal file
20
apps/api/src/app/health/health.service.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HealthService {
|
||||||
|
public constructor(
|
||||||
|
private readonly dataEnhancerService: DataEnhancerService,
|
||||||
|
private readonly dataProviderService: DataProviderService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async hasResponseFromDataEnhancer(aName: string) {
|
||||||
|
return this.dataEnhancerService.enhance(aName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async hasResponseFromDataProvider(aDataSource: DataSource) {
|
||||||
|
return this.dataProviderService.checkQuote(aDataSource);
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
@ -35,6 +35,8 @@ export class ImportController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async import(
|
public async import(
|
||||||
@Body() importData: ImportDataDto,
|
@Body() importData: ImportDataDto,
|
||||||
@Query('dryRun') isDryRun?: boolean
|
@Query('dryRun') isDryRun?: boolean
|
||||||
|
@ -1,14 +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 { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { 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 { ImportController } from './import.controller';
|
import { ImportController } from './import.controller';
|
||||||
@ -24,6 +25,7 @@ import { ImportService } from './import.service';
|
|||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
|
PlatformModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
|
@ -1,31 +1,43 @@
|
|||||||
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 { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
import {
|
||||||
|
Activity,
|
||||||
|
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 { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
|
getAssetProfileIdentifier,
|
||||||
|
parseDate
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
AccountWithPlatform,
|
AccountWithPlatform,
|
||||||
OrderWithAccount
|
OrderWithAccount
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { SymbolProfile } from '@prisma/client';
|
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
import { endOfToday, format, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||||
|
import { uniqBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
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 dataGatheringService: DataGatheringService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly orderService: OrderService,
|
private readonly orderService: OrderService,
|
||||||
|
private readonly platformService: PlatformService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
@ -69,8 +81,25 @@ export class ImportService {
|
|||||||
|
|
||||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
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 {
|
return {
|
||||||
Account,
|
Account,
|
||||||
|
error,
|
||||||
quantity,
|
quantity,
|
||||||
value,
|
value,
|
||||||
accountId: Account?.id,
|
accountId: Account?.id,
|
||||||
@ -118,15 +147,18 @@ export class ImportService {
|
|||||||
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||||
|
|
||||||
if (!isDryRun && accountsDto?.length) {
|
if (!isDryRun && accountsDto?.length) {
|
||||||
const existingAccounts = await this.accountService.accounts({
|
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||||
where: {
|
this.accountService.accounts({
|
||||||
id: {
|
where: {
|
||||||
in: accountsDto.map(({ id }) => {
|
id: {
|
||||||
return id;
|
in: accountsDto.map(({ id }) => {
|
||||||
})
|
return id;
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}),
|
||||||
});
|
this.platformService.getPlatforms()
|
||||||
|
]);
|
||||||
|
|
||||||
for (const account of accountsDto) {
|
for (const account of accountsDto) {
|
||||||
// Check if there is any existing account with the same ID
|
// Check if there is any existing account with the same ID
|
||||||
@ -146,19 +178,24 @@ export class ImportService {
|
|||||||
delete account.id;
|
delete account.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newAccountObject = {
|
let accountObject: Prisma.AccountCreateInput = {
|
||||||
...account,
|
...account,
|
||||||
User: { connect: { id: userId } }
|
User: { connect: { id: userId } }
|
||||||
};
|
};
|
||||||
|
|
||||||
if (platformId) {
|
if (
|
||||||
Object.assign(newAccountObject, {
|
existingPlatforms.some(({ id }) => {
|
||||||
|
return id === platformId;
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
accountObject = {
|
||||||
|
...accountObject,
|
||||||
Platform: { connect: { id: platformId } }
|
Platform: { connect: { id: platformId } }
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const newAccount = await this.accountService.createAccount(
|
const newAccount = await this.accountService.createAccount(
|
||||||
newAccountObject,
|
accountObject,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -172,10 +209,11 @@ export class ImportService {
|
|||||||
|
|
||||||
for (const activity of activitiesDto) {
|
for (const activity of activitiesDto) {
|
||||||
if (!activity.dataSource) {
|
if (!activity.dataSource) {
|
||||||
if (activity.type === 'ITEM') {
|
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
|
||||||
activity.dataSource = 'MANUAL';
|
activity.dataSource = DataSource.MANUAL;
|
||||||
} else {
|
} else {
|
||||||
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
activity.dataSource =
|
||||||
|
this.dataProviderService.getDataSourceForImport();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,13 +227,17 @@ export class ImportService {
|
|||||||
|
|
||||||
const assetProfiles = await this.validateActivities({
|
const assetProfiles = await this.validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport
|
||||||
|
});
|
||||||
|
|
||||||
|
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||||
|
activitiesDto,
|
||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
const accounts = (await this.accountService.getAccounts(userId)).map(
|
const accounts = (await this.accountService.getAccounts(userId)).map(
|
||||||
(account) => {
|
({ id, name }) => {
|
||||||
return { id: account.id, name: account.name };
|
return { id, name };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -207,19 +249,47 @@ export class ImportService {
|
|||||||
|
|
||||||
const activities: Activity[] = [];
|
const activities: Activity[] = [];
|
||||||
|
|
||||||
for (const {
|
for (let [
|
||||||
accountId,
|
index,
|
||||||
comment,
|
{
|
||||||
currency,
|
accountId,
|
||||||
dataSource,
|
comment,
|
||||||
date: dateString,
|
date,
|
||||||
fee,
|
error,
|
||||||
quantity,
|
fee,
|
||||||
symbol,
|
quantity,
|
||||||
type,
|
SymbolProfile,
|
||||||
unitPrice
|
type,
|
||||||
} of activitiesDto) {
|
unitPrice
|
||||||
const date = parseISO(<string>(<unknown>dateString));
|
}
|
||||||
|
] of activitiesExtendedWithErrors.entries()) {
|
||||||
|
const assetProfile = assetProfiles[
|
||||||
|
getAssetProfileIdentifier({
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
})
|
||||||
|
] ?? {
|
||||||
|
currency: SymbolProfile.currency,
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
countries,
|
||||||
|
createdAt,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
id,
|
||||||
|
isin,
|
||||||
|
name,
|
||||||
|
scraperConfiguration,
|
||||||
|
sectors,
|
||||||
|
symbol,
|
||||||
|
symbolMapping,
|
||||||
|
url,
|
||||||
|
updatedAt
|
||||||
|
} = assetProfile;
|
||||||
const validatedAccount = accounts.find(({ id }) => {
|
const validatedAccount = accounts.find(({ id }) => {
|
||||||
return id === accountId;
|
return id === accountId;
|
||||||
});
|
});
|
||||||
@ -230,6 +300,35 @@ export class ImportService {
|
|||||||
Account?: { id: string; name: string };
|
Account?: { id: string; name: string };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (SymbolProfile.currency !== assetProfile.currency) {
|
||||||
|
// Convert the unit price and fee to the asset currency if the imported
|
||||||
|
// activity is in a different currency
|
||||||
|
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||||
|
unitPrice,
|
||||||
|
SymbolProfile.currency,
|
||||||
|
assetProfile.currency,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!unitPrice) {
|
||||||
|
throw new Error(
|
||||||
|
`activities.${index} historical exchange rate at ${format(
|
||||||
|
date,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} is not available from "${SymbolProfile.currency}" to "${
|
||||||
|
assetProfile.currency
|
||||||
|
}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fee = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||||
|
fee,
|
||||||
|
SymbolProfile.currency,
|
||||||
|
assetProfile.currency,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isDryRun) {
|
if (isDryRun) {
|
||||||
order = {
|
order = {
|
||||||
comment,
|
comment,
|
||||||
@ -245,28 +344,32 @@ export class ImportService {
|
|||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
isDraft: isAfter(date, endOfToday()),
|
isDraft: isAfter(date, endOfToday()),
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
countries,
|
||||||
|
createdAt,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
id,
|
||||||
|
isin,
|
||||||
|
name,
|
||||||
|
scraperConfiguration,
|
||||||
|
sectors,
|
||||||
symbol,
|
symbol,
|
||||||
assetClass: null,
|
symbolMapping,
|
||||||
assetSubClass: null,
|
updatedAt,
|
||||||
comment: null,
|
url,
|
||||||
countries: null,
|
comment: assetProfile.comment
|
||||||
createdAt: undefined,
|
|
||||||
id: undefined,
|
|
||||||
name: null,
|
|
||||||
scraperConfiguration: null,
|
|
||||||
sectors: null,
|
|
||||||
symbolMapping: null,
|
|
||||||
updatedAt: undefined,
|
|
||||||
url: null,
|
|
||||||
...assetProfiles[symbol]
|
|
||||||
},
|
},
|
||||||
Account: validatedAccount,
|
Account: validatedAccount,
|
||||||
symbolProfileId: undefined,
|
symbolProfileId: undefined,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
if (error) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
order = await this.orderService.createOrder({
|
order = await this.orderService.createOrder({
|
||||||
comment,
|
comment,
|
||||||
date,
|
date,
|
||||||
@ -291,21 +394,24 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
updateAccountBalance: false,
|
||||||
User: { connect: { id: userId } }
|
User: { connect: { id: userId } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||||
|
|
||||||
//@ts-ignore
|
|
||||||
activities.push({
|
activities.push({
|
||||||
...order,
|
...order,
|
||||||
|
error,
|
||||||
value,
|
value,
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
fee,
|
fee,
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
),
|
||||||
|
// @ts-ignore
|
||||||
|
SymbolProfile: assetProfile,
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
value,
|
value,
|
||||||
currency,
|
currency,
|
||||||
@ -314,9 +420,109 @@ export class ImportService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activities.sort((activity1, activity2) => {
|
||||||
|
return Number(activity1.date) - Number(activity2.date);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDryRun) {
|
||||||
|
// Gather symbol data in the background, if not dry run
|
||||||
|
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => {
|
||||||
|
return getAssetProfileIdentifier({
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherSymbols(
|
||||||
|
uniqueActivities.map(({ date, SymbolProfile }) => {
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return activities;
|
return activities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async extendActivitiesWithErrors({
|
||||||
|
activitiesDto,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
|
userId: string;
|
||||||
|
}): Promise<Partial<Activity>[]> {
|
||||||
|
const existingActivities = await this.orderService.orders({
|
||||||
|
include: { SymbolProfile: true },
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
where: { userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return activitiesDto.map(
|
||||||
|
({
|
||||||
|
accountId,
|
||||||
|
comment,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
date: dateString,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
symbol,
|
||||||
|
type,
|
||||||
|
unitPrice
|
||||||
|
}) => {
|
||||||
|
const date = parseISO(<string>(<unknown>dateString));
|
||||||
|
const isDuplicate = existingActivities.some((activity) => {
|
||||||
|
return (
|
||||||
|
activity.SymbolProfile.currency === currency &&
|
||||||
|
activity.SymbolProfile.dataSource === dataSource &&
|
||||||
|
isSameDay(activity.date, date) &&
|
||||||
|
activity.fee === fee &&
|
||||||
|
activity.quantity === quantity &&
|
||||||
|
activity.SymbolProfile.symbol === symbol &&
|
||||||
|
activity.type === type &&
|
||||||
|
activity.unitPrice === unitPrice
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const error: ActivityError = isDuplicate
|
||||||
|
? { code: 'IS_DUPLICATE' }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
comment,
|
||||||
|
date,
|
||||||
|
error,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
type,
|
||||||
|
unitPrice,
|
||||||
|
SymbolProfile: {
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
assetClass: null,
|
||||||
|
assetSubClass: null,
|
||||||
|
comment: null,
|
||||||
|
countries: null,
|
||||||
|
createdAt: undefined,
|
||||||
|
id: undefined,
|
||||||
|
isin: null,
|
||||||
|
name: null,
|
||||||
|
scraperConfiguration: null,
|
||||||
|
sectors: null,
|
||||||
|
symbolMapping: null,
|
||||||
|
updatedAt: undefined,
|
||||||
|
url: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private isUniqueAccount(accounts: AccountWithPlatform[]) {
|
private isUniqueAccount(accounts: AccountWithPlatform[]) {
|
||||||
const uniqueAccountIds = new Set<string>();
|
const uniqueAccountIds = new Set<string>();
|
||||||
|
|
||||||
@ -329,47 +535,30 @@ export class ImportService {
|
|||||||
|
|
||||||
private async validateActivities({
|
private async validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport
|
||||||
userId
|
|
||||||
}: {
|
}: {
|
||||||
activitiesDto: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
maxActivitiesToImport: number;
|
maxActivitiesToImport: number;
|
||||||
userId: string;
|
|
||||||
}) {
|
}) {
|
||||||
if (activitiesDto?.length > maxActivitiesToImport) {
|
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetProfiles: {
|
const assetProfiles: {
|
||||||
[symbol: string]: Partial<SymbolProfile>;
|
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||||
} = {};
|
} = {};
|
||||||
const existingActivities = await this.orderService.orders({
|
|
||||||
include: { SymbolProfile: true },
|
const uniqueActivitiesDto = uniqBy(
|
||||||
orderBy: { date: 'desc' },
|
activitiesDto,
|
||||||
where: { userId }
|
({ dataSource, symbol }) => {
|
||||||
});
|
return getAssetProfileIdentifier({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
{ currency, dataSource, symbol }
|
||||||
] of activitiesDto.entries()) {
|
] of uniqueActivitiesDto.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 assetProfile = (
|
const assetProfile = (
|
||||||
await this.dataProviderService.getAssetProfiles([
|
await this.dataProviderService.getAssetProfiles([
|
||||||
@ -377,19 +566,26 @@ export class ImportService {
|
|||||||
])
|
])
|
||||||
)?.[symbol];
|
)?.[symbol];
|
||||||
|
|
||||||
if (assetProfile === undefined) {
|
if (!assetProfile?.name) {
|
||||||
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 (assetProfile.currency !== currency) {
|
if (
|
||||||
|
assetProfile.currency !== currency &&
|
||||||
|
!this.exchangeRateDataService.hasCurrencyPair(
|
||||||
|
currency,
|
||||||
|
assetProfile.currency
|
||||||
|
)
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"`
|
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
assetProfiles[symbol] = assetProfile;
|
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||||
|
assetProfile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
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 { UserModule } from '@ghostfolio/api/app/user/user.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 { 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';
|
||||||
@ -26,11 +28,12 @@ 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' }
|
||||||
}),
|
}),
|
||||||
PrismaModule,
|
PlatformModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule,
|
SymbolProfileModule,
|
||||||
TagModule
|
TagModule,
|
||||||
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [InfoService]
|
providers: [InfoService]
|
||||||
})
|
})
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
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 { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 {
|
||||||
|
DEFAULT_CURRENCY,
|
||||||
|
DEFAULT_REQUEST_TIMEOUT,
|
||||||
|
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||||
PROPERTY_DEMO_USER_ID,
|
PROPERTY_DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
@ -15,18 +19,22 @@ import {
|
|||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
encodeDataSource,
|
encodeDataSource,
|
||||||
extractNumberFromString
|
extractNumberFromString
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
InfoItem,
|
||||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
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 cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
|
import got from 'got';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
@ -37,18 +45,22 @@ export class InfoService {
|
|||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly platformService: PlatformService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
private readonly tagService: TagService
|
private readonly tagService: TagService,
|
||||||
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@ -109,29 +121,33 @@ export class InfoService {
|
|||||||
globalPermissions.push(permissions.createUserAccount);
|
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,
|
||||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
tags,
|
||||||
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
baseCurrency: DEFAULT_CURRENCY,
|
||||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
currencies: this.exchangeRateDataService.getCurrencies()
|
||||||
demoAuthToken: await this.getDemoAuthToken(),
|
|
||||||
statistics: await this.getStatistics(),
|
|
||||||
subscriptions: await this.getSubscriptions(),
|
|
||||||
tags: await this.tagService.get()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countActiveUsers(aDays: number) {
|
private async countActiveUsers(aDays: number) {
|
||||||
return await this.prismaService.user.count({
|
return this.userService.count({
|
||||||
orderBy: {
|
|
||||||
Analytics: {
|
|
||||||
updatedAt: 'desc'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
@ -153,20 +169,24 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countDockerHubPulls(): Promise<number> {
|
private async countDockerHubPulls(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const abortController = new AbortController();
|
||||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
|
||||||
'GET',
|
setTimeout(() => {
|
||||||
'json',
|
abortController.abort();
|
||||||
200,
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
{
|
|
||||||
'User-Agent': 'request'
|
const { pull_count } = await got(
|
||||||
}
|
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||||
);
|
{
|
||||||
|
headers: { 'User-Agent': 'request' },
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
}
|
||||||
|
).json<any>();
|
||||||
|
|
||||||
const { pull_count } = await get();
|
|
||||||
return pull_count;
|
return pull_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService - DockerHub');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -174,16 +194,18 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countGitHubContributors(): Promise<number> {
|
private async countGitHubContributors(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const abortController = new AbortController();
|
||||||
'https://github.com/ghostfolio/ghostfolio',
|
|
||||||
'GET',
|
|
||||||
'string',
|
|
||||||
200,
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
const html = await get();
|
setTimeout(() => {
|
||||||
const $ = cheerio.load(html);
|
abortController.abort();
|
||||||
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
|
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(body);
|
||||||
|
|
||||||
return extractNumberFromString(
|
return extractNumberFromString(
|
||||||
$(
|
$(
|
||||||
@ -191,7 +213,7 @@ export class InfoService {
|
|||||||
).text()
|
).text()
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService - GitHub');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -199,30 +221,31 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countGitHubStargazers(): Promise<number> {
|
private async countGitHubStargazers(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const abortController = new AbortController();
|
||||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
|
||||||
'GET',
|
setTimeout(() => {
|
||||||
'json',
|
abortController.abort();
|
||||||
200,
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
{
|
|
||||||
'User-Agent': 'request'
|
const { stargazers_count } = await got(
|
||||||
}
|
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||||
);
|
{
|
||||||
|
headers: { 'User-Agent': 'request' },
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
}
|
||||||
|
).json<any>();
|
||||||
|
|
||||||
const { stargazers_count } = await get();
|
|
||||||
return stargazers_count;
|
return stargazers_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService - GitHub');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countNewUsers(aDays: number) {
|
private async countNewUsers(aDays: number) {
|
||||||
return await this.prismaService.user.count({
|
return this.userService.count({
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc'
|
|
||||||
},
|
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
@ -285,6 +308,7 @@ export class InfoService {
|
|||||||
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,
|
||||||
@ -293,7 +317,8 @@ export class InfoService {
|
|||||||
gitHubContributors,
|
gitHubContributors,
|
||||||
gitHubStargazers,
|
gitHubStargazers,
|
||||||
newUsers30d,
|
newUsers30d,
|
||||||
slackCommunityUsers
|
slackCommunityUsers,
|
||||||
|
uptime
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.redisCacheService.set(
|
await this.redisCacheService.set(
|
||||||
@ -304,19 +329,54 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
let subscriptions: Subscription[] = [];
|
return (
|
||||||
|
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
private async getUptime(): Promise<number> {
|
||||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
{
|
||||||
})) ?? { value: '{}' };
|
try {
|
||||||
|
const monitorId = (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
||||||
|
)) as string;
|
||||||
|
|
||||||
subscriptions = [JSON.parse(stripeConfig.value)];
|
const abortController = new AbortController();
|
||||||
|
|
||||||
return subscriptions;
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
|
const { data } = await got(
|
||||||
|
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||||
|
subDays(new Date(), 90),
|
||||||
|
DATE_FORMAT
|
||||||
|
)}&to${format(new Date(), DATE_FORMAT)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.configurationService.get(
|
||||||
|
'BETTER_UPTIME_API_KEY'
|
||||||
|
)}`
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
}
|
||||||
|
).json<any>();
|
||||||
|
|
||||||
|
return data.attributes.availability / 100;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'InfoService - Better Stack');
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { LogoController } from './logo.controller';
|
import { LogoController } from './logo.controller';
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
|
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { HttpException, Injectable } from '@nestjs/common';
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import got from 'got';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -41,15 +42,19 @@ export class LogoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getBuffer(aUrl: string) {
|
private getBuffer(aUrl: string) {
|
||||||
const get = bent(
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, DEFAULT_REQUEST_TIMEOUT);
|
||||||
|
|
||||||
|
return got(
|
||||||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||||
'GET',
|
|
||||||
'buffer',
|
|
||||||
200,
|
|
||||||
{
|
{
|
||||||
'User-Agent': 'request'
|
headers: { 'User-Agent': 'request' },
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
}
|
}
|
||||||
);
|
).buffer();
|
||||||
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,7 +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;
|
value: number;
|
||||||
valueInBaseCurrency: number;
|
valueInBaseCurrency: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActivityError {
|
||||||
|
code: 'IS_DUPLICATE';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
@ -2,7 +2,8 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { 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';
|
||||||
@ -36,11 +37,29 @@ import { UpdateOrderDto } from './update-order.dto';
|
|||||||
export class OrderController {
|
export class OrderController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
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
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@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> {
|
||||||
@ -79,10 +98,7 @@ export class OrderController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
impersonationId,
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
const activities = await this.orderService.getOrders({
|
const activities = await this.orderService.getOrders({
|
||||||
@ -109,7 +125,7 @@ export class OrderController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.orderService.createOrder({
|
const order = await this.orderService.createOrder({
|
||||||
...data,
|
...data,
|
||||||
date: parseISO(data.date),
|
date: parseISO(data.date),
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
@ -130,6 +146,20 @@ export class OrderController {
|
|||||||
User: { connect: { id: this.request.user.id } },
|
User: { connect: { id: this.request.user.id } },
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (data.dataSource && !order.isDraft) {
|
||||||
|
// Gather symbol data in the background, if data source is set
|
||||||
|
// (not MANUAL) and not draft
|
||||||
|
this.dataGatheringService.gatherSymbols([
|
||||||
|
{
|
||||||
|
dataSource: data.dataSource,
|
||||||
|
date: order.date,
|
||||||
|
symbol: data.symbol
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@ -2,14 +2,15 @@ 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 { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { OrderController } from './order.controller';
|
import { OrderController } from './order.controller';
|
||||||
@ -31,6 +32,6 @@ import { OrderService } from './order.service';
|
|||||||
SymbolProfileModule,
|
SymbolProfileModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [AccountService, OrderService]
|
providers: [AccountBalanceService, AccountService, OrderService]
|
||||||
})
|
})
|
||||||
export class OrderModule {}
|
export class OrderModule {}
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
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
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
@ -73,6 +74,7 @@ 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> {
|
||||||
@ -89,12 +91,21 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accountId = data.accountId;
|
||||||
|
let currency = data.currency;
|
||||||
const tags = data.tags ?? [];
|
const tags = data.tags ?? [];
|
||||||
|
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||||
|
const userId = data.userId;
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (
|
||||||
|
data.type === 'FEE' ||
|
||||||
|
data.type === 'INTEREST' ||
|
||||||
|
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;
|
||||||
@ -112,26 +123,21 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue(
|
if (data.SymbolProfile.connectOrCreate.create.dataSource !== 'MANUAL') {
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
this.dataGatheringService.addJobToQueue({
|
||||||
{
|
data: {
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
|
||||||
},
|
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
|
||||||
);
|
|
||||||
|
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
|
||||||
|
|
||||||
if (!isDraft) {
|
|
||||||
// Gather symbol data of order in the background, if not draft
|
|
||||||
this.dataGatheringService.gatherSymbols([
|
|
||||||
{
|
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
date: <Date>data.date,
|
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
|
},
|
||||||
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
opts: {
|
||||||
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: getAssetProfileIdentifier({
|
||||||
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
|
})
|
||||||
}
|
}
|
||||||
]);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
@ -146,11 +152,20 @@ 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 isDraft =
|
||||||
|
data.type === 'FEE' ||
|
||||||
|
data.type === 'INTEREST' ||
|
||||||
|
data.type === 'ITEM' ||
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
? false
|
||||||
|
: isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
|
const order = await this.prismaService.order.create({
|
||||||
data: {
|
data: {
|
||||||
...orderData,
|
...orderData,
|
||||||
Account,
|
Account,
|
||||||
@ -162,6 +177,27 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (updateAccountBalance === true) {
|
||||||
|
let amount = new Big(data.unitPrice)
|
||||||
|
.mul(data.quantity)
|
||||||
|
.plus(data.fee)
|
||||||
|
.toNumber();
|
||||||
|
|
||||||
|
if (data.type === 'BUY') {
|
||||||
|
amount = new Big(amount).mul(-1).toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.accountService.updateAccountBalance({
|
||||||
|
accountId,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
userId,
|
||||||
|
date: data.date as Date
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteOrder(
|
public async deleteOrder(
|
||||||
@ -171,13 +207,26 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
if (order.type === 'ITEM') {
|
if (
|
||||||
|
order.type === 'FEE' ||
|
||||||
|
order.type === 'INTEREST' ||
|
||||||
|
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,
|
||||||
@ -282,7 +331,11 @@ export class OrderService {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.filter((order) => {
|
.filter((order) => {
|
||||||
return withExcludedAccounts || order.Account?.isExcluded === false;
|
return (
|
||||||
|
withExcludedAccounts ||
|
||||||
|
!order.Account ||
|
||||||
|
order.Account?.isExcluded === false
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.map((order) => {
|
.map((order) => {
|
||||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||||
@ -330,7 +383,12 @@ export class OrderService {
|
|||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (
|
||||||
|
data.type === 'FEE' ||
|
||||||
|
data.type === 'INTEREST' ||
|
||||||
|
data.type === 'ITEM' ||
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
) {
|
||||||
delete data.SymbolProfile.connect;
|
delete data.SymbolProfile.connect;
|
||||||
} else {
|
} else {
|
||||||
delete data.SymbolProfile.update;
|
delete data.SymbolProfile.update;
|
||||||
|
@ -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;
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
|
||||||
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||||
|
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
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) {
|
||||||
@ -49,11 +49,9 @@ export const CurrentRateServiceMock = {
|
|||||||
getValues: ({
|
getValues: ({
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery
|
dateQuery
|
||||||
}: GetValuesParams): Promise<{
|
}: GetValuesParams): Promise<GetValuesObject> => {
|
||||||
dataProviderInfos: DataProviderInfo[];
|
|
||||||
values: GetValueObject[];
|
|
||||||
}> => {
|
|
||||||
const values: GetValueObject[] = [];
|
const values: GetValueObject[] = [];
|
||||||
|
|
||||||
if (dateQuery.lt) {
|
if (dateQuery.lt) {
|
||||||
for (
|
for (
|
||||||
let date = resetHours(dateQuery.gte);
|
let date = resetHours(dateQuery.gte);
|
||||||
@ -85,6 +83,7 @@ export const CurrentRateServiceMock = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.resolve({ values, dataProviderInfos: [] });
|
|
||||||
|
return Promise.resolve({ values, dataProviderInfos: [], errors: [] });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,13 +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 { DataProviderInfo } from '@ghostfolio/common/interfaces';
|
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 {
|
||||||
@ -18,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: ({
|
||||||
@ -37,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]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -45,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]
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
@ -54,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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@ -72,11 +88,20 @@ 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);
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
|
dataProviderService = new DataProviderService(
|
||||||
null,
|
null,
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
propertyService,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@ -104,21 +129,14 @@ describe('CurrentRateService', () => {
|
|||||||
},
|
},
|
||||||
userCurrency: 'CHF'
|
userCurrency: 'CHF'
|
||||||
})
|
})
|
||||||
).toMatchObject<{
|
).toMatchObject<GetValuesObject>({
|
||||||
dataProviderInfos: DataProviderInfo[];
|
|
||||||
values: GetValueObject[];
|
|
||||||
}>({
|
|
||||||
dataProviderInfos: [],
|
dataProviderInfos: [],
|
||||||
|
errors: [],
|
||||||
values: [
|
values: [
|
||||||
{
|
{
|
||||||
date: undefined,
|
date: undefined,
|
||||||
marketPriceInBaseCurrency: 1841.823902,
|
marketPriceInBaseCurrency: 1841.823902,
|
||||||
symbol: 'AMZN'
|
symbol: 'AMZN'
|
||||||
},
|
|
||||||
{
|
|
||||||
date: undefined,
|
|
||||||
marketPriceInBaseCurrency: 1847.839966,
|
|
||||||
symbol: 'AMZN'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
@ -1,13 +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 } from '@ghostfolio/common/interfaces';
|
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()
|
||||||
@ -23,10 +24,7 @@ export class CurrentRateService {
|
|||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery,
|
dateQuery,
|
||||||
userCurrency
|
userCurrency
|
||||||
}: GetValuesParams): Promise<{
|
}: GetValuesParams): Promise<GetValuesObject> {
|
||||||
dataProviderInfos: DataProviderInfo[];
|
|
||||||
values: GetValueObject[];
|
|
||||||
}> {
|
|
||||||
const dataProviderInfos: DataProviderInfo[] = [];
|
const dataProviderInfos: DataProviderInfo[] = [];
|
||||||
const includeToday =
|
const includeToday =
|
||||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||||
@ -34,12 +32,13 @@ export class CurrentRateService {
|
|||||||
(!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({ items: dataGatheringItems })
|
||||||
.then((dataResultProvider) => {
|
.then((dataResultProvider) => {
|
||||||
const result: GetValueObject[] = [];
|
const result: GetValueObject[] = [];
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
@ -51,18 +50,26 @@ export class CurrentRateService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push({
|
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
|
||||||
date: today,
|
result.push({
|
||||||
marketPriceInBaseCurrency:
|
date: today,
|
||||||
this.exchangeRateDataService.toCurrency(
|
marketPriceInBaseCurrency:
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]
|
this.exchangeRateDataService.toCurrency(
|
||||||
?.marketPrice ?? 0,
|
dataResultProvider?.[dataGatheringItem.symbol]
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
?.marketPrice,
|
||||||
userCurrency
|
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||||
),
|
userCurrency
|
||||||
symbol: dataGatheringItem.symbol
|
),
|
||||||
});
|
symbol: dataGatheringItem.symbol
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
quoteErrors.push({
|
||||||
|
dataSource: dataGatheringItem.dataSource,
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -94,10 +101,60 @@ export class CurrentRateService {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
const values = flatten(await Promise.all(promises));
|
||||||
|
|
||||||
|
const response: GetValuesObject = {
|
||||||
dataProviderInfos,
|
dataProviderInfos,
|
||||||
values: flatten(await Promise.all(promises))
|
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,4 @@
|
|||||||
import { DataSource, Type as TypeOfOrder } from '@prisma/client';
|
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
export interface PortfolioOrder {
|
export interface PortfolioOrder {
|
||||||
@ -9,6 +9,7 @@ export interface PortfolioOrder {
|
|||||||
name: string;
|
name: string;
|
||||||
quantity: Big;
|
quantity: Big;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
tags?: Tag[];
|
||||||
type: TypeOfOrder;
|
type: TypeOfOrder;
|
||||||
unitPrice: Big;
|
unitPrice: Big;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { DataSource } from '@prisma/client';
|
import { DataSource, Tag } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
export interface TransactionPointSymbol {
|
export interface TransactionPointSymbol {
|
||||||
@ -9,5 +9,6 @@ export interface TransactionPointSymbol {
|
|||||||
investment: Big;
|
investment: Big;
|
||||||
quantity: Big;
|
quantity: Big;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
tags?: Tag[];
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
}
|
}
|
||||||
|
@ -105,6 +105,40 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
expect(investmentsByMonth).toEqual([
|
expect(investmentsByMonth).toEqual([
|
||||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
{ 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') }
|
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
@ -86,7 +86,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
netPerformanceInPercentage: 13.100263852242744,
|
netPerformanceInPercentage: 13.100263852242744,
|
||||||
netPerformance: 19.86,
|
netPerformance: 19.86,
|
||||||
totalInvestment: 0,
|
totalInvestment: 0,
|
||||||
value: 19.86
|
value: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
|
@ -24,9 +24,10 @@ import {
|
|||||||
isSameYear,
|
isSameYear,
|
||||||
max,
|
max,
|
||||||
min,
|
min,
|
||||||
set
|
set,
|
||||||
|
subDays
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { first, flatten, isNumber, last, sortBy } from 'lodash';
|
import { first, flatten, isNumber, last, sortBy, uniq } from 'lodash';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||||
@ -113,6 +114,7 @@ export class PortfolioCalculator {
|
|||||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||||
quantity: newQuantity,
|
quantity: newQuantity,
|
||||||
symbol: order.symbol,
|
symbol: order.symbol,
|
||||||
|
tags: order.tags,
|
||||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@ -124,6 +126,7 @@ export class PortfolioCalculator {
|
|||||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||||
quantity: order.quantity.mul(factor),
|
quantity: order.quantity.mul(factor),
|
||||||
symbol: order.symbol,
|
symbol: order.symbol,
|
||||||
|
tags: order.tags,
|
||||||
transactionCount: 1
|
transactionCount: 1
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -182,10 +185,10 @@ export class PortfolioCalculator {
|
|||||||
return isBefore(parseDate(transactionPoint.date), end);
|
return isBefore(parseDate(transactionPoint.date), end);
|
||||||
}) ?? [];
|
}) ?? [];
|
||||||
|
|
||||||
const firstIndex = transactionPointsBeforeEndDate.length;
|
const currencies: { [symbol: string]: string } = {};
|
||||||
const dates: Date[] = [];
|
const dates: Date[] = [];
|
||||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
const currencies: { [symbol: string]: string } = {};
|
const firstIndex = transactionPointsBeforeEndDate.length;
|
||||||
|
|
||||||
let day = start;
|
let day = start;
|
||||||
|
|
||||||
@ -235,87 +238,100 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const netPerformanceValuesBySymbol: {
|
const valuesByDate: {
|
||||||
[symbol: string]: { [date: string]: Big };
|
[date: string]: {
|
||||||
|
maxTotalInvestmentValue: Big;
|
||||||
|
totalCurrentValue: Big;
|
||||||
|
totalInvestmentValue: Big;
|
||||||
|
totalNetPerformanceValue: Big;
|
||||||
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
const investmentValuesBySymbol: {
|
const valuesBySymbol: {
|
||||||
[symbol: string]: { [date: string]: Big };
|
[symbol: string]: {
|
||||||
|
currentValues: { [date: string]: Big };
|
||||||
|
investmentValues: { [date: string]: Big };
|
||||||
|
maxInvestmentValues: { [date: string]: Big };
|
||||||
|
netPerformanceValues: { [date: string]: Big };
|
||||||
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
const maxInvestmentValuesBySymbol: {
|
|
||||||
[symbol: string]: { [date: string]: Big };
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
const totalNetPerformanceValues: { [date: string]: Big } = {};
|
|
||||||
const totalInvestmentValues: { [date: string]: Big } = {};
|
|
||||||
const maxTotalInvestmentValues: { [date: string]: Big } = {};
|
|
||||||
|
|
||||||
for (const symbol of Object.keys(symbols)) {
|
for (const symbol of Object.keys(symbols)) {
|
||||||
const { investmentValues, maxInvestmentValues, netPerformanceValues } =
|
const {
|
||||||
this.getSymbolMetrics({
|
currentValues,
|
||||||
end,
|
investmentValues,
|
||||||
marketSymbolMap,
|
maxInvestmentValues,
|
||||||
start,
|
netPerformanceValues
|
||||||
step,
|
} = this.getSymbolMetrics({
|
||||||
symbol,
|
end,
|
||||||
isChartMode: true
|
marketSymbolMap,
|
||||||
});
|
start,
|
||||||
|
step,
|
||||||
|
symbol,
|
||||||
|
isChartMode: true
|
||||||
|
});
|
||||||
|
|
||||||
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
|
valuesBySymbol[symbol] = {
|
||||||
investmentValuesBySymbol[symbol] = investmentValues;
|
currentValues,
|
||||||
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues;
|
investmentValues,
|
||||||
|
maxInvestmentValues,
|
||||||
|
netPerformanceValues
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const currentDate of dates) {
|
for (const currentDate of dates) {
|
||||||
const dateString = format(currentDate, DATE_FORMAT);
|
const dateString = format(currentDate, DATE_FORMAT);
|
||||||
|
|
||||||
for (const symbol of Object.keys(netPerformanceValuesBySymbol)) {
|
for (const symbol of Object.keys(valuesBySymbol)) {
|
||||||
totalNetPerformanceValues[dateString] =
|
const symbolValues = valuesBySymbol[symbol];
|
||||||
totalNetPerformanceValues[dateString] ?? new Big(0);
|
|
||||||
|
|
||||||
if (netPerformanceValuesBySymbol[symbol]?.[dateString]) {
|
const currentValue =
|
||||||
totalNetPerformanceValues[dateString] = totalNetPerformanceValues[
|
symbolValues.currentValues?.[dateString] ?? new Big(0);
|
||||||
dateString
|
const investmentValue =
|
||||||
].add(netPerformanceValuesBySymbol[symbol][dateString]);
|
symbolValues.investmentValues?.[dateString] ?? new Big(0);
|
||||||
}
|
const maxInvestmentValue =
|
||||||
|
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
|
||||||
|
const netPerformanceValue =
|
||||||
|
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
|
||||||
|
|
||||||
totalInvestmentValues[dateString] =
|
valuesByDate[dateString] = {
|
||||||
totalInvestmentValues[dateString] ?? new Big(0);
|
totalCurrentValue: (
|
||||||
|
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
|
||||||
maxTotalInvestmentValues[dateString] =
|
).add(currentValue),
|
||||||
maxTotalInvestmentValues[dateString] ?? new Big(0);
|
totalInvestmentValue: (
|
||||||
|
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
|
||||||
if (investmentValuesBySymbol[symbol]?.[dateString]) {
|
).add(investmentValue),
|
||||||
totalInvestmentValues[dateString] = totalInvestmentValues[
|
maxTotalInvestmentValue: (
|
||||||
dateString
|
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
|
||||||
].add(investmentValuesBySymbol[symbol][dateString]);
|
).add(maxInvestmentValue),
|
||||||
}
|
totalNetPerformanceValue: (
|
||||||
|
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
|
||||||
if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) {
|
).add(netPerformanceValue)
|
||||||
maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[
|
};
|
||||||
dateString
|
|
||||||
].add(maxInvestmentValuesBySymbol[symbol][dateString]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(totalNetPerformanceValues).map((date) => {
|
return Object.entries(valuesByDate).map(([date, values]) => {
|
||||||
const netPerformanceInPercentage = maxTotalInvestmentValues[date].eq(0)
|
const {
|
||||||
|
maxTotalInvestmentValue,
|
||||||
|
totalCurrentValue,
|
||||||
|
totalInvestmentValue,
|
||||||
|
totalNetPerformanceValue
|
||||||
|
} = values;
|
||||||
|
|
||||||
|
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
|
||||||
? 0
|
? 0
|
||||||
: totalNetPerformanceValues[date]
|
: totalNetPerformanceValue
|
||||||
.div(maxTotalInvestmentValues[date])
|
.div(maxTotalInvestmentValue)
|
||||||
.mul(100)
|
.mul(100)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
netPerformanceInPercentage,
|
netPerformanceInPercentage,
|
||||||
netPerformance: totalNetPerformanceValues[date].toNumber(),
|
netPerformance: totalNetPerformanceValue.toNumber(),
|
||||||
totalInvestment: totalInvestmentValues[date].toNumber(),
|
totalInvestment: totalInvestmentValue.toNumber(),
|
||||||
value: totalInvestmentValues[date]
|
value: totalCurrentValue.toNumber()
|
||||||
.plus(totalNetPerformanceValues[date])
|
|
||||||
.toNumber()
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -347,7 +363,7 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
let firstTransactionPoint: TransactionPoint = null;
|
let firstTransactionPoint: TransactionPoint = null;
|
||||||
let firstIndex = transactionPointsBeforeEndDate.length;
|
let firstIndex = transactionPointsBeforeEndDate.length;
|
||||||
const dates = [];
|
let dates = [];
|
||||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
const currencies: { [symbol: string]: string } = {};
|
const currencies: { [symbol: string]: string } = {};
|
||||||
|
|
||||||
@ -376,15 +392,37 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
dates.push(resetHours(end));
|
dates.push(resetHours(end));
|
||||||
|
|
||||||
const { dataProviderInfos, values: marketSymbols } =
|
// Add dates of last week for fallback
|
||||||
await this.currentRateService.getValues({
|
dates.push(subDays(resetHours(new Date()), 7));
|
||||||
currencies,
|
dates.push(subDays(resetHours(new Date()), 6));
|
||||||
dataGatheringItems,
|
dates.push(subDays(resetHours(new Date()), 5));
|
||||||
dateQuery: {
|
dates.push(subDays(resetHours(new Date()), 4));
|
||||||
in: dates
|
dates.push(subDays(resetHours(new Date()), 3));
|
||||||
},
|
dates.push(subDays(resetHours(new Date()), 2));
|
||||||
userCurrency: this.currency
|
dates.push(subDays(resetHours(new Date()), 1));
|
||||||
});
|
dates.push(resetHours(new Date()));
|
||||||
|
|
||||||
|
dates = uniq(
|
||||||
|
dates.map((date) => {
|
||||||
|
return date.getTime();
|
||||||
|
})
|
||||||
|
).map((timestamp) => {
|
||||||
|
return new Date(timestamp);
|
||||||
|
});
|
||||||
|
dates.sort((a, b) => a.getTime() - b.getTime());
|
||||||
|
|
||||||
|
const {
|
||||||
|
dataProviderInfos,
|
||||||
|
errors: currentRateErrors,
|
||||||
|
values: marketSymbols
|
||||||
|
} = await this.currentRateService.getValues({
|
||||||
|
currencies,
|
||||||
|
dataGatheringItems,
|
||||||
|
dateQuery: {
|
||||||
|
in: dates
|
||||||
|
},
|
||||||
|
userCurrency: this.currency
|
||||||
|
});
|
||||||
|
|
||||||
this.dataProviderInfos = dataProviderInfos;
|
this.dataProviderInfos = dataProviderInfos;
|
||||||
|
|
||||||
@ -456,10 +494,17 @@ export class PortfolioCalculator {
|
|||||||
: null,
|
: null,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
symbol: item.symbol,
|
symbol: item.symbol,
|
||||||
|
tags: item.tags,
|
||||||
transactionCount: item.transactionCount
|
transactionCount: item.transactionCount
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasErrors && item.investment.gt(0)) {
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -502,7 +547,7 @@ export class PortfolioCalculator {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const investments = [];
|
const investments: { date: string; investment: Big }[] = [];
|
||||||
let currentDate: Date;
|
let currentDate: Date;
|
||||||
let investmentByGroup = new Big(0);
|
let investmentByGroup = new Big(0);
|
||||||
|
|
||||||
@ -512,13 +557,11 @@ export class PortfolioCalculator {
|
|||||||
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
||||||
) {
|
) {
|
||||||
// Same group: Add up investments
|
// Same group: Add up investments
|
||||||
|
|
||||||
investmentByGroup = investmentByGroup.plus(
|
investmentByGroup = investmentByGroup.plus(
|
||||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// New group: Store previous group and reset
|
// New group: Store previous group and reset
|
||||||
|
|
||||||
if (currentDate) {
|
if (currentDate) {
|
||||||
investments.push({
|
investments.push({
|
||||||
date: format(
|
date: format(
|
||||||
@ -553,7 +596,39 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
||||||
@ -709,7 +784,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 ${currentPosition.symbol} (${currentPosition.dataSource})`,
|
||||||
'PortfolioCalculator'
|
'PortfolioCalculator'
|
||||||
);
|
);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
@ -906,12 +981,16 @@ export class PortfolioCalculator {
|
|||||||
|
|
||||||
if (orders.length <= 0) {
|
if (orders.length <= 0) {
|
||||||
return {
|
return {
|
||||||
|
currentValues: {},
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
initialValue: new Big(0),
|
initialValue: new Big(0),
|
||||||
|
investmentValues: {},
|
||||||
|
maxInvestmentValues: {},
|
||||||
netPerformance: new Big(0),
|
netPerformance: new Big(0),
|
||||||
netPerformancePercentage: new Big(0),
|
netPerformancePercentage: new Big(0),
|
||||||
grossPerformance: new Big(0),
|
netPerformanceValues: {}
|
||||||
grossPerformancePercentage: new Big(0)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -946,6 +1025,7 @@ export class PortfolioCalculator {
|
|||||||
let grossPerformanceFromSells = new Big(0);
|
let grossPerformanceFromSells = new Big(0);
|
||||||
let initialValue: Big;
|
let initialValue: Big;
|
||||||
let investmentAtStartDate: Big;
|
let investmentAtStartDate: Big;
|
||||||
|
const currentValues: { [date: string]: Big } = {};
|
||||||
const investmentValues: { [date: string]: Big } = {};
|
const investmentValues: { [date: string]: Big } = {};
|
||||||
const maxInvestmentValues: { [date: string]: Big } = {};
|
const maxInvestmentValues: { [date: string]: Big } = {};
|
||||||
let lastAveragePrice = new Big(0);
|
let lastAveragePrice = new Big(0);
|
||||||
@ -1164,6 +1244,7 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isChartMode && i > indexOfStartOrder) {
|
if (isChartMode && i > indexOfStartOrder) {
|
||||||
|
currentValues[order.date] = valueOfInvestment;
|
||||||
netPerformanceValues[order.date] = grossPerformance
|
netPerformanceValues[order.date] = grossPerformance
|
||||||
.minus(grossPerformanceAtStartDate)
|
.minus(grossPerformanceAtStartDate)
|
||||||
.minus(fees.minus(feesAtStartDate));
|
.minus(fees.minus(feesAtStartDate));
|
||||||
@ -1261,15 +1342,16 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialValue,
|
currentValues,
|
||||||
grossPerformancePercentage,
|
grossPerformancePercentage,
|
||||||
|
initialValue,
|
||||||
investmentValues,
|
investmentValues,
|
||||||
maxInvestmentValues,
|
maxInvestmentValues,
|
||||||
netPerformancePercentage,
|
netPerformancePercentage,
|
||||||
netPerformanceValues,
|
netPerformanceValues,
|
||||||
|
grossPerformance: totalGrossPerformance,
|
||||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||||
netPerformance: totalNetPerformance,
|
netPerformance: totalNetPerformance
|
||||||
grossPerformance: totalGrossPerformance
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,9 +8,12 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import {
|
||||||
|
DEFAULT_CURRENCY,
|
||||||
|
HEADER_KEY_IMPERSONATION
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioDividends,
|
PortfolioDividends,
|
||||||
@ -47,8 +50,6 @@ import { PortfolioService } from './portfolio.service';
|
|||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
private baseCurrency: string;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
@ -57,9 +58,7 @@ export class PortfolioController {
|
|||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {
|
) {}
|
||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@ -91,6 +90,7 @@ export class PortfolioController {
|
|||||||
filteredValueInPercentage,
|
filteredValueInPercentage,
|
||||||
hasErrors,
|
hasErrors,
|
||||||
holdings,
|
holdings,
|
||||||
|
platforms,
|
||||||
summary,
|
summary,
|
||||||
totalValueInBaseCurrency
|
totalValueInBaseCurrency
|
||||||
} = await this.portfolioService.getDetails({
|
} = await this.portfolioService.getDetails({
|
||||||
@ -133,12 +133,15 @@ export class PortfolioController {
|
|||||||
portfolioPosition.netPerformance = null;
|
portfolioPosition.netPerformance = null;
|
||||||
portfolioPosition.quantity = null;
|
portfolioPosition.quantity = null;
|
||||||
portfolioPosition.valueInPercentage =
|
portfolioPosition.valueInPercentage =
|
||||||
portfolioPosition.value / totalValue;
|
portfolioPosition.valueInBaseCurrency / 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,9 +160,12 @@ export class PortfolioController {
|
|||||||
'emergencyFund',
|
'emergencyFund',
|
||||||
'excludedAccountsAndActivities',
|
'excludedAccountsAndActivities',
|
||||||
'fees',
|
'fees',
|
||||||
|
'fireWealth',
|
||||||
'items',
|
'items',
|
||||||
|
'liabilities',
|
||||||
'netWorth',
|
'netWorth',
|
||||||
'totalBuy',
|
'totalBuy',
|
||||||
|
'totalInvestment',
|
||||||
'totalSell'
|
'totalSell'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -167,11 +173,20 @@ export class PortfolioController {
|
|||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
holdings[symbol] = {
|
holdings[symbol] = {
|
||||||
...portfolioPosition,
|
...portfolioPosition,
|
||||||
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
|
assetClass:
|
||||||
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
|
hasDetails || portfolioPosition.assetClass === 'CASH'
|
||||||
|
? portfolioPosition.assetClass
|
||||||
|
: undefined,
|
||||||
|
assetSubClass:
|
||||||
|
hasDetails || portfolioPosition.assetSubClass === 'CASH'
|
||||||
|
? portfolioPosition.assetSubClass
|
||||||
|
: undefined,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
|
marketsAdvanced: hasDetails
|
||||||
|
? portfolioPosition.marketsAdvanced
|
||||||
|
: undefined,
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : []
|
sectors: hasDetails ? portfolioPosition.sectors : []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -182,6 +197,7 @@ export class PortfolioController {
|
|||||||
filteredValueInPercentage,
|
filteredValueInPercentage,
|
||||||
hasError,
|
hasError,
|
||||||
holdings,
|
holdings,
|
||||||
|
platforms,
|
||||||
totalValueInBaseCurrency,
|
totalValueInBaseCurrency,
|
||||||
summary: portfolioSummary
|
summary: portfolioSummary
|
||||||
};
|
};
|
||||||
@ -253,11 +269,12 @@ export class PortfolioController {
|
|||||||
filterByTags
|
filterByTags
|
||||||
});
|
});
|
||||||
|
|
||||||
let investments = await this.portfolioService.getInvestments({
|
let { investments, streaks } = await this.portfolioService.getInvestments({
|
||||||
dateRange,
|
dateRange,
|
||||||
filters,
|
filters,
|
||||||
groupBy,
|
groupBy,
|
||||||
impersonationId
|
impersonationId,
|
||||||
|
savingsRate: this.request.user?.Settings?.settings.savingsRate
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -273,6 +290,11 @@ export class PortfolioController {
|
|||||||
date: item.date,
|
date: item.date,
|
||||||
investment: item.investment / maxInvestment
|
investment: item.investment / maxInvestment
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
streaks = nullifyValuesInObject(streaks, [
|
||||||
|
'currentStreak',
|
||||||
|
'longestStreak'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -282,9 +304,14 @@ export class PortfolioController {
|
|||||||
investments = investments.map((item) => {
|
investments = investments.map((item) => {
|
||||||
return nullifyValuesInObject(item, ['investment']);
|
return nullifyValuesInObject(item, ['investment']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
streaks = nullifyValuesInObject(streaks, [
|
||||||
|
'currentStreak',
|
||||||
|
'longestStreak'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { investments };
|
return { investments, streaks };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
@ -420,15 +447,15 @@ 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?.settings.baseCurrency ??
|
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
|
||||||
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] = {
|
||||||
allocationInPercentage: portfolioPosition.value / totalValue,
|
allocationInPercentage:
|
||||||
|
portfolioPosition.valueInBaseCurrency / totalValue,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
dataSource: portfolioPosition.dataSource,
|
dataSource: portfolioPosition.dataSource,
|
||||||
@ -439,7 +466,7 @@ export class PortfolioController {
|
|||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
symbol: portfolioPosition.symbol,
|
symbol: portfolioPosition.symbol,
|
||||||
url: portfolioPosition.url,
|
url: portfolioPosition.url,
|
||||||
valueInPercentage: portfolioPosition.value / totalValue
|
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,15 +2,16 @@ 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 { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
@ -36,6 +37,7 @@ import { RulesService } from './rules.service';
|
|||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
AccountBalanceService,
|
||||||
AccountService,
|
AccountService,
|
||||||
CurrentRateService,
|
CurrentRateService,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,7 @@
|
|||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
|
||||||
|
import type { RedisStore } from './redis-store.interface';
|
||||||
|
|
||||||
|
export interface RedisCache extends Cache {
|
||||||
|
store: RedisStore;
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import { Store } from 'cache-manager';
|
||||||
|
import { createClient } from 'redis';
|
||||||
|
|
||||||
|
export interface RedisStore extends Store {
|
||||||
|
getClient: () => ReturnType<typeof createClient>;
|
||||||
|
isCacheableValue: (value: any) => boolean;
|
||||||
|
name: 'redis';
|
||||||
|
}
|
@ -1,7 +1,9 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
import * as redisStore from 'cache-manager-redis-store';
|
import * as redisStore from 'cache-manager-redis-store';
|
||||||
|
import type { RedisClientOptions } from 'redis';
|
||||||
|
|
||||||
import { RedisCacheService } from './redis-cache.service';
|
import { RedisCacheService } from './redis-cache.service';
|
||||||
|
|
||||||
@ -11,7 +13,7 @@ import { RedisCacheService } from './redis-cache.service';
|
|||||||
imports: [ConfigurationModule],
|
imports: [ConfigurationModule],
|
||||||
inject: [ConfigurationService],
|
inject: [ConfigurationService],
|
||||||
useFactory: async (configurationService: ConfigurationService) => {
|
useFactory: async (configurationService: ConfigurationService) => {
|
||||||
return <CacheManagerOptions>{
|
return <RedisClientOptions>{
|
||||||
host: configurationService.get('REDIS_HOST'),
|
host: configurationService.get('REDIS_HOST'),
|
||||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||||
password: configurationService.get('REDIS_PASSWORD'),
|
password: configurationService.get('REDIS_PASSWORD'),
|
||||||
|
@ -1,18 +1,32 @@
|
|||||||
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 { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import { Cache } from 'cache-manager';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type { RedisCache } from './interfaces/redis-cache.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RedisCacheService {
|
export class RedisCacheService {
|
||||||
public constructor(
|
public constructor(
|
||||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
@Inject(CACHE_MANAGER) private readonly cache: RedisCache,
|
||||||
private readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService
|
||||||
) {}
|
) {
|
||||||
|
const client = cache.store.getClient();
|
||||||
|
|
||||||
|
client.on('error', (error) => {
|
||||||
|
Logger.error(error, 'RedisCacheService');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async get(key: string): Promise<string> {
|
public async get(key: string): Promise<string> {
|
||||||
return await this.cache.get(key);
|
return await this.cache.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
|
||||||
|
}
|
||||||
|
|
||||||
public async remove(key: string) {
|
public async remove(key: string) {
|
||||||
await this.cache.del(key);
|
await this.cache.del(key);
|
||||||
}
|
}
|
||||||
@ -22,8 +36,10 @@ export class RedisCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async set(key: string, value: string, ttlInSeconds?: number) {
|
public async set(key: string, value: string, ttlInSeconds?: number) {
|
||||||
await this.cache.set(key, value, {
|
await this.cache.set(
|
||||||
ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
key,
|
||||||
});
|
value,
|
||||||
|
ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
36
apps/api/src/app/sitemap/sitemap.controller.ts
Normal file
36
apps/api/src/app/sitemap/sitemap.controller.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
|
getYesterday,
|
||||||
|
interpolate
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
|
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
@Controller('sitemap.xml')
|
||||||
|
export class SitemapController {
|
||||||
|
public sitemapXml = '';
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
try {
|
||||||
|
this.sitemapXml = fs.readFileSync(
|
||||||
|
path.join(__dirname, 'assets', 'sitemap.xml'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Version(VERSION_NEUTRAL)
|
||||||
|
public async flushCache(@Res() response: Response): Promise<void> {
|
||||||
|
response.setHeader('content-type', 'application/xml');
|
||||||
|
response.send(
|
||||||
|
interpolate(this.sitemapXml, {
|
||||||
|
currentDate: format(getYesterday(), DATE_FORMAT)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
24
apps/api/src/app/sitemap/sitemap.module.ts
Normal file
24
apps/api/src/app/sitemap/sitemap.module.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { SitemapController } from './sitemap.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SitemapController],
|
||||||
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
PrismaModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class SitemapModule {}
|
@ -1,4 +1,4 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
DEFAULT_LANGUAGE_CODE,
|
||||||
|
@ -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';
|
||||||
|
|
||||||
|
@ -1,12 +1,8 @@
|
|||||||
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 {
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
DEFAULT_LANGUAGE_CODE,
|
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
PROPERTY_STRIPE_CONFIG
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { UserWithSettings } from '@ghostfolio/common/interfaces';
|
|
||||||
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
|
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Subscription } from '@prisma/client';
|
import { Subscription } from '@prisma/client';
|
||||||
import { addMilliseconds, isBefore } from 'date-fns';
|
import { addMilliseconds, isBefore } from 'date-fns';
|
||||||
@ -97,23 +93,11 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
|
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
|
||||||
try {
|
try {
|
||||||
const session = await this.stripe.checkout.sessions.retrieve(
|
const session =
|
||||||
aCheckoutSessionId
|
await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId);
|
||||||
);
|
|
||||||
|
|
||||||
let subscriptions: SubscriptionInterface[] = [];
|
|
||||||
|
|
||||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
|
||||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
|
||||||
})) ?? { value: '{}' };
|
|
||||||
|
|
||||||
subscriptions = [JSON.parse(stripeConfig.value)];
|
|
||||||
|
|
||||||
const coupon = subscriptions[0]?.coupon ?? 0;
|
|
||||||
const price = subscriptions[0]?.price ?? 0;
|
|
||||||
|
|
||||||
await this.createSubscription({
|
await this.createSubscription({
|
||||||
price: price - coupon,
|
price: session.amount_total / 100,
|
||||||
userId: session.client_reference_id
|
userId: session.client_reference_id
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -123,7 +107,9 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSubscription(aSubscriptions: Subscription[]) {
|
public getSubscription(
|
||||||
|
aSubscriptions: Subscription[]
|
||||||
|
): UserWithSettings['subscription'] {
|
||||||
if (aSubscriptions.length > 0) {
|
if (aSubscriptions.length > 0) {
|
||||||
const latestSubscription = aSubscriptions.reduce((a, b) => {
|
const latestSubscription = aSubscriptions.reduce((a, b) => {
|
||||||
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||||
@ -131,12 +117,14 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
expiresAt: latestSubscription.expiresAt,
|
expiresAt: latestSubscription.expiresAt,
|
||||||
|
offer: latestSubscription.price === 0 ? 'default' : 'renewal',
|
||||||
type: isBefore(new Date(), latestSubscription.expiresAt)
|
type: isBefore(new Date(), latestSubscription.expiresAt)
|
||||||
? SubscriptionType.Premium
|
? SubscriptionType.Premium
|
||||||
: SubscriptionType.Basic
|
: SubscriptionType.Basic
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
|
offer: 'default',
|
||||||
type: SubscriptionType.Basic
|
type: SubscriptionType.Basic
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { DataSource } from '@prisma/client';
|
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface LookupItem {
|
export interface LookupItem {
|
||||||
|
assetClass: AssetClass;
|
||||||
|
assetSubClass: AssetSubClass;
|
||||||
currency: string;
|
currency: string;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -1,17 +1,21 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
import { isDate, isEmpty } from 'lodash';
|
import { isDate, isEmpty } from 'lodash';
|
||||||
|
|
||||||
@ -21,7 +25,10 @@ import { SymbolService } from './symbol.service';
|
|||||||
|
|
||||||
@Controller('symbol')
|
@Controller('symbol')
|
||||||
export class SymbolController {
|
export class SymbolController {
|
||||||
public constructor(private readonly symbolService: SymbolService) {}
|
public constructor(
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
|
private readonly symbolService: SymbolService
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Must be before /:symbol
|
* Must be before /:symbol
|
||||||
@ -30,10 +37,15 @@ export class SymbolController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async lookupSymbol(
|
public async lookupSymbol(
|
||||||
@Query() { query = '' }
|
@Query('includeIndices') includeIndices: boolean = false,
|
||||||
|
@Query('query') query = ''
|
||||||
): Promise<{ items: LookupItem[] }> {
|
): Promise<{ items: LookupItem[] }> {
|
||||||
try {
|
try {
|
||||||
return this.symbolService.lookup(query.toLowerCase());
|
return this.symbolService.lookup({
|
||||||
|
includeIndices,
|
||||||
|
query: query.toLowerCase(),
|
||||||
|
user: this.request.user
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
@ -51,7 +63,7 @@ export class SymbolController {
|
|||||||
public async getSymbolData(
|
public async getSymbolData(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string,
|
@Param('symbol') symbol: string,
|
||||||
@Query('includeHistoricalData') includeHistoricalData?: number
|
@Query('includeHistoricalData') includeHistoricalData = 0
|
||||||
): Promise<SymbolItem> {
|
): Promise<SymbolItem> {
|
||||||
if (!DataSource[dataSource]) {
|
if (!DataSource[dataSource]) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@ -82,7 +94,7 @@ export class SymbolController {
|
|||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<IDataProviderHistoricalResponse> {
|
): Promise<IDataProviderHistoricalResponse> {
|
||||||
const date = new Date(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
if (!isDate(date)) {
|
if (!isDate(date)) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { SymbolController } from './symbol.controller';
|
import { SymbolController } from './symbol.controller';
|
||||||
|
@ -3,9 +3,10 @@ import {
|
|||||||
IDataGatheringItem,
|
IDataGatheringItem,
|
||||||
IDataProviderHistoricalResponse
|
IDataProviderHistoricalResponse
|
||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||||
|
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { format, subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
|
|
||||||
@ -26,9 +27,9 @@ export class SymbolService {
|
|||||||
dataGatheringItem: IDataGatheringItem;
|
dataGatheringItem: IDataGatheringItem;
|
||||||
includeHistoricalData?: number;
|
includeHistoricalData?: number;
|
||||||
}): Promise<SymbolItem> {
|
}): Promise<SymbolItem> {
|
||||||
const quotes = await this.dataProviderService.getQuotes([
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
dataGatheringItem
|
items: [dataGatheringItem]
|
||||||
]);
|
});
|
||||||
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
||||||
|
|
||||||
if (dataGatheringItem.dataSource && marketPrice >= 0) {
|
if (dataGatheringItem.dataSource && marketPrice >= 0) {
|
||||||
@ -79,15 +80,27 @@ export class SymbolService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async lookup({
|
||||||
|
includeIndices = false,
|
||||||
|
query,
|
||||||
|
user
|
||||||
|
}: {
|
||||||
|
includeIndices?: boolean;
|
||||||
|
query: string;
|
||||||
|
user: UserWithSettings;
|
||||||
|
}): Promise<{ items: LookupItem[] }> {
|
||||||
const results: { items: LookupItem[] } = { items: [] };
|
const results: { items: LookupItem[] } = { items: [] };
|
||||||
|
|
||||||
if (!aQuery) {
|
if (!query) {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { items } = await this.dataProviderService.search(aQuery);
|
const { items } = await this.dataProviderService.search({
|
||||||
|
includeIndices,
|
||||||
|
query,
|
||||||
|
user
|
||||||
|
});
|
||||||
results.items = items;
|
results.items = items;
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -3,17 +3,20 @@ import type {
|
|||||||
DateRange,
|
DateRange,
|
||||||
ViewMode
|
ViewMode
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
|
IsIn,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateUserSettingDto {
|
export class UpdateUserSettingDto {
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
annualInterestRate?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
baseCurrency?: string;
|
baseCurrency?: string;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
@ -1,39 +1,40 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
|
|
||||||
import {
|
import {
|
||||||
User as IUser,
|
DEFAULT_CURRENCY,
|
||||||
UserSettings,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
UserWithSettings
|
locale
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
getPermissions,
|
||||||
hasRole,
|
hasRole,
|
||||||
permissions
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
|
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma, Role, User } from '@prisma/client';
|
import { Prisma, Role, User } from '@prisma/client';
|
||||||
import { sortBy } from 'lodash';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
import { sortBy, without } from 'lodash';
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
public static DEFAULT_CURRENCY = 'USD';
|
|
||||||
|
|
||||||
private baseCurrency: string;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
private readonly tagService: TagService
|
private readonly tagService: TagService
|
||||||
) {
|
) {}
|
||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
|
||||||
|
public async count(args?: Prisma.UserCountArgs) {
|
||||||
|
return this.prismaService.user.count(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getUser(
|
public async getUser(
|
||||||
@ -125,7 +126,7 @@ export class UserService {
|
|||||||
id,
|
id,
|
||||||
provider,
|
provider,
|
||||||
role,
|
role,
|
||||||
Settings,
|
Settings: Settings as UserWithSettings['Settings'],
|
||||||
thirdPartyId,
|
thirdPartyId,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
activityCount: Analytics?.activityCount
|
activityCount: Analytics?.activityCount
|
||||||
@ -146,8 +147,7 @@ export class UserService {
|
|||||||
|
|
||||||
// Set default value for base currency
|
// Set default value for base currency
|
||||||
if (!(user.Settings.settings as UserSettings)?.baseCurrency) {
|
if (!(user.Settings.settings as UserSettings)?.baseCurrency) {
|
||||||
(user.Settings.settings as UserSettings).baseCurrency =
|
(user.Settings.settings as UserSettings).baseCurrency = DEFAULT_CURRENCY;
|
||||||
UserService.DEFAULT_CURRENCY;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default value for date range
|
// Set default value for date range
|
||||||
@ -167,11 +167,34 @@ export class UserService {
|
|||||||
user.subscription =
|
user.subscription =
|
||||||
this.subscriptionService.getSubscription(Subscription);
|
this.subscriptionService.getSubscription(Subscription);
|
||||||
|
|
||||||
if (
|
if (user.subscription?.type === 'Basic') {
|
||||||
Analytics?.activityCount % 25 === 0 &&
|
const daysSinceRegistration = differenceInDays(
|
||||||
user.subscription?.type === 'Basic'
|
new Date(),
|
||||||
) {
|
user.createdAt
|
||||||
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
);
|
||||||
|
let frequency = 20;
|
||||||
|
|
||||||
|
if (daysSinceRegistration > 180) {
|
||||||
|
frequency = 3;
|
||||||
|
} else if (daysSinceRegistration > 60) {
|
||||||
|
frequency = 5;
|
||||||
|
} else if (daysSinceRegistration > 30) {
|
||||||
|
frequency = 10;
|
||||||
|
} else if (daysSinceRegistration > 15) {
|
||||||
|
frequency = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Analytics?.activityCount % frequency === 1) {
|
||||||
|
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPermissions = without(
|
||||||
|
currentPermissions,
|
||||||
|
permissions.createAccess
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset benchmark
|
||||||
|
user.Settings.settings.benchmark = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.subscription?.type === 'Premium') {
|
if (user.subscription?.type === 'Premium') {
|
||||||
@ -199,6 +222,10 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!environment.production && role === 'ADMIN') {
|
||||||
|
currentPermissions.push(permissions.impersonateAllUsers);
|
||||||
|
}
|
||||||
|
|
||||||
user.Account = sortBy(user.Account, (account) => {
|
user.Account = sortBy(user.Account, (account) => {
|
||||||
return account.name;
|
return account.name;
|
||||||
});
|
});
|
||||||
@ -245,7 +272,7 @@ export class UserService {
|
|||||||
...data,
|
...data,
|
||||||
Account: {
|
Account: {
|
||||||
create: {
|
create: {
|
||||||
currency: this.baseCurrency,
|
currency: DEFAULT_CURRENCY,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
name: 'Default Account'
|
name: 'Default Account'
|
||||||
}
|
}
|
||||||
@ -253,7 +280,7 @@ export class UserService {
|
|||||||
Settings: {
|
Settings: {
|
||||||
create: {
|
create: {
|
||||||
settings: {
|
settings: {
|
||||||
currency: this.baseCurrency
|
currency: DEFAULT_CURRENCY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -302,21 +329,29 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
|
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
|
||||||
await this.prismaService.access.deleteMany({
|
try {
|
||||||
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
|
await this.prismaService.access.deleteMany({
|
||||||
});
|
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
await this.prismaService.account.deleteMany({
|
try {
|
||||||
where: { userId: where.id }
|
await this.prismaService.account.deleteMany({
|
||||||
});
|
where: { userId: where.id }
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
await this.prismaService.analytics.delete({
|
try {
|
||||||
where: { userId: where.id }
|
await this.prismaService.analytics.delete({
|
||||||
});
|
where: { userId: where.id }
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
await this.prismaService.order.deleteMany({
|
try {
|
||||||
where: { userId: where.id }
|
await this.prismaService.order.deleteMany({
|
||||||
});
|
where: { userId: where.id }
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.prismaService.settings.delete({
|
await this.prismaService.settings.delete({
|
||||||
|
1
apps/api/src/assets/countries/asia-pacific-markets.json
Normal file
1
apps/api/src/assets/countries/asia-pacific-markets.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
["AU", "HK", "NZ", "SG"]
|
19
apps/api/src/assets/countries/europe-markets.json
Normal file
19
apps/api/src/assets/countries/europe-markets.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
[
|
||||||
|
"AT",
|
||||||
|
"BE",
|
||||||
|
"CH",
|
||||||
|
"DE",
|
||||||
|
"DK",
|
||||||
|
"ES",
|
||||||
|
"FI",
|
||||||
|
"FR",
|
||||||
|
"GB",
|
||||||
|
"IE",
|
||||||
|
"IL",
|
||||||
|
"IT",
|
||||||
|
"LU",
|
||||||
|
"NL",
|
||||||
|
"NO",
|
||||||
|
"PT",
|
||||||
|
"SE"
|
||||||
|
]
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user