Compare commits
319 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 |
@ -12,5 +12,5 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
|||||||
|
|
||||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
ALPHA_VANTAGE_API_KEY=
|
ALPHA_VANTAGE_API_KEY=
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"root": true,
|
"root": true,
|
||||||
"ignorePatterns": ["**/*"],
|
"ignorePatterns": ["**/*"],
|
||||||
"plugins": ["@nrwl/nx"],
|
"plugins": ["@nx"],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"@nrwl/nx/enforce-module-boundaries": [
|
"@nx/enforce-module-boundaries": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
"enforceBuildableLibDependency": true,
|
"enforceBuildableLibDependency": true,
|
||||||
@ -23,12 +23,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["*.ts", "*.tsx"],
|
"files": ["*.ts", "*.tsx"],
|
||||||
"extends": ["plugin:@nrwl/nx/typescript"],
|
"extends": ["plugin:@nx/typescript"],
|
||||||
"rules": {}
|
"rules": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"files": ["*.js", "*.jsx"],
|
"files": ["*.js", "*.jsx"],
|
||||||
"extends": ["plugin:@nrwl/nx/javascript"],
|
"extends": ["plugin:@nx/javascript"],
|
||||||
"rules": {}
|
"rules": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -113,5 +113,6 @@
|
|||||||
"radix": "error"
|
"radix": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"extends": [null, "plugin:storybook/recommended"]
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
2
.github/workflows/build-code.yml
vendored
2
.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
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
673
CHANGELOG.md
673
CHANGELOG.md
@ -5,6 +5,674 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.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 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 +834,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 +981,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
|
||||||
|
|
||||||
@ -593,7 +1260,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
|
||||||
|
|
||||||
|
@ -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
|
||||||
@ -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/*
|
||||||
|
15
README.md
15
README.md
@ -145,7 +145,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 16)
|
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- 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`)
|
||||||
@ -200,7 +200,9 @@ Set the header for each request as follows:
|
|||||||
"Authorization": "Bearer eyJh..."
|
"Authorization": "Bearer eyJh..."
|
||||||
```
|
```
|
||||||
|
|
||||||
You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
|
||||||
|
|
||||||
|
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||||
|
|
||||||
### Import Activities
|
### Import Activities
|
||||||
|
|
||||||
@ -230,6 +232,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 +263,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/node: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,6 +7,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
ValidateIf
|
ValidateIf
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -14,6 +16,13 @@ export class CreateAccountDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@ -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,6 +7,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
ValidateIf
|
ValidateIf
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -14,6 +16,13 @@ export class UpdateAccountDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
|
@ -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,11 +32,12 @@ 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 } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@ -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')
|
||||||
@ -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')
|
||||||
@ -304,9 +336,10 @@ export class AdminController {
|
|||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
|
|
||||||
return this.marketDataService.updateMarketData({
|
return this.marketDataService.updateMarketData({
|
||||||
data: { ...data, dataSource },
|
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
|
||||||
where: {
|
where: {
|
||||||
date_symbol: {
|
dataSource_date_symbol: {
|
||||||
|
dataSource,
|
||||||
date,
|
date,
|
||||||
symbol
|
symbol
|
||||||
}
|
}
|
||||||
@ -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,21 +1,25 @@
|
|||||||
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_PAGE_SIZE,
|
||||||
|
PROPERTY_CURRENCIES
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminMarketDataItem,
|
|
||||||
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';
|
||||||
|
|
||||||
@ -25,6 +29,7 @@ export class AdminService {
|
|||||||
|
|
||||||
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,
|
||||||
@ -35,6 +40,38 @@ export class AdminService {
|
|||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
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) {
|
||||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||||
@ -65,9 +102,32 @@ 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 === '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 +135,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 +180,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 +264,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
|
||||||
});
|
});
|
||||||
|
@ -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,18 @@
|
|||||||
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 { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, RequestMethod } 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 { 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';
|
||||||
@ -24,10 +24,12 @@ import { CacheModule } from './cache/cache.module';
|
|||||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
import { FrontendMiddleware } from './frontend.middleware';
|
import { FrontendMiddleware } from './frontend.middleware';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { InfoModule } from './info/info.module';
|
import { InfoModule } from './info/info.module';
|
||||||
import { LogoModule } from './logo/logo.module';
|
import { LogoModule } from './logo/logo.module';
|
||||||
import { OrderModule } from './order/order.module';
|
import { OrderModule } from './order/order.module';
|
||||||
|
import { 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 { SubscriptionModule } from './subscription/subscription.module';
|
import { SubscriptionModule } from './subscription/subscription.module';
|
||||||
@ -57,10 +59,12 @@ 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,
|
||||||
|
@ -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,8 +33,11 @@ export class AuthController {
|
|||||||
private readonly webAuthService: WebAuthService
|
private readonly webAuthService: WebAuthService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
@Get('anonymous/:accessToken')
|
@Get('anonymous/:accessToken')
|
||||||
public async accessTokenLogin(
|
public async accessTokenLoginGet(
|
||||||
@Param('accessToken') accessToken: string
|
@Param('accessToken') accessToken: string
|
||||||
): Promise<OAuthResponse> {
|
): Promise<OAuthResponse> {
|
||||||
try {
|
try {
|
||||||
@ -50,6 +53,23 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('anonymous')
|
||||||
|
public async accessTokenLogin(
|
||||||
|
@Body() body: { accessToken: string }
|
||||||
|
): Promise<OAuthResponse> {
|
||||||
|
try {
|
||||||
|
const authToken = await this.authService.validateAnonymousLogin(
|
||||||
|
body.accessToken
|
||||||
|
);
|
||||||
|
return { authToken };
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get('google')
|
@Get('google')
|
||||||
@UseGuards(AuthGuard('google'))
|
@UseGuards(AuthGuard('google'))
|
||||||
public googleLogin() {
|
public googleLogin() {
|
||||||
@ -81,13 +101,13 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('internet-identity/:principalId')
|
@Post('internet-identity')
|
||||||
public async internetIdentityLogin(
|
public async internetIdentityLogin(
|
||||||
@Param('principalId') principalId: string
|
@Body() body: { principalId: string }
|
||||||
): Promise<OAuthResponse> {
|
): Promise<OAuthResponse> {
|
||||||
try {
|
try {
|
||||||
const authToken = await this.authService.validateInternetIdentityLogin(
|
const authToken = await this.authService.validateInternetIdentityLogin(
|
||||||
principalId
|
body.principalId
|
||||||
);
|
);
|
||||||
return { authToken };
|
return { authToken };
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -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';
|
||||||
|
@ -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';
|
||||||
|
@ -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,40 @@ 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,
|
({
|
||||||
isExcluded: true,
|
accountType,
|
||||||
name: true,
|
balance,
|
||||||
platformId: true
|
comment,
|
||||||
},
|
currency,
|
||||||
where: { userId }
|
id,
|
||||||
});
|
isExcluded,
|
||||||
|
name,
|
||||||
|
platformId
|
||||||
|
}) => {
|
||||||
|
return {
|
||||||
|
accountType,
|
||||||
|
balance,
|
||||||
|
comment,
|
||||||
|
currency,
|
||||||
|
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 }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -2,9 +2,9 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
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 { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
@ -18,6 +18,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
public indexHtmlIt = '';
|
public indexHtmlIt = '';
|
||||||
public indexHtmlNl = '';
|
public indexHtmlNl = '';
|
||||||
public indexHtmlPt = '';
|
public indexHtmlPt = '';
|
||||||
|
public sitemapXml = '';
|
||||||
|
|
||||||
|
private static readonly DEFAULT_DESCRIPTION =
|
||||||
|
'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService
|
||||||
@ -51,6 +55,10 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
this.getPathOfIndexHtmlFile('pt'),
|
this.getPathOfIndexHtmlFile('pt'),
|
||||||
'utf8'
|
'utf8'
|
||||||
);
|
);
|
||||||
|
this.sitemapXml = fs.readFileSync(
|
||||||
|
path.join(__dirname, 'assets', 'sitemap.xml'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,6 +95,25 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
) {
|
) {
|
||||||
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
|
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
|
||||||
title = `Ghostfolio meets Umbrel - ${title}`;
|
title = `Ghostfolio meets Umbrel - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith(
|
||||||
|
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
|
||||||
|
title = `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith(
|
||||||
|
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/20230520.jpg';
|
||||||
|
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
|
||||||
|
} else if (
|
||||||
|
request.path.startsWith('/en/blog/2023/07/exploring-the-path-to-fire')
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/20230701.jpg';
|
||||||
|
title = `Exploring the Path to FIRE - ${title}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -96,12 +123,21 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
) {
|
) {
|
||||||
// Skip
|
// Skip
|
||||||
next();
|
next();
|
||||||
|
} else if (request.path === '/sitemap.xml') {
|
||||||
|
response.setHeader('content-type', 'application/xml');
|
||||||
|
response.send(
|
||||||
|
this.interpolate(this.sitemapXml, {
|
||||||
|
currentDate: format(getYesterday(), DATE_FORMAT)
|
||||||
|
})
|
||||||
|
);
|
||||||
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
|
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
|
||||||
response.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlDe, {
|
this.interpolate(this.indexHtmlDe, {
|
||||||
currentDate,
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
title,
|
title,
|
||||||
|
description:
|
||||||
|
'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
|
||||||
languageCode: 'de',
|
languageCode: 'de',
|
||||||
path: request.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
@ -113,6 +149,8 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
currentDate,
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
title,
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
|
||||||
languageCode: 'es',
|
languageCode: 'es',
|
||||||
path: request.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
@ -121,7 +159,11 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
||||||
response.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlFr, {
|
this.interpolate(this.indexHtmlFr, {
|
||||||
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
|
||||||
languageCode: 'fr',
|
languageCode: 'fr',
|
||||||
path: request.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
@ -133,6 +175,8 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
currentDate,
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
title,
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
|
||||||
languageCode: 'it',
|
languageCode: 'it',
|
||||||
path: request.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
@ -144,6 +188,8 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
currentDate,
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
title,
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.',
|
||||||
languageCode: 'nl',
|
languageCode: 'nl',
|
||||||
path: request.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
@ -152,7 +198,11 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
||||||
response.send(
|
response.send(
|
||||||
this.interpolate(this.indexHtmlPt, {
|
this.interpolate(this.indexHtmlPt, {
|
||||||
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
|
title,
|
||||||
|
description:
|
||||||
|
'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
|
||||||
languageCode: 'pt',
|
languageCode: 'pt',
|
||||||
path: request.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
@ -164,6 +214,7 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
currentDate,
|
currentDate,
|
||||||
featureGraphicPath,
|
featureGraphicPath,
|
||||||
title,
|
title,
|
||||||
|
description: FrontendMiddleware.DEFAULT_DESCRIPTION,
|
||||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||||
path: request.path,
|
path: request.path,
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
@ -189,7 +240,13 @@ export class FrontendMiddleware implements NestMiddleware {
|
|||||||
private isFileRequest(filename: string) {
|
private isFileRequest(filename: string) {
|
||||||
if (filename === '/assets/LICENSE') {
|
if (filename === '/assets/LICENSE') {
|
||||||
return true;
|
return true;
|
||||||
} else if (filename.includes('auth/ey')) {
|
} else if (
|
||||||
|
filename === '/sitemap.xml' ||
|
||||||
|
filename.includes('auth/ey') ||
|
||||||
|
filename.includes(
|
||||||
|
'personal-finance-tools/open-source-alternative-to-markets.sh'
|
||||||
|
)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
44
apps/api/src/app/health/health.controller.ts
Normal file
44
apps/api/src/app/health/health.controller.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Param,
|
||||||
|
UseInterceptors
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
public constructor(private readonly healthService: HealthService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
public async getHealth() {}
|
||||||
|
|
||||||
|
@Get('data-provider/:dataSource')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async getHealthOfDataProvider(
|
||||||
|
@Param('dataSource') dataSource: DataSource
|
||||||
|
) {
|
||||||
|
if (!DataSource[dataSource]) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasResponse = await this.healthService.hasResponseFromDataProvider(
|
||||||
|
dataSource
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasResponse !== true) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||||
|
StatusCodes.SERVICE_UNAVAILABLE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/health/health.module.ts
Normal file
13
apps/api/src/app/health/health.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
imports: [ConfigurationModule, DataProviderModule],
|
||||||
|
providers: [HealthService]
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
14
apps/api/src/app/health/health.service.ts
Normal file
14
apps/api/src/app/health/health.service.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HealthService {
|
||||||
|
public constructor(
|
||||||
|
private readonly dataProviderService: DataProviderService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async hasResponseFromDataProvider(aDataSource: DataSource) {
|
||||||
|
return this.dataProviderService.checkQuote(aDataSource);
|
||||||
|
}
|
||||||
|
}
|
@ -1,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,42 @@
|
|||||||
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 {
|
||||||
|
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, 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 +80,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 +146,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 +177,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 +208,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 +226,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 };
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -210,16 +251,41 @@ export class ImportService {
|
|||||||
for (const {
|
for (const {
|
||||||
accountId,
|
accountId,
|
||||||
comment,
|
comment,
|
||||||
currency,
|
date,
|
||||||
dataSource,
|
error,
|
||||||
date: dateString,
|
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
symbol,
|
SymbolProfile,
|
||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
} of activitiesDto) {
|
} of activitiesExtendedWithErrors) {
|
||||||
const date = parseISO(<string>(<unknown>dateString));
|
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;
|
||||||
});
|
});
|
||||||
@ -245,28 +311,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 +361,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 +387,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 +502,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([
|
||||||
@ -389,7 +545,8 @@ export class ImportService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
assetProfiles[symbol] = assetProfile;
|
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||||
|
assetProfile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
@ -26,6 +27,7 @@ import { InfoService } from './info.service';
|
|||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
}),
|
}),
|
||||||
|
PlatformModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
|
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 +17,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 bent from 'bent';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
@ -37,6 +43,7 @@ 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 platformService: PlatformService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
@ -46,9 +53,12 @@ export class InfoService {
|
|||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
const info: Partial<InfoItem> = {};
|
const info: Partial<InfoItem> = {};
|
||||||
let isReadOnlyMode: boolean;
|
let isReadOnlyMode: boolean;
|
||||||
const platforms = await this.prismaService.platform.findMany({
|
const platforms = (
|
||||||
orderBy: { name: 'asc' },
|
await this.platformService.getPlatforms({
|
||||||
select: { id: true, name: true }
|
orderBy: { name: 'asc' }
|
||||||
|
})
|
||||||
|
).map(({ id, name }) => {
|
||||||
|
return { id, name };
|
||||||
});
|
});
|
||||||
let systemMessage: string;
|
let systemMessage: string;
|
||||||
|
|
||||||
@ -109,19 +119,28 @@ 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,
|
||||||
|
tags,
|
||||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||||
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
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()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -285,6 +304,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 +313,8 @@ export class InfoService {
|
|||||||
gitHubContributors,
|
gitHubContributors,
|
||||||
gitHubStargazers,
|
gitHubStargazers,
|
||||||
newUsers30d,
|
newUsers30d,
|
||||||
slackCommunityUsers
|
slackCommunityUsers,
|
||||||
|
uptime
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.redisCacheService.set(
|
await this.redisCacheService.set(
|
||||||
@ -304,19 +325,49 @@ export class InfoService {
|
|||||||
return statistics;
|
return statistics;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSubscriptions(): Promise<Subscription[]> {
|
private async getSubscriptions(): Promise<{
|
||||||
|
[offer in SubscriptionOffer]: Subscription;
|
||||||
|
}> {
|
||||||
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let subscriptions: Subscription[] = [];
|
|
||||||
|
|
||||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||||
})) ?? { value: '{}' };
|
})) ?? { value: '{}' };
|
||||||
|
|
||||||
subscriptions = [JSON.parse(stripeConfig.value)];
|
return JSON.parse(stripeConfig.value);
|
||||||
|
}
|
||||||
|
|
||||||
return subscriptions;
|
private async getUptime(): Promise<number> {
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
const monitorId = (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
||||||
|
)) as string;
|
||||||
|
|
||||||
|
const get = bent(
|
||||||
|
`https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||||
|
subDays(new Date(), 90),
|
||||||
|
DATE_FORMAT
|
||||||
|
)}&to${format(new Date(), DATE_FORMAT)}`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
Authorization: `Bearer ${this.configurationService.get(
|
||||||
|
'BETTER_UPTIME_API_KEY'
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data } = await get();
|
||||||
|
return data.attributes.availability / 100;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'InfoService');
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,4 +1,4 @@
|
|||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { HttpException, Injectable } from '@nestjs/common';
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
@ -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,19 @@ 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 (!order.isDraft) {
|
||||||
|
// Gather symbol data in the background, if 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,16 @@ 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 === '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,27 +118,20 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue(
|
this.dataGatheringService.addJobToQueue({
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
data: {
|
||||||
{
|
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
},
|
},
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
);
|
opts: {
|
||||||
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
jobId: getAssetProfileIdentifier({
|
||||||
|
|
||||||
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
|
||||||
}
|
})
|
||||||
]);
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
delete data.assetClass;
|
delete data.assetClass;
|
||||||
@ -146,11 +145,17 @@ 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 === 'LIABILITY'
|
||||||
|
? false
|
||||||
|
: isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
|
const order = await this.prismaService.order.create({
|
||||||
data: {
|
data: {
|
||||||
...orderData,
|
...orderData,
|
||||||
Account,
|
Account,
|
||||||
@ -162,6 +167,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 +197,21 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
if (order.type === 'ITEM') {
|
if (order.type === 'ITEM' || order.type === 'LIABILITY') {
|
||||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> {
|
||||||
|
const { count } = await this.prismaService.order.deleteMany({
|
||||||
|
where
|
||||||
|
});
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
public async getOrders({
|
public async getOrders({
|
||||||
filters,
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
@ -282,7 +316,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 +368,7 @@ export class OrderService {
|
|||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
||||||
delete data.SymbolProfile.connect;
|
delete data.SymbolProfile.connect;
|
||||||
} else {
|
} else {
|
||||||
delete data.SymbolProfile.update;
|
delete data.SymbolProfile.update;
|
||||||
|
@ -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,9 +88,19 @@ describe('CurrentRateService', () => {
|
|||||||
let dataProviderService: DataProviderService;
|
let dataProviderService: DataProviderService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
let marketDataService: MarketDataService;
|
let marketDataService: MarketDataService;
|
||||||
|
let propertyService: PropertyService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
dataProviderService = new DataProviderService(null, [], null);
|
propertyService = new PropertyService(null);
|
||||||
|
|
||||||
|
dataProviderService = new DataProviderService(
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
propertyService,
|
||||||
|
null
|
||||||
|
);
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@ -104,21 +130,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 symbol ${currentPosition.symbol}`,
|
||||||
'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,8 +8,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 { 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 { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
@ -91,6 +91,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 +134,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 +161,12 @@ export class PortfolioController {
|
|||||||
'emergencyFund',
|
'emergencyFund',
|
||||||
'excludedAccountsAndActivities',
|
'excludedAccountsAndActivities',
|
||||||
'fees',
|
'fees',
|
||||||
|
'fireWealth',
|
||||||
'items',
|
'items',
|
||||||
|
'liabilities',
|
||||||
'netWorth',
|
'netWorth',
|
||||||
'totalBuy',
|
'totalBuy',
|
||||||
|
'totalInvestment',
|
||||||
'totalSell'
|
'totalSell'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -172,6 +179,9 @@ export class PortfolioController {
|
|||||||
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 +192,7 @@ export class PortfolioController {
|
|||||||
filteredValueInPercentage,
|
filteredValueInPercentage,
|
||||||
hasError,
|
hasError,
|
||||||
holdings,
|
holdings,
|
||||||
|
platforms,
|
||||||
totalValueInBaseCurrency,
|
totalValueInBaseCurrency,
|
||||||
summary: portfolioSummary
|
summary: portfolioSummary
|
||||||
};
|
};
|
||||||
@ -253,11 +264,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 +285,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 +299,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')
|
||||||
@ -428,7 +450,8 @@ export class PortfolioController {
|
|||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
portfolioPublicDetails.holdings[symbol] = {
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
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 +462,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,
|
||||||
|
@ -7,18 +7,15 @@ import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfol
|
|||||||
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
||||||
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
|
|
||||||
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
||||||
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
|
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
|
||||||
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-initial-investment';
|
|
||||||
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
|
||||||
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
|
|
||||||
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
EMERGENCY_FUND_TAG_ID,
|
EMERGENCY_FUND_TAG_ID,
|
||||||
MAX_CHART_ITEMS,
|
MAX_CHART_ITEMS,
|
||||||
@ -31,23 +28,23 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
HistoricalDataItem,
|
HistoricalDataItem,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
|
PortfolioInvestments,
|
||||||
PortfolioPerformanceResponse,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioPosition,
|
PortfolioPosition,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary,
|
PortfolioSummary,
|
||||||
Position,
|
Position,
|
||||||
TimelinePosition,
|
TimelinePosition,
|
||||||
UserSettings,
|
UserSettings
|
||||||
UserWithSettings
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
import type {
|
import type {
|
||||||
AccountWithValue,
|
AccountWithValue,
|
||||||
DateRange,
|
DateRange,
|
||||||
GroupBy,
|
GroupBy,
|
||||||
Market,
|
|
||||||
OrderWithAccount,
|
OrderWithAccount,
|
||||||
RequestWithUser
|
RequestWithUser,
|
||||||
|
UserWithSettings
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
@ -86,8 +83,10 @@ import {
|
|||||||
import { PortfolioCalculator } from './portfolio-calculator';
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
import { RulesService } from './rules.service';
|
import { RulesService } from './rules.service';
|
||||||
|
|
||||||
|
const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json');
|
||||||
const developedMarkets = require('../../assets/countries/developed-markets.json');
|
const developedMarkets = require('../../assets/countries/developed-markets.json');
|
||||||
const emergingMarkets = require('../../assets/countries/emerging-markets.json');
|
const emergingMarkets = require('../../assets/countries/emerging-markets.json');
|
||||||
|
const europeMarkets = require('../../assets/countries/europe-markets.json');
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
@ -149,7 +148,8 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueInBaseCurrency = details.accounts[account.id]?.current ?? 0;
|
const valueInBaseCurrency =
|
||||||
|
details.accounts[account.id]?.valueInBaseCurrency ?? 0;
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
...account,
|
...account,
|
||||||
@ -254,13 +254,15 @@ export class PortfolioService {
|
|||||||
dateRange,
|
dateRange,
|
||||||
filters,
|
filters,
|
||||||
groupBy,
|
groupBy,
|
||||||
impersonationId
|
impersonationId,
|
||||||
|
savingsRate
|
||||||
}: {
|
}: {
|
||||||
dateRange: DateRange;
|
dateRange: DateRange;
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
groupBy?: GroupBy;
|
groupBy?: GroupBy;
|
||||||
impersonationId: string;
|
impersonationId: string;
|
||||||
}): Promise<InvestmentItem[]> {
|
savingsRate: number;
|
||||||
|
}): Promise<PortfolioInvestments> {
|
||||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const { portfolioOrders, transactionPoints } =
|
const { portfolioOrders, transactionPoints } =
|
||||||
@ -278,7 +280,10 @@ export class PortfolioService {
|
|||||||
|
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
if (transactionPoints.length === 0) {
|
if (transactionPoints.length === 0) {
|
||||||
return [];
|
return {
|
||||||
|
investments: [],
|
||||||
|
streaks: { currentStreak: 0, longestStreak: 0 }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let investments: InvestmentItem[];
|
let investments: InvestmentItem[];
|
||||||
@ -348,9 +353,23 @@ export class PortfolioService {
|
|||||||
parseDate(investments[0]?.date)
|
parseDate(investments[0]?.date)
|
||||||
);
|
);
|
||||||
|
|
||||||
return investments.filter(({ date }) => {
|
investments = investments.filter(({ date }) => {
|
||||||
return !isBefore(parseDate(date), startDate);
|
return !isBefore(parseDate(date), startDate);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let streaks: PortfolioInvestments['streaks'];
|
||||||
|
|
||||||
|
if (savingsRate) {
|
||||||
|
streaks = this.getStreaks({
|
||||||
|
investments,
|
||||||
|
savingsRate: groupBy === 'year' ? 12 * savingsRate : savingsRate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
investments,
|
||||||
|
streaks
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getChart({
|
public async getChart({
|
||||||
@ -462,10 +481,18 @@ export class PortfolioService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const holdings: PortfolioDetails['holdings'] = {};
|
const holdings: PortfolioDetails['holdings'] = {};
|
||||||
const totalInvestmentInBaseCurrency = currentPositions.totalInvestment.plus(
|
const totalValueInBaseCurrency = currentPositions.currentValue.plus(
|
||||||
cashDetails.balanceInBaseCurrency
|
cashDetails.balanceInBaseCurrency
|
||||||
);
|
);
|
||||||
let filteredValueInBaseCurrency = currentPositions.currentValue;
|
|
||||||
|
const isFilteredByAccount =
|
||||||
|
filters?.some((filter) => {
|
||||||
|
return filter.type === 'ACCOUNT';
|
||||||
|
}) ?? false;
|
||||||
|
|
||||||
|
let filteredValueInBaseCurrency = isFilteredByAccount
|
||||||
|
? totalValueInBaseCurrency
|
||||||
|
: currentPositions.currentValue;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
filters?.length === 0 ||
|
filters?.length === 0 ||
|
||||||
@ -478,19 +505,18 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataGatheringItems = currentPositions.positions.map((position) => {
|
const dataGatheringItems = currentPositions.positions.map(
|
||||||
return {
|
({ dataSource, symbol }) => {
|
||||||
dataSource: position.dataSource,
|
return {
|
||||||
symbol: position.symbol
|
dataSource,
|
||||||
};
|
symbol
|
||||||
});
|
};
|
||||||
const symbols = currentPositions.positions.map(
|
}
|
||||||
(position) => position.symbol
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
this.dataProviderService.getQuotes(dataGatheringItems),
|
this.dataProviderService.getQuotes({ items: dataGatheringItems }),
|
||||||
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
|
this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
||||||
@ -513,30 +539,79 @@ export class PortfolioService {
|
|||||||
const symbolProfile = symbolProfileMap[item.symbol];
|
const symbolProfile = symbolProfileMap[item.symbol];
|
||||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||||
|
|
||||||
const markets: { [key in Market]: number } = {
|
const markets: PortfolioPosition['markets'] = {
|
||||||
|
[UNKNOWN_KEY]: 0,
|
||||||
developedMarkets: 0,
|
developedMarkets: 0,
|
||||||
emergingMarkets: 0,
|
emergingMarkets: 0,
|
||||||
otherMarkets: 0
|
otherMarkets: 0
|
||||||
};
|
};
|
||||||
|
const marketsAdvanced: PortfolioPosition['marketsAdvanced'] = {
|
||||||
|
[UNKNOWN_KEY]: 0,
|
||||||
|
asiaPacific: 0,
|
||||||
|
emergingMarkets: 0,
|
||||||
|
europe: 0,
|
||||||
|
japan: 0,
|
||||||
|
northAmerica: 0,
|
||||||
|
otherMarkets: 0
|
||||||
|
};
|
||||||
|
|
||||||
for (const country of symbolProfile.countries) {
|
if (symbolProfile.countries.length > 0) {
|
||||||
if (developedMarkets.includes(country.code)) {
|
for (const country of symbolProfile.countries) {
|
||||||
markets.developedMarkets = new Big(markets.developedMarkets)
|
if (developedMarkets.includes(country.code)) {
|
||||||
.plus(country.weight)
|
markets.developedMarkets = new Big(markets.developedMarkets)
|
||||||
.toNumber();
|
.plus(country.weight)
|
||||||
} else if (emergingMarkets.includes(country.code)) {
|
.toNumber();
|
||||||
markets.emergingMarkets = new Big(markets.emergingMarkets)
|
} else if (emergingMarkets.includes(country.code)) {
|
||||||
.plus(country.weight)
|
markets.emergingMarkets = new Big(markets.emergingMarkets)
|
||||||
.toNumber();
|
.plus(country.weight)
|
||||||
} else {
|
.toNumber();
|
||||||
markets.otherMarkets = new Big(markets.otherMarkets)
|
} else {
|
||||||
.plus(country.weight)
|
markets.otherMarkets = new Big(markets.otherMarkets)
|
||||||
.toNumber();
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (country.code === 'JP') {
|
||||||
|
marketsAdvanced.japan = new Big(marketsAdvanced.japan)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
} else if (country.code === 'CA' || country.code === 'US') {
|
||||||
|
marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
} else if (asiaPacificMarkets.includes(country.code)) {
|
||||||
|
marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
} else if (emergingMarkets.includes(country.code)) {
|
||||||
|
marketsAdvanced.emergingMarkets = new Big(
|
||||||
|
marketsAdvanced.emergingMarkets
|
||||||
|
)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
} else if (europeMarkets.includes(country.code)) {
|
||||||
|
marketsAdvanced.europe = new Big(marketsAdvanced.europe)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
} else {
|
||||||
|
marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets)
|
||||||
|
.plus(country.weight)
|
||||||
|
.toNumber();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY])
|
||||||
|
.plus(value)
|
||||||
|
.toNumber();
|
||||||
|
|
||||||
|
marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY])
|
||||||
|
.plus(value)
|
||||||
|
.toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
holdings[item.symbol] = {
|
holdings[item.symbol] = {
|
||||||
markets,
|
markets,
|
||||||
|
marketsAdvanced,
|
||||||
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
|
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
|
||||||
? 0
|
? 0
|
||||||
: value.div(filteredValueInBaseCurrency).toNumber(),
|
: value.div(filteredValueInBaseCurrency).toNumber(),
|
||||||
@ -558,18 +633,18 @@ export class PortfolioService {
|
|||||||
quantity: item.quantity.toNumber(),
|
quantity: item.quantity.toNumber(),
|
||||||
sectors: symbolProfile.sectors,
|
sectors: symbolProfile.sectors,
|
||||||
symbol: item.symbol,
|
symbol: item.symbol,
|
||||||
|
tags: item.tags,
|
||||||
transactionCount: item.transactionCount,
|
transactionCount: item.transactionCount,
|
||||||
url: symbolProfile.url,
|
url: symbolProfile.url,
|
||||||
value: value.toNumber()
|
valueInBaseCurrency: value.toNumber()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const isFilteredByCash = filters?.some((filter) => {
|
||||||
filters?.length === 0 ||
|
return filter.type === 'ASSET_CLASS' && filter.id === 'CASH';
|
||||||
(filters?.length === 1 &&
|
});
|
||||||
filters[0].type === 'ASSET_CLASS' &&
|
|
||||||
filters[0].id === 'CASH')
|
if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) {
|
||||||
) {
|
|
||||||
const cashPositions = await this.getCashPositions({
|
const cashPositions = await this.getCashPositions({
|
||||||
cashDetails,
|
cashDetails,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
@ -581,7 +656,7 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await this.getValueOfAccounts({
|
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
|
||||||
filters,
|
filters,
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
@ -595,7 +670,7 @@ export class PortfolioService {
|
|||||||
filters[0].id === EMERGENCY_FUND_TAG_ID &&
|
filters[0].id === EMERGENCY_FUND_TAG_ID &&
|
||||||
filters[0].type === 'TAG'
|
filters[0].type === 'TAG'
|
||||||
) {
|
) {
|
||||||
const cashPositions = await this.getCashPositions({
|
const emergencyFundCashPositions = await this.getCashPositions({
|
||||||
cashDetails,
|
cashDetails,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
value: filteredValueInBaseCurrency
|
value: filteredValueInBaseCurrency
|
||||||
@ -604,7 +679,7 @@ export class PortfolioService {
|
|||||||
const emergencyFundInCash = emergencyFund
|
const emergencyFundInCash = emergencyFund
|
||||||
.minus(
|
.minus(
|
||||||
this.getEmergencyFundPositionsValueInBaseCurrency({
|
this.getEmergencyFundPositionsValueInBaseCurrency({
|
||||||
activities: orders
|
holdings
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
@ -614,15 +689,14 @@ export class PortfolioService {
|
|||||||
accounts[UNKNOWN_KEY] = {
|
accounts[UNKNOWN_KEY] = {
|
||||||
balance: 0,
|
balance: 0,
|
||||||
currency: userCurrency,
|
currency: userCurrency,
|
||||||
current: emergencyFundInCash,
|
|
||||||
name: UNKNOWN_KEY,
|
name: UNKNOWN_KEY,
|
||||||
original: emergencyFundInCash
|
valueInBaseCurrency: emergencyFundInCash
|
||||||
};
|
};
|
||||||
|
|
||||||
holdings[userCurrency] = {
|
holdings[userCurrency] = {
|
||||||
...cashPositions[userCurrency],
|
...emergencyFundCashPositions[userCurrency],
|
||||||
investment: emergencyFundInCash,
|
investment: emergencyFundInCash,
|
||||||
value: emergencyFundInCash
|
valueInBaseCurrency: emergencyFundInCash
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -633,13 +707,14 @@ export class PortfolioService {
|
|||||||
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
|
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
|
||||||
emergencyFundPositionsValueInBaseCurrency:
|
emergencyFundPositionsValueInBaseCurrency:
|
||||||
this.getEmergencyFundPositionsValueInBaseCurrency({
|
this.getEmergencyFundPositionsValueInBaseCurrency({
|
||||||
activities: orders
|
holdings
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accounts,
|
accounts,
|
||||||
holdings,
|
holdings,
|
||||||
|
platforms,
|
||||||
summary,
|
summary,
|
||||||
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
|
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
|
||||||
filteredValueInPercentage: summary.netWorth
|
filteredValueInPercentage: summary.netWorth
|
||||||
@ -718,6 +793,7 @@ export class PortfolioService {
|
|||||||
name: order.SymbolProfile?.name,
|
name: order.SymbolProfile?.name,
|
||||||
quantity: new Big(order.quantity),
|
quantity: new Big(order.quantity),
|
||||||
symbol: order.SymbolProfile.symbol,
|
symbol: order.SymbolProfile.symbol,
|
||||||
|
tags: order.tags,
|
||||||
type: order.type,
|
type: order.type,
|
||||||
unitPrice: new Big(order.unitPrice)
|
unitPrice: new Big(order.unitPrice)
|
||||||
}));
|
}));
|
||||||
@ -792,16 +868,6 @@ export class PortfolioService {
|
|||||||
let maxPrice = Math.max(orders[0].unitPrice, marketPrice);
|
let maxPrice = Math.max(orders[0].unitPrice, marketPrice);
|
||||||
let minPrice = Math.min(orders[0].unitPrice, marketPrice);
|
let minPrice = Math.min(orders[0].unitPrice, marketPrice);
|
||||||
|
|
||||||
if (!historicalData?.[aSymbol]?.[firstBuyDate]) {
|
|
||||||
// Add historical entry for buy date, if no historical data available
|
|
||||||
historicalDataArray.push({
|
|
||||||
averagePrice: orders[0].unitPrice,
|
|
||||||
date: firstBuyDate,
|
|
||||||
marketPrice: orders[0].unitPrice,
|
|
||||||
quantity: orders[0].quantity
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (historicalData[aSymbol]) {
|
if (historicalData[aSymbol]) {
|
||||||
let j = -1;
|
let j = -1;
|
||||||
for (const [date, { marketPrice }] of Object.entries(
|
for (const [date, { marketPrice }] of Object.entries(
|
||||||
@ -813,11 +879,16 @@ export class PortfolioService {
|
|||||||
) {
|
) {
|
||||||
j++;
|
j++;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentAveragePrice = 0;
|
let currentAveragePrice = 0;
|
||||||
let currentQuantity = 0;
|
let currentQuantity = 0;
|
||||||
|
|
||||||
const currentSymbol = transactionPoints[j].items.find(
|
const currentSymbol = transactionPoints[j].items.find(
|
||||||
(item) => item.symbol === aSymbol
|
({ symbol }) => {
|
||||||
|
return symbol === aSymbol;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentSymbol) {
|
if (currentSymbol) {
|
||||||
currentAveragePrice = currentSymbol.quantity.eq(0)
|
currentAveragePrice = currentSymbol.quantity.eq(0)
|
||||||
? 0
|
? 0
|
||||||
@ -827,14 +898,25 @@ export class PortfolioService {
|
|||||||
|
|
||||||
historicalDataArray.push({
|
historicalDataArray.push({
|
||||||
date,
|
date,
|
||||||
marketPrice,
|
|
||||||
averagePrice: currentAveragePrice,
|
averagePrice: currentAveragePrice,
|
||||||
|
marketPrice:
|
||||||
|
historicalDataArray.length > 0
|
||||||
|
? marketPrice
|
||||||
|
: currentAveragePrice,
|
||||||
quantity: currentQuantity
|
quantity: currentQuantity
|
||||||
});
|
});
|
||||||
|
|
||||||
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
|
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
|
||||||
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
|
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Add historical entry for buy date, if no historical data available
|
||||||
|
historicalDataArray.push({
|
||||||
|
averagePrice: orders[0].unitPrice,
|
||||||
|
date: firstBuyDate,
|
||||||
|
marketPrice: orders[0].unitPrice,
|
||||||
|
quantity: orders[0].quantity
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -869,9 +951,9 @@ export class PortfolioService {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const currentData = await this.dataProviderService.getQuotes([
|
const currentData = await this.dataProviderService.getQuotes({
|
||||||
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
|
items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }]
|
||||||
]);
|
});
|
||||||
const marketPrice = currentData[aSymbol]?.marketPrice;
|
const marketPrice = currentData[aSymbol]?.marketPrice;
|
||||||
|
|
||||||
let historicalData = await this.dataProviderService.getHistorical(
|
let historicalData = await this.dataProviderService.getHistorical(
|
||||||
@ -972,18 +1054,20 @@ export class PortfolioService {
|
|||||||
(item) => !item.quantity.eq(0)
|
(item) => !item.quantity.eq(0)
|
||||||
);
|
);
|
||||||
|
|
||||||
const dataGatheringItem = positions.map((position) => {
|
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
dataSource: position.dataSource,
|
dataSource,
|
||||||
symbol: position.symbol
|
symbol
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const symbols = positions.map((position) => position.symbol);
|
|
||||||
|
|
||||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||||
this.dataProviderService.getQuotes(dataGatheringItem),
|
this.dataProviderService.getQuotes({ items: dataGatheringItems }),
|
||||||
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
|
this.symbolProfileService.getSymbolProfiles(
|
||||||
|
positions.map(({ dataSource, symbol }) => {
|
||||||
|
return { dataSource, symbol };
|
||||||
|
})
|
||||||
|
)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
||||||
@ -1168,7 +1252,7 @@ export class PortfolioService {
|
|||||||
portfolioItemsNow[position.symbol] = position;
|
portfolioItemsNow[position.symbol] = position;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await this.getValueOfAccounts({
|
const { accounts } = await this.getValueOfAccountsAndPlatforms({
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
@ -1179,10 +1263,6 @@ export class PortfolioService {
|
|||||||
rules: {
|
rules: {
|
||||||
accountClusterRisk: await this.rulesService.evaluate(
|
accountClusterRisk: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
new AccountClusterRiskInitialInvestment(
|
|
||||||
this.exchangeRateDataService,
|
|
||||||
accounts
|
|
||||||
),
|
|
||||||
new AccountClusterRiskCurrentInvestment(
|
new AccountClusterRiskCurrentInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
accounts
|
accounts
|
||||||
@ -1196,18 +1276,10 @@ export class PortfolioService {
|
|||||||
),
|
),
|
||||||
currencyClusterRisk: await this.rulesService.evaluate(
|
currencyClusterRisk: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
|
|
||||||
this.exchangeRateDataService,
|
|
||||||
positions
|
|
||||||
),
|
|
||||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
positions
|
positions
|
||||||
),
|
),
|
||||||
new CurrencyClusterRiskInitialInvestment(
|
|
||||||
this.exchangeRateDataService,
|
|
||||||
positions
|
|
||||||
),
|
|
||||||
new CurrencyClusterRiskCurrentInvestment(
|
new CurrencyClusterRiskCurrentInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
positions
|
positions
|
||||||
@ -1258,7 +1330,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
if (cashPositions[account.currency]) {
|
if (cashPositions[account.currency]) {
|
||||||
cashPositions[account.currency].investment += convertedBalance;
|
cashPositions[account.currency].investment += convertedBalance;
|
||||||
cashPositions[account.currency].value += convertedBalance;
|
cashPositions[account.currency].valueInBaseCurrency += convertedBalance;
|
||||||
} else {
|
} else {
|
||||||
cashPositions[account.currency] = this.getInitialCashPosition({
|
cashPositions[account.currency] = this.getInitialCashPosition({
|
||||||
balance: convertedBalance,
|
balance: convertedBalance,
|
||||||
@ -1270,7 +1342,9 @@ export class PortfolioService {
|
|||||||
for (const symbol of Object.keys(cashPositions)) {
|
for (const symbol of Object.keys(cashPositions)) {
|
||||||
// Calculate allocations for each currency
|
// Calculate allocations for each currency
|
||||||
cashPositions[symbol].allocationInPercentage = value.gt(0)
|
cashPositions[symbol].allocationInPercentage = value.gt(0)
|
||||||
? new Big(cashPositions[symbol].value).div(value).toNumber()
|
? new Big(cashPositions[symbol].valueInBaseCurrency)
|
||||||
|
.div(value)
|
||||||
|
.toNumber()
|
||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1284,12 +1358,11 @@ export class PortfolioService {
|
|||||||
}: {
|
}: {
|
||||||
activities: OrderWithAccount[];
|
activities: OrderWithAccount[];
|
||||||
date?: Date;
|
date?: Date;
|
||||||
|
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
}) {
|
}) {
|
||||||
return activities
|
return activities
|
||||||
.filter((activity) => {
|
.filter((activity) => {
|
||||||
// Filter out all activities before given date and type dividend
|
// Filter out all activities before given date (drafts) and type dividend
|
||||||
return (
|
return (
|
||||||
isBefore(date, new Date(activity.date)) &&
|
isBefore(date, new Date(activity.date)) &&
|
||||||
activity.type === TypeOfOrder.DIVIDEND
|
activity.type === TypeOfOrder.DIVIDEND
|
||||||
@ -1371,13 +1444,13 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getEmergencyFundPositionsValueInBaseCurrency({
|
private getEmergencyFundPositionsValueInBaseCurrency({
|
||||||
activities
|
holdings
|
||||||
}: {
|
}: {
|
||||||
activities: Activity[];
|
holdings: PortfolioDetails['holdings'];
|
||||||
}) {
|
}) {
|
||||||
const emergencyFundOrders = activities.filter((activity) => {
|
const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => {
|
||||||
return (
|
return (
|
||||||
activity.tags?.some(({ id }) => {
|
tags?.some(({ id }) => {
|
||||||
return id === EMERGENCY_FUND_TAG_ID;
|
return id === EMERGENCY_FUND_TAG_ID;
|
||||||
}) ?? false
|
}) ?? false
|
||||||
);
|
);
|
||||||
@ -1385,18 +1458,9 @@ export class PortfolioService {
|
|||||||
|
|
||||||
let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0);
|
let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0);
|
||||||
|
|
||||||
for (const order of emergencyFundOrders) {
|
for (const { valueInBaseCurrency } of emergencyFundHoldings) {
|
||||||
if (order.type === 'BUY') {
|
valueInBaseCurrencyOfEmergencyFundPositions =
|
||||||
valueInBaseCurrencyOfEmergencyFundPositions =
|
valueInBaseCurrencyOfEmergencyFundPositions.plus(valueInBaseCurrency);
|
||||||
valueInBaseCurrencyOfEmergencyFundPositions.plus(
|
|
||||||
order.valueInBaseCurrency
|
|
||||||
);
|
|
||||||
} else if (order.type === 'SELL') {
|
|
||||||
valueInBaseCurrencyOfEmergencyFundPositions =
|
|
||||||
valueInBaseCurrencyOfEmergencyFundPositions.minus(
|
|
||||||
order.valueInBaseCurrency
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return valueInBaseCurrencyOfEmergencyFundPositions.toNumber();
|
return valueInBaseCurrencyOfEmergencyFundPositions.toNumber();
|
||||||
@ -1413,7 +1477,7 @@ export class PortfolioService {
|
|||||||
}) {
|
}) {
|
||||||
return activities
|
return activities
|
||||||
.filter((activity) => {
|
.filter((activity) => {
|
||||||
// Filter out all activities before given date
|
// Filter out all activities before given date (drafts)
|
||||||
return isBefore(date, new Date(activity.date));
|
return isBefore(date, new Date(activity.date));
|
||||||
})
|
})
|
||||||
.map(({ fee, SymbolProfile }) => {
|
.map(({ fee, SymbolProfile }) => {
|
||||||
@ -1455,24 +1519,25 @@ export class PortfolioService {
|
|||||||
quantity: 0,
|
quantity: 0,
|
||||||
sectors: [],
|
sectors: [],
|
||||||
symbol: currency,
|
symbol: currency,
|
||||||
|
tags: [],
|
||||||
transactionCount: 0,
|
transactionCount: 0,
|
||||||
value: balance
|
valueInBaseCurrency: balance
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getItems(orders: OrderWithAccount[], date = new Date(0)) {
|
private getItems(activities: OrderWithAccount[], date = new Date(0)) {
|
||||||
return orders
|
return activities
|
||||||
.filter((order) => {
|
.filter((activity) => {
|
||||||
// Filter out all orders before given date and type item
|
// Filter out all activities before given date (drafts) and type item
|
||||||
return (
|
return (
|
||||||
isBefore(date, new Date(order.date)) &&
|
isBefore(date, new Date(activity.date)) &&
|
||||||
order.type === TypeOfOrder.ITEM
|
activity.type === TypeOfOrder.ITEM
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.map((order) => {
|
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
new Big(quantity).mul(unitPrice).toNumber(),
|
||||||
order.SymbolProfile.currency,
|
SymbolProfile.currency,
|
||||||
this.request.user.Settings.settings.baseCurrency
|
this.request.user.Settings.settings.baseCurrency
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@ -1482,6 +1547,30 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getLiabilities({
|
||||||
|
activities,
|
||||||
|
userCurrency
|
||||||
|
}: {
|
||||||
|
activities: OrderWithAccount[];
|
||||||
|
userCurrency: string;
|
||||||
|
}) {
|
||||||
|
return activities
|
||||||
|
.filter(({ type }) => {
|
||||||
|
return type === TypeOfOrder.LIABILITY;
|
||||||
|
})
|
||||||
|
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
new Big(quantity).mul(unitPrice).toNumber(),
|
||||||
|
SymbolProfile.currency,
|
||||||
|
userCurrency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(previous, current) => new Big(previous).plus(current),
|
||||||
|
new Big(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||||
switch (aDateRange) {
|
switch (aDateRange) {
|
||||||
case '1d':
|
case '1d':
|
||||||
@ -1512,6 +1601,28 @@ export class PortfolioService {
|
|||||||
return portfolioStart;
|
return portfolioStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getStreaks({
|
||||||
|
investments,
|
||||||
|
savingsRate
|
||||||
|
}: {
|
||||||
|
investments: InvestmentItem[];
|
||||||
|
savingsRate: number;
|
||||||
|
}) {
|
||||||
|
let currentStreak = 0;
|
||||||
|
let longestStreak = 0;
|
||||||
|
|
||||||
|
for (const { investment } of investments) {
|
||||||
|
if (investment >= savingsRate) {
|
||||||
|
currentStreak++;
|
||||||
|
longestStreak = Math.max(longestStreak, currentStreak);
|
||||||
|
} else {
|
||||||
|
currentStreak = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentStreak, longestStreak };
|
||||||
|
}
|
||||||
|
|
||||||
private async getSummary({
|
private async getSummary({
|
||||||
balanceInBaseCurrency,
|
balanceInBaseCurrency,
|
||||||
emergencyFundPositionsValueInBaseCurrency,
|
emergencyFundPositionsValueInBaseCurrency,
|
||||||
@ -1561,6 +1672,10 @@ export class PortfolioService {
|
|||||||
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
||||||
const firstOrderDate = activities[0]?.date;
|
const firstOrderDate = activities[0]?.date;
|
||||||
const items = this.getItems(activities).toNumber();
|
const items = this.getItems(activities).toNumber();
|
||||||
|
const liabilities = this.getLiabilities({
|
||||||
|
activities,
|
||||||
|
userCurrency
|
||||||
|
}).toNumber();
|
||||||
|
|
||||||
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
|
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
|
||||||
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
|
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
|
||||||
@ -1593,6 +1708,7 @@ export class PortfolioService {
|
|||||||
.plus(performanceInformation.performance.currentValue)
|
.plus(performanceInformation.performance.currentValue)
|
||||||
.plus(items)
|
.plus(items)
|
||||||
.plus(excludedAccountsAndActivities)
|
.plus(excludedAccountsAndActivities)
|
||||||
|
.minus(liabilities)
|
||||||
.toNumber();
|
.toNumber();
|
||||||
|
|
||||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||||
@ -1619,11 +1735,21 @@ export class PortfolioService {
|
|||||||
fees,
|
fees,
|
||||||
firstOrderDate,
|
firstOrderDate,
|
||||||
items,
|
items,
|
||||||
|
liabilities,
|
||||||
netWorth,
|
netWorth,
|
||||||
totalBuy,
|
totalBuy,
|
||||||
totalSell,
|
totalSell,
|
||||||
committedFunds: committedFunds.toNumber(),
|
committedFunds: committedFunds.toNumber(),
|
||||||
emergencyFund: emergencyFund.toNumber(),
|
emergencyFund: {
|
||||||
|
assets: emergencyFundPositionsValueInBaseCurrency,
|
||||||
|
cash: emergencyFund
|
||||||
|
.minus(emergencyFundPositionsValueInBaseCurrency)
|
||||||
|
.toNumber(),
|
||||||
|
total: emergencyFund.toNumber()
|
||||||
|
},
|
||||||
|
fireWealth: new Big(performanceInformation.performance.currentValue)
|
||||||
|
.minus(emergencyFundPositionsValueInBaseCurrency)
|
||||||
|
.toNumber(),
|
||||||
ordersCount: activities.filter(({ type }) => {
|
ordersCount: activities.filter(({ type }) => {
|
||||||
return type === 'BUY' || type === 'SELL';
|
return type === 'BUY' || type === 'SELL';
|
||||||
}).length
|
}).length
|
||||||
@ -1675,6 +1801,7 @@ export class PortfolioService {
|
|||||||
name: order.SymbolProfile?.name,
|
name: order.SymbolProfile?.name,
|
||||||
quantity: new Big(order.quantity),
|
quantity: new Big(order.quantity),
|
||||||
symbol: order.SymbolProfile.symbol,
|
symbol: order.SymbolProfile.symbol,
|
||||||
|
tags: order.tags,
|
||||||
type: order.type,
|
type: order.type,
|
||||||
unitPrice: new Big(
|
unitPrice: new Big(
|
||||||
this.exchangeRateDataService.toCurrency(
|
this.exchangeRateDataService.toCurrency(
|
||||||
@ -1700,7 +1827,7 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getValueOfAccounts({
|
private async getValueOfAccountsAndPlatforms({
|
||||||
filters = [],
|
filters = [],
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
@ -1715,15 +1842,16 @@ export class PortfolioService {
|
|||||||
userId: string;
|
userId: string;
|
||||||
withExcludedAccounts?: boolean;
|
withExcludedAccounts?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const ordersOfTypeItem = await this.orderService.getOrders({
|
const ordersOfTypeItemOrLiability = await this.orderService.getOrders({
|
||||||
filters,
|
filters,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
withExcludedAccounts,
|
withExcludedAccounts,
|
||||||
types: ['ITEM']
|
types: ['ITEM', 'LIABILITY']
|
||||||
});
|
});
|
||||||
|
|
||||||
const accounts: PortfolioDetails['accounts'] = {};
|
const accounts: PortfolioDetails['accounts'] = {};
|
||||||
|
const platforms: PortfolioDetails['platforms'] = {};
|
||||||
|
|
||||||
let currentAccounts: (Account & {
|
let currentAccounts: (Account & {
|
||||||
Order?: Order[];
|
Order?: Order[];
|
||||||
@ -1734,6 +1862,7 @@ export class PortfolioService {
|
|||||||
currentAccounts = await this.accountService.getAccounts(userId);
|
currentAccounts = await this.accountService.getAccounts(userId);
|
||||||
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
|
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
|
||||||
currentAccounts = await this.accountService.accounts({
|
currentAccounts = await this.accountService.accounts({
|
||||||
|
include: { Platform: true },
|
||||||
where: { id: filters[0].id }
|
where: { id: filters[0].id }
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -1744,6 +1873,7 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
currentAccounts = await this.accountService.accounts({
|
currentAccounts = await this.accountService.accounts({
|
||||||
|
include: { Platform: true },
|
||||||
where: { id: { in: accountIds } }
|
where: { id: { in: accountIds } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1757,76 +1887,88 @@ export class PortfolioService {
|
|||||||
return accountId === account.id;
|
return accountId === account.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
const ordersOfTypeItemByAccount = ordersOfTypeItem.filter(
|
const ordersOfTypeItemOrLiabilityByAccount =
|
||||||
({ accountId }) => {
|
ordersOfTypeItemOrLiability.filter(({ accountId }) => {
|
||||||
return accountId === account.id;
|
return accountId === account.id;
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
ordersByAccount = ordersByAccount.concat(ordersOfTypeItemByAccount);
|
ordersByAccount = ordersByAccount.concat(
|
||||||
|
ordersOfTypeItemOrLiabilityByAccount
|
||||||
|
);
|
||||||
|
|
||||||
accounts[account.id] = {
|
accounts[account.id] = {
|
||||||
balance: account.balance,
|
balance: account.balance,
|
||||||
currency: account.currency,
|
currency: account.currency,
|
||||||
current: this.exchangeRateDataService.toCurrency(
|
|
||||||
account.balance,
|
|
||||||
account.currency,
|
|
||||||
userCurrency
|
|
||||||
),
|
|
||||||
name: account.name,
|
name: account.name,
|
||||||
original: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
account.balance,
|
account.balance,
|
||||||
account.currency,
|
account.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (platforms[account.Platform?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
|
||||||
|
platforms[account.Platform?.id || UNKNOWN_KEY].valueInBaseCurrency +=
|
||||||
|
this.exchangeRateDataService.toCurrency(
|
||||||
|
account.balance,
|
||||||
|
account.currency,
|
||||||
|
userCurrency
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
platforms[account.Platform?.id || UNKNOWN_KEY] = {
|
||||||
|
balance: account.balance,
|
||||||
|
currency: account.currency,
|
||||||
|
name: account.Platform?.name,
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
account.balance,
|
||||||
|
account.currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
for (const order of ordersByAccount) {
|
for (const order of ordersByAccount) {
|
||||||
let currentValueOfSymbolInBaseCurrency =
|
let currentValueOfSymbolInBaseCurrency =
|
||||||
order.quantity *
|
order.quantity *
|
||||||
(portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ??
|
(portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ??
|
||||||
order.unitPrice ??
|
order.unitPrice ??
|
||||||
0);
|
0);
|
||||||
let originalValueOfSymbolInBaseCurrency =
|
|
||||||
this.exchangeRateDataService.toCurrency(
|
|
||||||
order.quantity * order.unitPrice,
|
|
||||||
order.SymbolProfile.currency,
|
|
||||||
userCurrency
|
|
||||||
);
|
|
||||||
|
|
||||||
if (order.type === 'SELL') {
|
if (order.type === 'LIABILITY' || order.type === 'SELL') {
|
||||||
currentValueOfSymbolInBaseCurrency *= -1;
|
currentValueOfSymbolInBaseCurrency *= -1;
|
||||||
originalValueOfSymbolInBaseCurrency *= -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
|
if (accounts[order.Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
|
||||||
accounts[order.Account?.id || UNKNOWN_KEY].current +=
|
accounts[order.Account?.id || UNKNOWN_KEY].valueInBaseCurrency +=
|
||||||
currentValueOfSymbolInBaseCurrency;
|
currentValueOfSymbolInBaseCurrency;
|
||||||
accounts[order.Account?.id || UNKNOWN_KEY].original +=
|
|
||||||
originalValueOfSymbolInBaseCurrency;
|
|
||||||
} else {
|
} else {
|
||||||
accounts[order.Account?.id || UNKNOWN_KEY] = {
|
accounts[order.Account?.id || UNKNOWN_KEY] = {
|
||||||
balance: 0,
|
balance: 0,
|
||||||
currency: order.Account?.currency,
|
currency: order.Account?.currency,
|
||||||
current: currentValueOfSymbolInBaseCurrency,
|
|
||||||
name: account.name,
|
name: account.name,
|
||||||
original: originalValueOfSymbolInBaseCurrency
|
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
platforms[order.Account?.Platform?.id || UNKNOWN_KEY]
|
||||||
|
?.valueInBaseCurrency
|
||||||
|
) {
|
||||||
|
platforms[
|
||||||
|
order.Account?.Platform?.id || UNKNOWN_KEY
|
||||||
|
].valueInBaseCurrency += currentValueOfSymbolInBaseCurrency;
|
||||||
|
} else {
|
||||||
|
platforms[order.Account?.Platform?.id || UNKNOWN_KEY] = {
|
||||||
|
balance: 0,
|
||||||
|
currency: order.Account?.currency,
|
||||||
|
name: account.Platform?.name,
|
||||||
|
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return accounts;
|
return { accounts, platforms };
|
||||||
}
|
|
||||||
|
|
||||||
private async getUserId(aImpersonationId: string, aUserId: string) {
|
|
||||||
const impersonationUserId =
|
|
||||||
await this.impersonationService.validateImpersonationId(
|
|
||||||
aImpersonationId,
|
|
||||||
aUserId
|
|
||||||
);
|
|
||||||
|
|
||||||
return impersonationUserId || aUserId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTotalByType(
|
private getTotalByType(
|
||||||
@ -1855,4 +1997,11 @@ export class PortfolioService {
|
|||||||
this.baseCurrency
|
this.baseCurrency
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||||
|
const impersonationUserId =
|
||||||
|
await this.impersonationService.validateImpersonationId(aImpersonationId);
|
||||||
|
|
||||||
|
return impersonationUserId || aUserId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 { RedisClient } from 'redis';
|
||||||
|
|
||||||
|
export interface RedisStore extends Store {
|
||||||
|
getClient: () => RedisClient;
|
||||||
|
isCacheableValue: (value: any) => boolean;
|
||||||
|
name: 'redis';
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
||||||
import * as redisStore from 'cache-manager-redis-store';
|
import * as redisStore from 'cache-manager-redis-store';
|
||||||
|
|
||||||
|
@ -1,18 +1,31 @@
|
|||||||
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, 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);
|
||||||
}
|
}
|
||||||
|
@ -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';
|
||||||
@ -101,19 +97,8 @@ export class SubscriptionService {
|
|||||||
aCheckoutSessionId
|
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 +108,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 +118,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,15 +1,18 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
@ -21,7 +24,10 @@ import { SymbolService } from './symbol.service';
|
|||||||
|
|
||||||
@Controller('symbol')
|
@Controller('symbol')
|
||||||
export class SymbolController {
|
export class SymbolController {
|
||||||
public constructor(private readonly symbolService: SymbolService) {}
|
public constructor(
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
|
private readonly symbolService: SymbolService
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Must be before /:symbol
|
* Must be before /:symbol
|
||||||
@ -30,10 +36,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 +62,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(
|
||||||
|
@ -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,21 +1,20 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
|
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
|
||||||
import {
|
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
User as IUser,
|
|
||||||
UserSettings,
|
|
||||||
UserWithSettings
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
getPermissions,
|
||||||
hasRole,
|
hasRole,
|
||||||
permissions
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
|
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma, Role, User } from '@prisma/client';
|
import { Prisma, Role, User } from '@prisma/client';
|
||||||
|
import { differenceInDays } from 'date-fns';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@ -125,7 +124,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
|
||||||
@ -167,11 +166,26 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.subscription?.type === 'Premium') {
|
if (user.subscription?.type === 'Premium') {
|
||||||
@ -199,6 +213,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;
|
||||||
});
|
});
|
||||||
@ -302,21 +320,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"
|
||||||
|
]
|
File diff suppressed because it is too large
Load Diff
519
apps/api/src/assets/sitemap.xml
Normal file
519
apps/api/src/assets/sitemap.xml
Normal file
@ -0,0 +1,519 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset
|
||||||
|
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||||
|
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/blog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/features</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/haeufig-gestellte-fragen</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/maerkte</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/open</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/preise</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/registrierung</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ressourcen</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ueber-uns</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ueber-uns/changelog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ueber-uns/datenschutzbestimmungen</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/de/ueber-uns/lizenz</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/about</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/about/changelog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/about/license</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2022/07/how-do-i-get-my-finances-in-order</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2022/08/500-stars-on-github</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2022/10/hacktoberfest-2022</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2022/11/black-friday-2022</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2022/12/the-importance-of-tracking-your-personal-finances</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2023/02/ghostfolio-meets-umbrel</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/faq</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/features</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/markets</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/open</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/pricing</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/register</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-getquin</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-parqet</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-plannix</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portfolio-dividend-tracker</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portseido</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sharesight</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-simple-portfolio</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/funcionalidades</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/mercados</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/open</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/precios</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/preguntas-mas-frecuentes</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/recursos</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/registro</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/sobre</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/sobre/changelog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/sobre/licencia</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/a-propos</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/a-propos/changelog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/a-propos/licence</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/enregistrement</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/fonctionnalites</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/foire-aux-questions</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/marches</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/open</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/prix</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/fr/ressources</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/domande-piu-frequenti</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/funzionalita</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/informazioni-su</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/informazioni-su/licenza</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/iscrizione</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/mercati</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/open</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/prezzi</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/it/risorse</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/bronnen</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/kenmerken</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/markten</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/open</loc>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/over</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/over/changelog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/over/licentie</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/prijzen</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/registratie</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/nl/vaak-gestelde-vragen</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/blog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/funcionalidades</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/mercados</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/open</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/perguntas-mais-frequentes</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/precos</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/recursos</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/registo</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/sobre</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/sobre/changelog</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/sobre/licenca</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
|
||||||
|
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
@ -16,9 +16,11 @@ export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
|||||||
export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T {
|
export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T {
|
||||||
const object = cloneDeep(aObject);
|
const object = cloneDeep(aObject);
|
||||||
|
|
||||||
keys.forEach((key) => {
|
if (object) {
|
||||||
object[key] = null;
|
keys.forEach((key) => {
|
||||||
});
|
object[key] = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { decodeDataSource } from '@ghostfolio/common/helper';
|
import { decodeDataSource } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
CallHandler,
|
CallHandler,
|
||||||
@ -5,10 +6,9 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NestInterceptor
|
NestInterceptor
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { ConfigurationService } from '../services/configuration.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransformDataSourceInRequestInterceptor<T>
|
export class TransformDataSourceInRequestInterceptor<T>
|
||||||
implements NestInterceptor<T, any>
|
implements NestInterceptor<T, any>
|
||||||
@ -25,11 +25,24 @@ export class TransformDataSourceInRequestInterceptor<T>
|
|||||||
const request = http.getRequest();
|
const request = http.getRequest();
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
if (request.body.dataSource) {
|
if (request.body.activities) {
|
||||||
|
request.body.activities = request.body.activities.map((activity) => {
|
||||||
|
if (DataSource[activity.dataSource]) {
|
||||||
|
return activity;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...activity,
|
||||||
|
dataSource: decodeDataSource(activity.dataSource)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.body.dataSource && !DataSource[request.body.dataSource]) {
|
||||||
request.body.dataSource = decodeDataSource(request.body.dataSource);
|
request.body.dataSource = decodeDataSource(request.body.dataSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.params.dataSource) {
|
if (request.params.dataSource && !DataSource[request.params.dataSource]) {
|
||||||
request.params.dataSource = decodeDataSource(request.params.dataSource);
|
request.params.dataSource = decodeDataSource(request.params.dataSource);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user