Compare commits
337 Commits
Author | SHA1 | Date | |
---|---|---|---|
51ca26bb4d | |||
2ecc8dbc4e | |||
c0e0e2401e | |||
1a30c180bc | |||
39d4f80f36 | |||
3693091ad6 | |||
bf52f1137d | |||
54ea6c84b4 | |||
689e50ae1a | |||
677757fdf0 | |||
58d9816f01 | |||
5f3d445f1d | |||
fce6caebc2 | |||
d0a4f5c000 | |||
b5e2a3aa91 | |||
f47883fb0b | |||
2932744a68 | |||
73c0f02e06 | |||
382fe24f29 | |||
908876ca6e | |||
99cf9f8802 | |||
7444ff97fc | |||
834a48466e | |||
a9526430c2 | |||
fce3b2084e | |||
f5a50a95de | |||
06dfb91f82 | |||
be36050d76 | |||
7931e6950d | |||
04eb452e04 | |||
6f7e370fca | |||
b4a126280f | |||
2d009aacc4 | |||
9116443305 | |||
0adaf12a01 | |||
b6562b6e2c | |||
b0a4b09ef5 | |||
ad8b9ad333 | |||
809956f210 | |||
6077bfa754 | |||
09498bd804 | |||
fd84f4ec14 | |||
c711a11d6e | |||
8232b05f62 | |||
0ea66aebcb | |||
64087de3fc | |||
7082ff12f8 | |||
1c7d92e15e | |||
a53461d257 | |||
d630fb900d | |||
51e8555fa5 | |||
9db675b955 | |||
45bd8ed029 | |||
707fd31550 | |||
6e5f0086a1 | |||
97bcd8ff49 | |||
1809fc8a80 | |||
beb24f9bd4 | |||
ae57a188f5 | |||
23db85e940 | |||
bd8bb1a36a | |||
c48670ccdc | |||
fc019002e2 | |||
4282cb66b8 | |||
1d0ba5fe4b | |||
24cfb26c5b | |||
26a70aa208 | |||
ab7e050066 | |||
26b1fd6572 | |||
d7e682b65a | |||
f589ccb775 | |||
206b6567fd | |||
6857e0314f | |||
c8682a7393 | |||
144b6b2211 | |||
16a5ace4be | |||
b24ddc30c9 | |||
19333ab084 | |||
7529a7a26c | |||
21ebaae6ef | |||
3bc8b3c836 | |||
bb9415cc15 | |||
b3baeb8a5d | |||
1f393e78f6 | |||
215f5eafa6 | |||
1916e5343d | |||
fa9863fc54 | |||
7bf48ef351 | |||
faef3606fd | |||
d0ccd4d238 | |||
51e3650790 | |||
db29e2b666 | |||
655a68a847 | |||
86296b3591 | |||
73c127f10c | |||
cf4c981cd9 | |||
1b9541b933 | |||
5bca8de44e | |||
136c4bf50b | |||
4d700e3b83 | |||
740fa6fc84 | |||
cdb8dc72c7 | |||
4b3afb5c97 | |||
abf208432a | |||
19e6df4fb2 | |||
7fc3fff431 | |||
edd690850c | |||
302339e1cd | |||
739796bc79 | |||
9c30139b86 | |||
0af528b649 | |||
9636c87a2e | |||
ad46fb6d61 | |||
8e000baef2 | |||
a2e1209196 | |||
ef4a75d1f0 | |||
3db20feb54 | |||
b9ec381ea2 | |||
7d6a74a67d | |||
b923cf7752 | |||
e32e457ff8 | |||
32c1e6b390 | |||
b42c0c8355 | |||
7140ed8512 | |||
27d9b075ce | |||
5249257dd8 | |||
606f6159c4 | |||
2e095603b5 | |||
3a99b81ade | |||
577a487301 | |||
086d43376c | |||
31a4c2ff1f | |||
6a1fad611c | |||
e1892d2870 | |||
8ba15f8f72 | |||
876b66f324 | |||
2c5bfb19d3 | |||
1bb94a04e3 | |||
e3c9316486 | |||
c19984c3d0 | |||
9002c20165 | |||
15c96a9757 | |||
1ca3792a4b | |||
90fe467114 | |||
e61b3b34a7 | |||
1326418ffc | |||
a5f0f48ddb | |||
e500ccb61b | |||
4090b03406 | |||
431d1d5fec | |||
d74d79198b | |||
623a284ba4 | |||
f79c36edbb | |||
f4c748f67a | |||
672d8dfab2 | |||
0464adccce | |||
c3df6c3194 | |||
29d53c7df4 | |||
7b77dc044a | |||
67e758365f | |||
475231ffd8 | |||
513a564e2c | |||
cddea0401f | |||
3dafbf7fef | |||
fcd75414be | |||
c1b5bfff8c | |||
3c322cca0d | |||
e965d12e31 | |||
3daf55a0dd | |||
aafedd5f75 | |||
32956ae04c | |||
bfd0241b2d | |||
5eff8402db | |||
ffa020ee2a | |||
80a3668aa9 | |||
7378900050 | |||
9be457943c | |||
93454c6c15 | |||
fccbd76993 | |||
922876a893 | |||
654446f068 | |||
947460abdd | |||
c5635b0050 | |||
8a3a6308a3 | |||
290a07fe79 | |||
4c907d56f0 | |||
56b437ca74 | |||
e23ff33e6f | |||
f2d206262e | |||
1ed5690b33 | |||
4451514ec5 | |||
8f73f85276 | |||
9a5d7b664b | |||
7d2d1d971a | |||
d111493eed | |||
e975f92a96 | |||
739cb4242d | |||
a37eebc9f1 | |||
e92730879e | |||
464973f9b0 | |||
f6228c099f | |||
a57fdfb2bb | |||
24716f0561 | |||
3453100afd | |||
84de2c0c68 | |||
1b7b082003 | |||
1928c2c2cc | |||
52e7a7886d | |||
36298b217e | |||
9bce57894e | |||
9d6bb325cd | |||
a5f833c612 | |||
732b14c6ab | |||
b74a042da8 | |||
d55c052f57 | |||
864f585efa | |||
6d56146054 | |||
2c9f29a3c6 | |||
9bef2e960c | |||
17b8c41673 | |||
f0afbd7346 | |||
5dc7429f6a | |||
7b39b32293 | |||
e5b5a9e7e9 | |||
1f3511368a | |||
b37df2c84f | |||
f92ba54060 | |||
a3bbd4030e | |||
4b30da2d92 | |||
93d082afbb | |||
0c85380dbf | |||
fb576376dc | |||
ff111d4c6c | |||
bc6e9a8b68 | |||
bd1963ec26 | |||
a0bec9e97f | |||
c45df20d88 | |||
fa1d669633 | |||
1009b462e9 | |||
b404858904 | |||
7ec033577f | |||
c8ca82b803 | |||
5db2faa17d | |||
1605fb8d48 | |||
b6a7804a26 | |||
de31381fd9 | |||
0d92b8d8bb | |||
7c6ff776d9 | |||
e37a34ed6c | |||
c4d9c00f92 | |||
3af8be89e3 | |||
0f1db71604 | |||
fce9e7fb0c | |||
6301c0c21c | |||
30bb484d5a | |||
f88ee5e5a0 | |||
73b5030972 | |||
a69a3442ab | |||
d4dff744b5 | |||
62c93ad99d | |||
1e42d6bffa | |||
002ac29f2f | |||
20ccf389e9 | |||
a2f99ed4d2 | |||
cc6320acfd | |||
261a0fb0b9 | |||
cfc05cce41 | |||
1f15b70134 | |||
a5b49b286d | |||
f3333f24da | |||
cad8f0d0e2 | |||
edd3e75730 | |||
ab68c2c69a | |||
cbb95f21a3 | |||
74d3954335 | |||
92449b0369 | |||
65276483e0 | |||
dde0d1e465 | |||
3ad802c6f5 | |||
b81377a682 | |||
545180b88f | |||
a9819b9e25 | |||
897e941e7a | |||
aef840c2cc | |||
80d0638922 | |||
494ba36d44 | |||
dab9154092 | |||
cd4a85abbf | |||
e7977a9fbb | |||
684c1e55b0 | |||
1ffa831c5c | |||
40eed0016c | |||
b58631083b | |||
e0c0425d21 | |||
bf2de5d572 | |||
2b4a1dc480 | |||
ce022c024f | |||
0f4bf529d8 | |||
dad6bf7095 | |||
86ca9eaae6 | |||
9d9b805b0e | |||
851401be1e | |||
85052bc9bc | |||
bff09f529d | |||
f438458687 | |||
7125b12631 | |||
0cbf275a2e | |||
0ec50819f5 | |||
c9abe818bc | |||
bfa32537a8 | |||
cef15afab8 | |||
1b9587c454 | |||
de76b0d8c3 | |||
e62989c981 | |||
d6b71e6314 | |||
8c59bfd6d7 | |||
f32df73256 | |||
9d03a8002c | |||
3c36ca29af | |||
efed7e3c2b | |||
b09d3cea95 | |||
eabd2f3934 | |||
cc184c2827 | |||
436f791fa4 | |||
e935a57dec | |||
203909d917 | |||
eed4f57f30 | |||
7878036bac | |||
75d140b436 | |||
a79f31b006 | |||
45cfd61dbb | |||
7fcfca952e | |||
279f16cc67 | |||
e7b1d8a5d3 | |||
1b2f8e5586 | |||
e4468252c6 | |||
ad3ebd42bb |
@ -12,5 +12,5 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||
|
||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||
ALPHA_VANTAGE_API_KEY=
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
@ -1,12 +1,12 @@
|
||||
{
|
||||
"root": true,
|
||||
"ignorePatterns": ["**/*"],
|
||||
"plugins": ["@nrwl/nx"],
|
||||
"plugins": ["@nx"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {
|
||||
"@nrwl/nx/enforce-module-boundaries": [
|
||||
"@nx/enforce-module-boundaries": [
|
||||
"error",
|
||||
{
|
||||
"enforceBuildableLibDependency": true,
|
||||
@ -23,12 +23,12 @@
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"extends": ["plugin:@nrwl/nx/typescript"],
|
||||
"extends": ["plugin:@nx/typescript"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"extends": ["plugin:@nrwl/nx/javascript"],
|
||||
"extends": ["plugin:@nx/javascript"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
@ -113,5 +113,6 @@
|
||||
"radix": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"extends": [null, "plugin:storybook/recommended"]
|
||||
}
|
||||
|
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,7 @@ labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
|
||||
**Bug Description**
|
||||
|
||||
@ -36,6 +36,7 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
||||
|
||||
<!-- Please complete the following information -->
|
||||
|
||||
- Cloud or Self-hosted
|
||||
- Ghostfolio Version X.Y.Z
|
||||
- Browser
|
||||
- OS
|
||||
|
2
.github/workflows/docker-image.yml
vendored
2
.github/workflows/docker-image.yml
vendored
@ -41,7 +41,7 @@ jobs:
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.output.labels }}
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -25,6 +25,7 @@
|
||||
|
||||
# misc
|
||||
/.angular/cache
|
||||
.env
|
||||
.env.prod
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
@ -38,4 +39,4 @@ yarn-error.log
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Thumbs.db
|
||||
|
@ -1,11 +0,0 @@
|
||||
module.exports = {
|
||||
stories: [],
|
||||
addons: ['@storybook/addon-essentials']
|
||||
// uncomment the property below if you want to apply some webpack config globally
|
||||
// webpackFinal: async (config, { configType }) => {
|
||||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
||||
|
||||
// // Return the altered config
|
||||
// return config;
|
||||
// },
|
||||
};
|
@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"exclude": [
|
||||
"../**/*.spec.js",
|
||||
"../**/*.spec.ts",
|
||||
"../**/*.spec.tsx",
|
||||
"../**/*.spec.jsx"
|
||||
],
|
||||
"include": ["../**/*"]
|
||||
}
|
27
.vscode/launch.json
vendored
27
.vscode/launch.json
vendored
@ -2,32 +2,33 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Jest File",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
|
||||
"args": [
|
||||
"test",
|
||||
"--codeCoverage=false",
|
||||
"--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts"
|
||||
],
|
||||
"console": "internalConsole",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "internalConsole"
|
||||
"name": "Debug Jest",
|
||||
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
|
||||
"request": "launch",
|
||||
"type": "node"
|
||||
},
|
||||
{
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"program": "${workspaceFolder}/apps/api/src/main.ts",
|
||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
|
||||
"autoAttachChildProcesses": true,
|
||||
"console": "integratedTerminal",
|
||||
"cwd": "${workspaceFolder}/apps/api",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"name": "Debug API",
|
||||
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
|
||||
"program": "${workspaceFolder}/apps/api/src/main.ts",
|
||||
"request": "launch",
|
||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
|
||||
"skipFiles": [
|
||||
"${workspaceFolder}/node_modules/**/*.js",
|
||||
"<node_internals>/**/*.js"
|
||||
],
|
||||
"console": "integratedTerminal"
|
||||
"type": "node"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
720
CHANGELOG.md
720
CHANGELOG.md
@ -5,6 +5,717 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 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
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the _FIRE_ calculator by a retirement date setting
|
||||
|
||||
## 1.243.0 - 2023-03-08
|
||||
|
||||
### Added
|
||||
|
||||
- Added `COINGECKO` as a default to `DATA_SOURCES`
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the validation of the manual currency for the activity fee and unit price
|
||||
- Harmonized the axis style of charts
|
||||
- Made setting `NODE_ENV: production` optional (to avoid `ENOENT: no such file or directory` errors on startup)
|
||||
- Removed the environment variable `ENABLE_FEATURE_CUSTOM_SYMBOLS`
|
||||
|
||||
## 1.242.0 - 2023-03-04
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplified the database seeding
|
||||
- Upgraded `ngx-skeleton-loader` from version `5.0.0` to `7.0.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Downgraded `Node.js` from version `18` to `16` (Dockerfile) to resolve `SIGSEGV` (segmentation fault) during the `prisma` database migrations (see https://github.com/prisma/prisma/issues/10649)
|
||||
|
||||
## 1.241.0 - 2023-03-01
|
||||
|
||||
### Changed
|
||||
|
||||
- Filtered activities with type `ITEM` from search results
|
||||
- Considered the user's language in the _Stripe_ checkout
|
||||
- Upgraded the _Stripe_ dependencies
|
||||
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
|
||||
|
||||
## 1.240.0 - 2023-02-26
|
||||
|
||||
### Added
|
||||
|
||||
- Supported a manual currency for the activity unit price
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the feature graphic of the _Ghostfolio meets Umbrel_ blog post
|
||||
|
||||
## 1.239.0 - 2023-02-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added a blog post: _Ghostfolio meets Umbrel_
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the dependency `rimraf`
|
||||
|
||||
## 1.238.0 - 2023-02-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added `COINGECKO` as a new data source type
|
||||
- Added support for data provider information to the position detail dialog
|
||||
- Added the configuration to publish a `linux/arm/v7` docker image
|
||||
- Added _Reddit_ to the _As seen in_ section on the landing page
|
||||
- Added _Umbrel_ to the _As seen in_ section on the landing page
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed the example environment variable file from `.env` to `.env.example`
|
||||
- Upgraded `zone.js` from version `0.11.8` to `0.12.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `RangeError: Maximum call stack size exceeded` for values of type `Big` in the value redaction interceptor for the impersonation mode
|
||||
- Reset the letter spacing in buttons
|
||||
|
||||
### Todo
|
||||
|
||||
- Ensure that you still have a `.env` file in your project
|
||||
|
||||
## 1.237.0 - 2023-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added the support details to the pricing page
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased the file size limit for the activities import
|
||||
- Improved the style of the search results for symbols
|
||||
- Migrated the style of `GfHeaderModule` to `@angular/material` `15` (mdc)
|
||||
- Upgraded `angular` from version `15.1.2` to `15.1.5`
|
||||
- Upgraded `Nx` from version `15.6.3` to `15.7.2`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with exact matches in the activities table filter (`VT` vs. `VTI`)
|
||||
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
|
||||
|
||||
## 1.236.0 - 2023-02-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Beautified the ETF names in the asset profile
|
||||
- Removed the data source type `GHOSTFOLIO`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
|
||||
- Fixed the buying power calculation if no emergency fund is set but an activity is tagged as _Emergency Fund_
|
||||
- Fixed the url on logout during the local development
|
||||
|
||||
## 1.235.0 - 2023-02-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styles on the about page
|
||||
- Eliminated the `GhostfolioScraperApiService`
|
||||
|
||||
## 1.234.0 - 2023-02-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added the data import and export feature to the pricing page
|
||||
|
||||
### Changed
|
||||
|
||||
- Copy the logic of `GhostfolioScraperApiService` to `ManualService`
|
||||
- Improved the content of the landing page
|
||||
- Improved the content of the Frequently Asked Questions (FAQ) page
|
||||
- Improved the usability of the _Import Activities..._ action
|
||||
- Eliminated the permission `enableImport`
|
||||
- Set the exposed port as an environment variable (`PORT`) in `Dockerfile`
|
||||
- Migrated the style of `AboutPageModule` to `@angular/material` `15` (mdc)
|
||||
- Migrated the style of `BlogPageModule` to `@angular/material` `15` (mdc)
|
||||
- Migrated the style of `ChangelogPageModule` to `@angular/material` `15` (mdc)
|
||||
- Migrated the style of `ResourcesPageModule` to `@angular/material` `15` (mdc)
|
||||
- Upgraded `chart.js` from version `4.0.1` to `4.2.0`
|
||||
- Upgraded `ionicons` from version `6.0.4` to `6.1.2`
|
||||
- Upgraded `prettier` from version `2.8.1` to `2.8.4`
|
||||
- Upgraded `prisma` from version `4.9.0` to `4.10.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue on the landing page caused by the global heat map of subscribers
|
||||
- Fixed the links in the interstitial for the subscription
|
||||
|
||||
### Todo
|
||||
|
||||
- Remove the environment variable `ENABLE_FEATURE_IMPORT`
|
||||
- Rename the `dataSource` from `GHOSTFOLIO` to `MANUAL`
|
||||
- Eliminate `GhostfolioScraperApiService`
|
||||
|
||||
## 1.233.0 - 2023-02-09
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to export accounts
|
||||
- Added support to import accounts
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styling in the admin control panel
|
||||
- Removed the _Google Play_ badge from the landing page
|
||||
- Upgraded `eslint` dependencies
|
||||
|
||||
## 1.232.0 - 2023-02-05
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for German (`de`)
|
||||
- Migrated the style of `ActivitiesPageModule` to `@angular/material` `15` (mdc)
|
||||
- Migrated the style of `GfCreateOrUpdateActivityDialogModule` to `@angular/material` `15` (mdc)
|
||||
- Migrated the style of `GfMarketDataDetailDialogModule` to `@angular/material` `15` (mdc)
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.1.2` to `2.5.0`
|
||||
- Upgraded `ngx-markdown` from version `14.0.1` to `15.1.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the `Upgrade Plan` button of the interstitial for the subscription
|
||||
|
||||
## 1.231.0 - 2023-02-04
|
||||
|
||||
### Added
|
||||
@ -129,7 +840,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 investment timeline grouped by year
|
||||
- Set up the language localization for Français (`fr`)
|
||||
- Set up the language localization for Português (`pt`)
|
||||
|
||||
### Changed
|
||||
|
||||
@ -409,7 +1119,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 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
|
||||
|
||||
@ -656,7 +1366,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Added the alias to the `Access` database schema
|
||||
- Added support for translated time distances
|
||||
- Added a _GitHub Action_ to create an `arm64` docker image
|
||||
- Added a _GitHub Action_ to create an `linux/arm64` docker image
|
||||
|
||||
### Changed
|
||||
|
||||
@ -1269,7 +1979,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Beautified the ETF names in the symbol profile
|
||||
- Beautified the ETF names in the asset profile
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -1694,7 +2404,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Extended the historical data view in the admin control panel
|
||||
- Upgraded _Stripe_ dependencies
|
||||
- Upgraded the _Stripe_ dependencies
|
||||
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
|
||||
|
||||
### Fixed
|
||||
|
31
DEVELOPMENT.md
Normal file
31
DEVELOPMENT.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Ghostfolio Development Guide
|
||||
|
||||
## Git
|
||||
|
||||
### Rebase
|
||||
|
||||
`git rebase -i --autosquash main`
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Nx
|
||||
|
||||
#### Upgrade
|
||||
|
||||
1. Run `yarn nx migrate latest`
|
||||
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||
1. Run `yarn nx migrate --run-migrations`
|
||||
|
||||
### Prisma
|
||||
|
||||
#### Synchronize schema with database for prototyping
|
||||
|
||||
Run `yarn database:push`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||
|
||||
#### Create schema migration
|
||||
|
||||
Run `yarn prisma migrate dev --name added_job_title`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
@ -57,5 +57,5 @@ RUN apt update && apt install -y \
|
||||
|
||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||
WORKDIR /ghostfolio/apps/api
|
||||
EXPOSE 3333
|
||||
EXPOSE ${PORT:-3333}
|
||||
CMD [ "yarn", "start:prod" ]
|
||||
|
60
README.md
60
README.md
@ -75,7 +75,7 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
||||
|
||||
## Self-hosting
|
||||
|
||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
||||
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@ -85,20 +85,19 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
### Supported Environment Variables
|
||||
|
||||
| Name | Default Value | Description |
|
||||
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application.<br />`AUD` \| `CAD` \| `CNY` \| `EUR` \| `GBP` \| `JPY` \| `RUB` \| `USD`<br />Caution: Only set if you intend to track cryptocurrencies in a non-`USD` currency. This cannot be changed later! |
|
||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
| Name | Default Value | Description |
|
||||
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
@ -106,7 +105,8 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
- Basic knowledge of Docker
|
||||
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- Local copy of this Git repository (clone)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||
|
||||
#### a. Run environment
|
||||
|
||||
@ -125,13 +125,10 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
```
|
||||
|
||||
#### Fetch Historical Data
|
||||
|
||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||
#### Setup
|
||||
|
||||
1. Open http://localhost:3333 in your browser
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
#### Upgrade Version
|
||||
|
||||
@ -148,20 +145,20 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 16)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- A local copy of this Git repository (clone)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
1. Run `yarn build:dev` to build the source code including the assets
|
||||
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||
1. Run `yarn database:setup` to initialize the database schema
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
1. Open http://localhost:4200/en in your browser
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
1. Go to the _Market Data_ tab in the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
### Start Server
|
||||
|
||||
@ -175,7 +172,7 @@ Run `yarn start:server`
|
||||
|
||||
### Start Client
|
||||
|
||||
Run `yarn start:client`
|
||||
Run `yarn start:client` and open http://localhost:4200/en in your browser
|
||||
|
||||
### Start _Storybook_
|
||||
|
||||
@ -203,7 +200,9 @@ Set the header for each request as follows:
|
||||
"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
|
||||
|
||||
@ -233,6 +232,7 @@ You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonym
|
||||
| Field | Type | Description |
|
||||
| ---------- | ------------------- | -------------------------------------------------- |
|
||||
| accountId | string (`optional`) | Id of the account |
|
||||
| comment | string (`optional`) | Comment of the activity |
|
||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||
| date | string | Date in the format `ISO-8601` |
|
||||
@ -269,12 +269,12 @@ You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonym
|
||||
|
||||
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).
|
||||
|
||||
## 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).
|
||||
|
@ -2,13 +2,14 @@
|
||||
export default {
|
||||
displayName: 'api',
|
||||
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||
}
|
||||
},
|
||||
globals: {},
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': 'ts-jest'
|
||||
'^.+\\.[tj]s$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||
}
|
||||
]
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/api',
|
||||
|
@ -33,7 +33,7 @@
|
||||
"outputs": ["{options.outputPath}"]
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nrwl/node:node",
|
||||
"executor": "@nx/node:node",
|
||||
"options": {
|
||||
"buildTarget": "api:build"
|
||||
}
|
||||
@ -45,7 +45,7 @@
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"executor": "@nx/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/api/jest.config.ts",
|
||||
"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 { AccessController } from './access.controller';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Access, Prisma } from '@prisma/client';
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { 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 { Accounts } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
@ -84,13 +84,10 @@ export class AccountController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAllAccounts(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||
): Promise<Accounts> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
|
||||
return this.portfolioService.getAccountsWithAggregations({
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
@ -102,14 +99,11 @@ export class AccountController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAccountById(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Param('id') id: string
|
||||
): Promise<AccountWithValue> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
|
||||
const accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations({
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccountController } from './account.controller';
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.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 { Injectable } from '@nestjs/common';
|
||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||
@ -172,4 +172,47 @@ export class AccountService {
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async updateAccountBalance({
|
||||
accountId,
|
||||
amount,
|
||||
currency,
|
||||
date,
|
||||
userId
|
||||
}: {
|
||||
accountId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
date: Date;
|
||||
userId: string;
|
||||
}) {
|
||||
const { balance, currency: currencyOfAccount } = await this.account({
|
||||
id_userId: {
|
||||
userId,
|
||||
id: accountId
|
||||
}
|
||||
});
|
||||
|
||||
const amountInCurrencyOfAccount =
|
||||
await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
amount,
|
||||
currency,
|
||||
currencyOfAccount,
|
||||
date
|
||||
);
|
||||
|
||||
if (amountInCurrencyOfAccount) {
|
||||
await this.prismaService.account.update({
|
||||
data: {
|
||||
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
userId,
|
||||
id: accountId
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
@ -6,6 +7,7 @@ import {
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString()
|
||||
@ -14,9 +16,20 @@ export class CreateAccountDto {
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) =>
|
||||
isString(value) ? value.trim() : value
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExcluded?: boolean;
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
@ -6,6 +7,7 @@ import {
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@IsString()
|
||||
@ -14,6 +16,13 @@ export class UpdateAccountDto {
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) =>
|
||||
isString(value) ? value.trim() : value
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import {
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
@ -26,11 +27,12 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
||||
import { isDate } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@ -100,16 +102,21 @@ export class AdminController {
|
||||
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
for (const { dataSource, symbol } of uniqueAssets) {
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: `${dataSource}-${symbol}`
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
this.dataGatheringService.gatherMax();
|
||||
}
|
||||
@ -131,16 +138,21 @@ export class AdminController {
|
||||
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
for (const { dataSource, symbol } of uniqueAssets) {
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
}
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: `${dataSource}-${symbol}`
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Post('gather/profile-data/:dataSource/:symbol')
|
||||
@ -161,14 +173,17 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
await this.dataGatheringService.addJobToQueue({
|
||||
data: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: `${dataSource}-${symbol}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Post('gather/:dataSource/:symbol')
|
||||
@ -304,9 +319,10 @@ export class AdminController {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return this.marketDataService.updateMarketData({
|
||||
data: { ...data, dataSource },
|
||||
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
|
||||
where: {
|
||||
date_symbol: {
|
||||
dataSource_date_symbol: {
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
}
|
||||
@ -314,6 +330,28 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async addProfileData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<SymbolProfile | never> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.addAssetProfile({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@Delete('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteProfileData(
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AdminController } from './admin.controller';
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||
import {
|
||||
AdminData,
|
||||
@ -14,8 +15,8 @@ import {
|
||||
Filter,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { groupBy } from 'lodash';
|
||||
|
||||
@ -25,6 +26,7 @@ export class AdminService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
@ -35,6 +37,38 @@ export class AdminService {
|
||||
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) {
|
||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||
@ -100,6 +134,7 @@ export class AdminService {
|
||||
dataSource,
|
||||
marketDataItemCount,
|
||||
symbol,
|
||||
assetClass: 'CASH',
|
||||
countriesCount: 0,
|
||||
sectorsCount: 0
|
||||
};
|
||||
@ -186,8 +221,11 @@ export class AdminService {
|
||||
]);
|
||||
|
||||
return {
|
||||
assetProfile,
|
||||
marketData
|
||||
marketData,
|
||||
assetProfile: assetProfile ?? {
|
||||
symbol,
|
||||
currency: '-'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -231,12 +269,27 @@ export class AdminService {
|
||||
}
|
||||
|
||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||
orderBy: {
|
||||
let orderBy: any = {
|
||||
createdAt: 'desc'
|
||||
};
|
||||
let where;
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
orderBy = {
|
||||
Analytics: {
|
||||
updatedAt: 'desc'
|
||||
}
|
||||
},
|
||||
};
|
||||
where = {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||
orderBy,
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
select: { Account: true, Order: true }
|
||||
@ -244,6 +297,7 @@ export class AdminService {
|
||||
Analytics: {
|
||||
select: {
|
||||
activityCount: true,
|
||||
country: true,
|
||||
updatedAt: true
|
||||
}
|
||||
},
|
||||
@ -251,19 +305,16 @@ export class AdminService {
|
||||
id: true,
|
||||
Subscription: true
|
||||
},
|
||||
take: 30,
|
||||
where: {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
}
|
||||
}
|
||||
take: 30
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||
const daysSinceRegistration =
|
||||
differenceInDays(new Date(), createdAt) + 1;
|
||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
||||
const engagement = Analytics
|
||||
? Analytics.activityCount / daysSinceRegistration
|
||||
: undefined;
|
||||
|
||||
const subscription = this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
@ -277,7 +328,8 @@ export class AdminService {
|
||||
id,
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
lastActivity: Analytics.updatedAt,
|
||||
country: Analytics?.country,
|
||||
lastActivity: Analytics?.updatedAt,
|
||||
transactionCount: _count.Order || 0
|
||||
};
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { QueueController } from './queue.controller';
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
} from '@ghostfolio/common/config';
|
||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JobStatus, Queue } from 'bull';
|
||||
|
||||
@Injectable()
|
||||
@ -23,14 +23,11 @@ export class QueueService {
|
||||
}: {
|
||||
status?: JobStatus[];
|
||||
}) {
|
||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
await job.remove();
|
||||
} catch (error) {
|
||||
Logger.warn(error, 'QueueService');
|
||||
}
|
||||
for (const statusItem of status) {
|
||||
await this.dataGatheringQueue.clean(
|
||||
300,
|
||||
statusItem === 'waiting' ? 'wait' : statusItem
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,18 +41,23 @@ export class QueueService {
|
||||
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||
|
||||
const jobsWithState = await Promise.all(
|
||||
jobs.slice(0, limit).map(async (job) => {
|
||||
return {
|
||||
attemptsMade: job.attemptsMade + 1,
|
||||
data: job.data,
|
||||
finishedOn: job.finishedOn,
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
stacktrace: job.stacktrace,
|
||||
state: await job.getState(),
|
||||
timestamp: job.timestamp
|
||||
};
|
||||
})
|
||||
jobs
|
||||
.filter((job) => {
|
||||
return job;
|
||||
})
|
||||
.slice(0, limit)
|
||||
.map(async (job) => {
|
||||
return {
|
||||
attemptsMade: job.attemptsMade + 1,
|
||||
data: job.data,
|
||||
finishedOn: job.finishedOn,
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
stacktrace: job.stacktrace,
|
||||
state: await job.getState(),
|
||||
timestamp: job.timestamp
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
|
@ -1,18 +1,18 @@
|
||||
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 { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
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 { AccountModule } from './account/account.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 { ExportModule } from './export/export.module';
|
||||
import { FrontendMiddleware } from './frontend.middleware';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { InfoModule } from './info/info.module';
|
||||
import { LogoModule } from './logo/logo.module';
|
||||
import { OrderModule } from './order/order.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
@ -57,10 +59,12 @@ import { UserModule } from './user/user.module';
|
||||
ExchangeRateModule,
|
||||
ExchangeRateDataModule,
|
||||
ExportModule,
|
||||
HealthModule,
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
LogoModule,
|
||||
OrderModule,
|
||||
PlatformModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthDevice, Prisma } from '@prisma/client';
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
@ -33,8 +33,11 @@ export class AuthController {
|
||||
private readonly webAuthService: WebAuthService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('anonymous/:accessToken')
|
||||
public async accessTokenLogin(
|
||||
public async accessTokenLoginGet(
|
||||
@Param('accessToken') accessToken: string
|
||||
): Promise<OAuthResponse> {
|
||||
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')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
public googleLogin() {
|
||||
@ -81,13 +101,13 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
@Get('internet-identity/:principalId')
|
||||
@Post('internet-identity')
|
||||
public async internetIdentityLogin(
|
||||
@Param('principalId') principalId: string
|
||||
@Body() body: { principalId: string }
|
||||
): Promise<OAuthResponse> {
|
||||
try {
|
||||
const authToken = await this.authService.validateInternetIdentityLogin(
|
||||
principalId
|
||||
body.principalId
|
||||
);
|
||||
return { authToken };
|
||||
} catch {
|
||||
|
@ -2,8 +2,8 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
@ -61,8 +61,10 @@ export class AuthService {
|
||||
|
||||
// Create new user if not found
|
||||
user = await this.userService.createUser({
|
||||
provider,
|
||||
thirdPartyId: principalId
|
||||
data: {
|
||||
provider,
|
||||
thirdPartyId: principalId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -96,8 +98,10 @@ export class AuthService {
|
||||
|
||||
// Create new user if not found
|
||||
user = await this.userService.createUser({
|
||||
provider,
|
||||
thirdPartyId
|
||||
data: {
|
||||
provider,
|
||||
thirdPartyId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Provider } from '@prisma/client';
|
||||
|
@ -1,33 +1,46 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
public constructor(
|
||||
readonly configurationService: ConfigurationService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly userService: UserService
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
passReqToCallback: true,
|
||||
secretOrKey: configurationService.get('JWT_SECRET_KEY')
|
||||
});
|
||||
}
|
||||
|
||||
public async validate({ id }: { id: string }) {
|
||||
public async validate(request: Request, { id }: { id: string }) {
|
||||
try {
|
||||
const timezone = request.headers[HEADER_KEY_TIMEZONE.toLowerCase()];
|
||||
const user = await this.userService.user({ id });
|
||||
|
||||
if (user) {
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { User: { connect: { id: user.id } } },
|
||||
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
|
||||
where: { userId: user.id }
|
||||
});
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
const country =
|
||||
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { country, User: { connect: { id: user.id } } },
|
||||
update: {
|
||||
country,
|
||||
activityCount: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
},
|
||||
where: { userId: user.id }
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
} else {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Inject,
|
||||
|
@ -1,24 +1,36 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import {
|
||||
import type {
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkResponse
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { BenchmarkService } from './benchmark.service';
|
||||
|
||||
@Controller('benchmark')
|
||||
export class BenchmarkController {
|
||||
public constructor(private readonly benchmarkService: BenchmarkService) {}
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@ -45,4 +57,41 @@ export class BenchmarkController {
|
||||
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 { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { BenchmarkController } from './benchmark.controller';
|
||||
@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service';
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolModule,
|
||||
|
@ -4,7 +4,15 @@ describe('BenchmarkService', () => {
|
||||
let benchmarkService: BenchmarkService;
|
||||
|
||||
beforeAll(async () => {
|
||||
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
|
||||
benchmarkService = new BenchmarkService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
it('calculateChangeInPercentage', async () => {
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
MAX_CHART_ITEMS,
|
||||
PROPERTY_BENCHMARKS
|
||||
@ -11,6 +12,7 @@ import {
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkProperty,
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -18,6 +20,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import { uniqBy } from 'lodash';
|
||||
import ms from 'ms';
|
||||
|
||||
@Injectable()
|
||||
@ -27,6 +30,7 @@ export class BenchmarkService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private readonly symbolProfileService: SymbolProfileService,
|
||||
@ -116,9 +120,9 @@ export class BenchmarkService {
|
||||
|
||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||
const symbolProfileIds: string[] = (
|
||||
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
|
||||
symbolProfileId: string;
|
||||
}[]) ?? []
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as BenchmarkProperty[]) ?? []
|
||||
).map(({ symbolProfileId }) => {
|
||||
return symbolProfileId;
|
||||
});
|
||||
@ -204,6 +208,43 @@ export class BenchmarkService {
|
||||
return response;
|
||||
}
|
||||
|
||||
public async addBenchmark({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
||||
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||
where: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
});
|
||||
|
||||
if (!assetProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
let benchmarks =
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as BenchmarkProperty[]) ?? [];
|
||||
|
||||
benchmarks.push({ symbolProfileId: assetProfile.id });
|
||||
|
||||
benchmarks = uniqBy(benchmarks, 'symbolProfileId');
|
||||
|
||||
await this.propertyService.put({
|
||||
key: PROPERTY_BENCHMARKS,
|
||||
value: JSON.stringify(benchmarks)
|
||||
});
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
id: assetProfile.id,
|
||||
name: assetProfile.name
|
||||
};
|
||||
}
|
||||
|
||||
private getMarketCondition(aPerformanceInPercent: number) {
|
||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||
}
|
||||
|
10
apps/api/src/app/cache/cache.module.ts
vendored
10
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,10 +1,10 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CacheController } from './cache.controller';
|
||||
|
@ -1,6 +1,13 @@
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Param,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { ExchangeRateService } from './exchange-rate.service';
|
||||
|
||||
@ -18,9 +25,18 @@ export class ExchangeRateController {
|
||||
): Promise<IDataProviderHistoricalResponse> {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return this.exchangeRateService.getExchangeRate({
|
||||
const exchangeRate = await this.exchangeRateService.getExchangeRate({
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
|
||||
if (exchangeRate) {
|
||||
return { marketPrice: exchangeRate };
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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 { ExchangeRateController } from './exchange-rate.controller';
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
@ -14,16 +13,14 @@ export class ExchangeRateService {
|
||||
}: {
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}): Promise<IDataProviderHistoricalResponse> {
|
||||
}): Promise<number> {
|
||||
const [currency1, currency2] = symbol.split('-');
|
||||
|
||||
const marketPrice = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||
return this.exchangeRateDataService.toCurrencyAtDate(
|
||||
1,
|
||||
currency1,
|
||||
currency2,
|
||||
date
|
||||
);
|
||||
|
||||
return { marketPrice };
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExportController } from './export.controller';
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@ -14,6 +14,23 @@ export class ExportService {
|
||||
activityIds?: string[];
|
||||
userId: string;
|
||||
}): Promise<Export> {
|
||||
const accounts = await this.prismaService.account.findMany({
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
},
|
||||
select: {
|
||||
accountType: true,
|
||||
balance: true,
|
||||
comment: true,
|
||||
currency: true,
|
||||
id: true,
|
||||
isExcluded: true,
|
||||
name: true,
|
||||
platformId: true
|
||||
},
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
let activities = await this.prismaService.order.findMany({
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
@ -38,6 +55,7 @@ export class ExportService {
|
||||
|
||||
return {
|
||||
meta: { date: new Date().toISOString(), version: environment.version },
|
||||
accounts,
|
||||
activities: activities.map(
|
||||
({
|
||||
accountId,
|
||||
|
@ -1,11 +1,11 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { format } from 'date-fns';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@ -18,18 +18,13 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
public indexHtmlIt = '';
|
||||
public indexHtmlNl = '';
|
||||
public indexHtmlPt = '';
|
||||
public isProduction: boolean;
|
||||
|
||||
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(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {
|
||||
const NODE_ENV =
|
||||
this.configService.get<'development' | 'production'>('NODE_ENV') ??
|
||||
'development';
|
||||
|
||||
this.isProduction = NODE_ENV === 'production';
|
||||
|
||||
try {
|
||||
this.indexHtmlDe = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('de'),
|
||||
@ -90,12 +85,31 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
|
||||
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith('/en/blog/2023/02/ghostfolio-meets-umbrel')
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
|
||||
title = `Ghostfolio meets Umbrel - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith(
|
||||
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github'
|
||||
)
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
|
||||
title = `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`;
|
||||
} else if (
|
||||
request.path.startsWith(
|
||||
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio'
|
||||
)
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/20230520.jpg';
|
||||
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
|
||||
}
|
||||
|
||||
if (
|
||||
request.path.startsWith('/api/') ||
|
||||
this.isFileRequest(request.url) ||
|
||||
!this.isProduction
|
||||
!environment.production
|
||||
) {
|
||||
// Skip
|
||||
next();
|
||||
@ -105,6 +119,8 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
description:
|
||||
'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
|
||||
languageCode: 'de',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -116,6 +132,8 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
description:
|
||||
'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
|
||||
languageCode: 'es',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -124,7 +142,11 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlFr, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
description:
|
||||
'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
|
||||
languageCode: 'fr',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -136,6 +158,8 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
description:
|
||||
'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
|
||||
languageCode: 'it',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -147,6 +171,8 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
description:
|
||||
'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.',
|
||||
languageCode: 'nl',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -155,7 +181,11 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
||||
response.send(
|
||||
this.interpolate(this.indexHtmlPt, {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
description:
|
||||
'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
|
||||
languageCode: 'pt',
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
@ -167,6 +197,7 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
currentDate,
|
||||
featureGraphicPath,
|
||||
title,
|
||||
description: FrontendMiddleware.DEFAULT_DESCRIPTION,
|
||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||
path: request.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
|
44
apps/api/src/app/health/health.controller.ts
Normal file
44
apps/api/src/app/health/health.controller.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Param,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
public constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get()
|
||||
public async getHealth() {}
|
||||
|
||||
@Get('data-provider/:dataSource')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getHealthOfDataProvider(
|
||||
@Param('dataSource') dataSource: DataSource
|
||||
) {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
const hasResponse = await this.healthService.hasResponseFromDataProvider(
|
||||
dataSource
|
||||
);
|
||||
|
||||
if (hasResponse !== true) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||
StatusCodes.SERVICE_UNAVAILABLE
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
13
apps/api/src/app/health/health.module.ts
Normal file
13
apps/api/src/app/health/health.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
imports: [ConfigurationModule, DataProviderModule],
|
||||
providers: [HealthService]
|
||||
})
|
||||
export class HealthModule {}
|
14
apps/api/src/app/health/health.service.ts
Normal file
14
apps/api/src/app/health/health.service.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService
|
||||
) {}
|
||||
|
||||
public async hasResponseFromDataProvider(aDataSource: DataSource) {
|
||||
return this.dataProviderService.checkQuote(aDataSource);
|
||||
}
|
||||
}
|
@ -1,8 +1,15 @@
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, ValidateNested } from 'class-validator';
|
||||
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
||||
|
||||
export class ImportDataDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Type(() => CreateAccountDto)
|
||||
@ValidateNested({ each: true })
|
||||
accounts: CreateAccountDto[];
|
||||
|
||||
@IsArray()
|
||||
@Type(() => CreateOrderDto)
|
||||
@ValidateNested({ each: true })
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
@ -34,11 +35,19 @@ export class ImportController {
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async import(
|
||||
@Body() importData: ImportDataDto,
|
||||
@Query('dryRun') isDryRun?: boolean
|
||||
): Promise<ImportResponse> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.createAccount
|
||||
) ||
|
||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
@ -60,9 +69,10 @@ export class ImportController {
|
||||
|
||||
try {
|
||||
const activities = await this.importService.import({
|
||||
maxActivitiesToImport,
|
||||
isDryRun,
|
||||
maxActivitiesToImport,
|
||||
userCurrency,
|
||||
accountsDto: importData.accounts ?? [],
|
||||
activitiesDto: importData.activities,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
@ -1,14 +1,15 @@
|
||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ImportController } from './import.controller';
|
||||
@ -24,6 +25,7 @@ import { ImportService } from './import.service';
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
OrderModule,
|
||||
PlatformModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
|
@ -1,11 +1,16 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Activity } 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 { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
@ -13,7 +18,7 @@ import {
|
||||
OrderWithAccount
|
||||
} from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@ -25,6 +30,7 @@ export class ImportService {
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly platformService: PlatformService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
@ -68,8 +74,25 @@ export class ImportService {
|
||||
|
||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||
|
||||
const isDuplicate = orders.some((activity) => {
|
||||
return (
|
||||
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||
isSameDay(activity.date, parseDate(dateString)) &&
|
||||
activity.quantity === quantity &&
|
||||
activity.SymbolProfile.symbol === assetProfile.symbol &&
|
||||
activity.type === 'DIVIDEND' &&
|
||||
activity.unitPrice === marketPrice
|
||||
);
|
||||
});
|
||||
|
||||
const error: ActivityError = isDuplicate
|
||||
? { code: 'IS_DUPLICATE' }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
Account,
|
||||
error,
|
||||
quantity,
|
||||
value,
|
||||
accountId: Account?.id,
|
||||
@ -100,24 +123,97 @@ export class ImportService {
|
||||
}
|
||||
|
||||
public async import({
|
||||
accountsDto,
|
||||
activitiesDto,
|
||||
isDryRun = false,
|
||||
maxActivitiesToImport,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
accountsDto: Partial<CreateAccountDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
isDryRun?: boolean;
|
||||
maxActivitiesToImport: number;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<Activity[]> {
|
||||
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||
|
||||
if (!isDryRun && accountsDto?.length) {
|
||||
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||
this.accountService.accounts({
|
||||
where: {
|
||||
id: {
|
||||
in: accountsDto.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
this.platformService.getPlatforms()
|
||||
]);
|
||||
|
||||
for (const account of accountsDto) {
|
||||
// Check if there is any existing account with the same ID
|
||||
const accountWithSameId = existingAccounts.find(
|
||||
(existingAccount) => existingAccount.id === account.id
|
||||
);
|
||||
|
||||
// If there is no account or if the account belongs to a different user then create a new account
|
||||
if (!accountWithSameId || accountWithSameId.userId !== userId) {
|
||||
let oldAccountId: string;
|
||||
const platformId = account.platformId;
|
||||
|
||||
delete account.platformId;
|
||||
|
||||
if (accountWithSameId) {
|
||||
oldAccountId = account.id;
|
||||
delete account.id;
|
||||
}
|
||||
|
||||
let accountObject: Prisma.AccountCreateInput = {
|
||||
...account,
|
||||
User: { connect: { id: userId } }
|
||||
};
|
||||
|
||||
if (
|
||||
existingPlatforms.some(({ id }) => {
|
||||
return id === platformId;
|
||||
})
|
||||
) {
|
||||
accountObject = {
|
||||
...accountObject,
|
||||
Platform: { connect: { id: platformId } }
|
||||
};
|
||||
}
|
||||
|
||||
const newAccount = await this.accountService.createAccount(
|
||||
accountObject,
|
||||
userId
|
||||
);
|
||||
|
||||
// Store the new to old account ID mappings for updating activities
|
||||
if (accountWithSameId && oldAccountId) {
|
||||
accountIdMapping[oldAccountId] = newAccount.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const activity of activitiesDto) {
|
||||
if (!activity.dataSource) {
|
||||
if (activity.type === 'ITEM') {
|
||||
activity.dataSource = 'MANUAL';
|
||||
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
|
||||
activity.dataSource = DataSource.MANUAL;
|
||||
} else {
|
||||
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||
activity.dataSource =
|
||||
this.dataProviderService.getDataSourceForImport();
|
||||
}
|
||||
}
|
||||
|
||||
// If a new account is created, then update the accountId in all activities
|
||||
if (!isDryRun) {
|
||||
if (Object.keys(accountIdMapping).includes(activity.accountId)) {
|
||||
activity.accountId = accountIdMapping[activity.accountId];
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -128,32 +224,45 @@ export class ImportService {
|
||||
userId
|
||||
});
|
||||
|
||||
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
||||
(account) => {
|
||||
return account.id;
|
||||
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||
activitiesDto,
|
||||
userId
|
||||
});
|
||||
|
||||
const accounts = (await this.accountService.getAccounts(userId)).map(
|
||||
({ id, name }) => {
|
||||
return { id, name };
|
||||
}
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
accountsDto.forEach(({ id, name }) => {
|
||||
accounts.push({ id, name });
|
||||
});
|
||||
}
|
||||
|
||||
const activities: Activity[] = [];
|
||||
|
||||
for (const {
|
||||
accountId,
|
||||
comment,
|
||||
currency,
|
||||
dataSource,
|
||||
date: dateString,
|
||||
date,
|
||||
error,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
SymbolProfile: assetProfile,
|
||||
type,
|
||||
unitPrice
|
||||
} of activitiesDto) {
|
||||
const date = parseISO(<string>(<unknown>dateString));
|
||||
const validatedAccountId = accountIds.includes(accountId)
|
||||
? accountId
|
||||
: undefined;
|
||||
} of activitiesExtendedWithErrors) {
|
||||
const validatedAccount = accounts.find(({ id }) => {
|
||||
return id === accountId;
|
||||
});
|
||||
|
||||
let order: OrderWithAccount;
|
||||
let order:
|
||||
| OrderWithAccount
|
||||
| (Omit<OrderWithAccount, 'Account'> & {
|
||||
Account?: { id: string; name: string };
|
||||
});
|
||||
|
||||
if (isDryRun) {
|
||||
order = {
|
||||
@ -164,11 +273,144 @@ export class ImportService {
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccountId,
|
||||
accountId: validatedAccount?.id,
|
||||
accountUserId: undefined,
|
||||
createdAt: new Date(),
|
||||
id: uuidv4(),
|
||||
isDraft: isAfter(date, endOfToday()),
|
||||
SymbolProfile: {
|
||||
assetClass: assetProfile.assetClass,
|
||||
assetSubClass: assetProfile.assetSubClass,
|
||||
comment: assetProfile.comment,
|
||||
countries: assetProfile.countries,
|
||||
createdAt: assetProfile.createdAt,
|
||||
currency: assetProfile.currency,
|
||||
dataSource: assetProfile.dataSource,
|
||||
id: assetProfile.id,
|
||||
isin: assetProfile.isin,
|
||||
name: assetProfile.name,
|
||||
scraperConfiguration: assetProfile.scraperConfiguration,
|
||||
sectors: assetProfile.sectors,
|
||||
symbol: assetProfile.currency,
|
||||
symbolMapping: assetProfile.symbolMapping,
|
||||
updatedAt: assetProfile.updatedAt,
|
||||
url: assetProfile.url,
|
||||
...assetProfiles[assetProfile.symbol]
|
||||
},
|
||||
Account: validatedAccount,
|
||||
symbolProfileId: undefined,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
} else {
|
||||
if (error) {
|
||||
continue;
|
||||
}
|
||||
|
||||
order = await this.orderService.createOrder({
|
||||
comment,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccount?.id,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency: assetProfile.currency,
|
||||
dataSource: assetProfile.dataSource,
|
||||
symbol: assetProfile.symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource: assetProfile.dataSource,
|
||||
symbol: assetProfile.symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
updateAccountBalance: false,
|
||||
User: { connect: { id: userId } }
|
||||
});
|
||||
}
|
||||
|
||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||
|
||||
//@ts-ignore
|
||||
activities.push({
|
||||
...order,
|
||||
error,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
fee,
|
||||
assetProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
assetProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return activities;
|
||||
}
|
||||
|
||||
private async 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,
|
||||
@ -179,65 +421,17 @@ export class ImportService {
|
||||
countries: null,
|
||||
createdAt: undefined,
|
||||
id: undefined,
|
||||
isin: null,
|
||||
name: null,
|
||||
scraperConfiguration: null,
|
||||
sectors: null,
|
||||
symbolMapping: null,
|
||||
updatedAt: undefined,
|
||||
url: null,
|
||||
...assetProfiles[symbol]
|
||||
},
|
||||
symbolProfileId: undefined,
|
||||
updatedAt: new Date()
|
||||
url: null
|
||||
}
|
||||
};
|
||||
} else {
|
||||
order = await this.orderService.createOrder({
|
||||
comment,
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccountId,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
});
|
||||
}
|
||||
|
||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||
|
||||
activities.push({
|
||||
...order,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
fee,
|
||||
currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
currency,
|
||||
userCurrency
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return activities;
|
||||
);
|
||||
}
|
||||
|
||||
private isUniqueAccount(accounts: AccountWithPlatform[]) {
|
||||
@ -266,33 +460,11 @@ export class ImportService {
|
||||
const assetProfiles: {
|
||||
[symbol: string]: Partial<SymbolProfile>;
|
||||
} = {};
|
||||
const existingActivities = await this.orderService.orders({
|
||||
include: { SymbolProfile: true },
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||
{ currency, dataSource, symbol }
|
||||
] of activitiesDto.entries()) {
|
||||
const duplicateActivity = existingActivities.find((activity) => {
|
||||
return (
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
activity.SymbolProfile.dataSource === dataSource &&
|
||||
isSameDay(activity.date, parseISO(<string>(<unknown>date))) &&
|
||||
activity.fee === fee &&
|
||||
activity.quantity === quantity &&
|
||||
activity.SymbolProfile.symbol === symbol &&
|
||||
activity.type === type &&
|
||||
activity.unitPrice === unitPrice
|
||||
);
|
||||
});
|
||||
|
||||
if (duplicateActivity) {
|
||||
throw new Error(`activities.${index} is a duplicate activity`);
|
||||
}
|
||||
|
||||
if (dataSource !== 'MANUAL') {
|
||||
const assetProfile = (
|
||||
await this.dataProviderService.getAssetProfiles([
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
@ -26,6 +27,7 @@ import { InfoService } from './info.service';
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
}),
|
||||
PlatformModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import {
|
||||
DEMO_USER_ID,
|
||||
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||
PROPERTY_DEMO_USER_ID,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||
PROPERTY_STRIPE_CONFIG,
|
||||
@ -15,18 +17,22 @@ import {
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
encodeDataSource,
|
||||
extractNumberFromString
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import {
|
||||
InfoItem,
|
||||
Statistics,
|
||||
Subscription
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { subDays } from 'date-fns';
|
||||
import { format, subDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class InfoService {
|
||||
@ -37,6 +43,7 @@ export class InfoService {
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly platformService: PlatformService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
@ -46,9 +53,12 @@ export class InfoService {
|
||||
public async get(): Promise<InfoItem> {
|
||||
const info: Partial<InfoItem> = {};
|
||||
let isReadOnlyMode: boolean;
|
||||
const platforms = await this.prismaService.platform.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true }
|
||||
const platforms = (
|
||||
await this.platformService.getPlatforms({
|
||||
orderBy: { name: 'asc' }
|
||||
})
|
||||
).map(({ id, name }) => {
|
||||
return { id, name };
|
||||
});
|
||||
let systemMessage: string;
|
||||
|
||||
@ -59,9 +69,7 @@ export class InfoService {
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||
) {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
info.fearAndGreedDataSource = encodeDataSource(
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
);
|
||||
@ -72,10 +80,6 @@ export class InfoService {
|
||||
globalPermissions.push(permissions.enableFearAndGreedIndex);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
globalPermissions.push(permissions.enableImport);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
isReadOnlyMode = (await this.propertyService.getByKey(
|
||||
PROPERTY_IS_READ_ONLY_MODE
|
||||
@ -115,19 +119,28 @@ export class InfoService {
|
||||
globalPermissions.push(permissions.createUserAccount);
|
||||
}
|
||||
|
||||
const [benchmarks, demoAuthToken, statistics, subscriptions, tags] =
|
||||
await Promise.all([
|
||||
this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||
this.getDemoAuthToken(),
|
||||
this.getStatistics(),
|
||||
this.getSubscriptions(),
|
||||
this.tagService.get()
|
||||
]);
|
||||
|
||||
return {
|
||||
...info,
|
||||
benchmarks,
|
||||
demoAuthToken,
|
||||
globalPermissions,
|
||||
isReadOnlyMode,
|
||||
platforms,
|
||||
statistics,
|
||||
subscriptions,
|
||||
systemMessage,
|
||||
tags,
|
||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
statistics: await this.getStatistics(),
|
||||
subscriptions: await this.getSubscriptions(),
|
||||
tags: await this.tagService.get()
|
||||
currencies: this.exchangeRateDataService.getCurrencies()
|
||||
};
|
||||
}
|
||||
|
||||
@ -252,10 +265,18 @@ export class InfoService {
|
||||
)) as string;
|
||||
}
|
||||
|
||||
private getDemoAuthToken() {
|
||||
return this.jwtService.sign({
|
||||
id: DEMO_USER_ID
|
||||
});
|
||||
private async getDemoAuthToken() {
|
||||
const demoUserId = (await this.propertyService.getByKey(
|
||||
PROPERTY_DEMO_USER_ID
|
||||
)) as string;
|
||||
|
||||
if (demoUserId) {
|
||||
return this.jwtService.sign({
|
||||
id: demoUserId
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async getStatistics() {
|
||||
@ -283,6 +304,7 @@ export class InfoService {
|
||||
const gitHubContributors = await this.countGitHubContributors();
|
||||
const gitHubStargazers = await this.countGitHubStargazers();
|
||||
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||
const uptime = await this.getUptime();
|
||||
|
||||
statistics = {
|
||||
activeUsers1d,
|
||||
@ -291,7 +313,8 @@ export class InfoService {
|
||||
gitHubContributors,
|
||||
gitHubStargazers,
|
||||
newUsers30d,
|
||||
slackCommunityUsers
|
||||
slackCommunityUsers,
|
||||
uptime
|
||||
};
|
||||
|
||||
await this.redisCacheService.set(
|
||||
@ -302,19 +325,49 @@ export class InfoService {
|
||||
return statistics;
|
||||
}
|
||||
|
||||
private async getSubscriptions(): Promise<Subscription[]> {
|
||||
private async getSubscriptions(): Promise<{
|
||||
[offer in SubscriptionOffer]: Subscription;
|
||||
}> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let subscriptions: Subscription[] = [];
|
||||
|
||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||
})) ?? { 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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LogoController } from './logo.controller';
|
||||
|
@ -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 { HttpException, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
@ -64,4 +65,8 @@ export class CreateOrderDto {
|
||||
|
||||
@IsNumber()
|
||||
unitPrice: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
updateAccountBalance?: boolean;
|
||||
}
|
||||
|
@ -5,7 +5,14 @@ export interface Activities {
|
||||
}
|
||||
|
||||
export interface Activity extends OrderWithAccount {
|
||||
error?: ActivityError;
|
||||
feeInBaseCurrency: number;
|
||||
updateAccountBalance?: boolean;
|
||||
value: number;
|
||||
valueInBaseCurrency: number;
|
||||
}
|
||||
|
||||
export interface ActivityError {
|
||||
code: 'IS_DUPLICATE';
|
||||
message?: string;
|
||||
}
|
||||
|
@ -2,7 +2,8 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
|
||||
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 { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
@ -40,6 +41,23 @@ export class OrderController {
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Delete()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteOrders(): Promise<number> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.orderService.deleteOrders({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||
@ -66,7 +84,7 @@ export class OrderController {
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getAllOrders(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
@ -78,10 +96,7 @@ export class OrderController {
|
||||
});
|
||||
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
const activities = await this.orderService.getOrders({
|
||||
|
@ -3,13 +3,13 @@ import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { OrderController } from './order.controller';
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
@ -73,6 +73,7 @@ export class OrderService {
|
||||
dataSource?: DataSource;
|
||||
symbol?: string;
|
||||
tags?: Tag[];
|
||||
updateAccountBalance?: boolean;
|
||||
userId: string;
|
||||
}
|
||||
): Promise<Order> {
|
||||
@ -89,12 +90,16 @@ export class OrderService {
|
||||
};
|
||||
}
|
||||
|
||||
const accountId = data.accountId;
|
||||
let currency = data.currency;
|
||||
const tags = data.tags ?? [];
|
||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||
const userId = data.userId;
|
||||
|
||||
if (data.type === 'ITEM') {
|
||||
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
||||
const assetClass = data.assetClass;
|
||||
const assetSubClass = data.assetSubClass;
|
||||
const currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||
const dataSource: DataSource = 'MANUAL';
|
||||
const id = uuidv4();
|
||||
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||
@ -110,21 +115,24 @@ export class OrderService {
|
||||
dataSource,
|
||||
symbol: id
|
||||
};
|
||||
} else {
|
||||
data.SymbolProfile.connectOrCreate.create.symbol =
|
||||
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
||||
}
|
||||
|
||||
await this.dataGatheringService.addJobToQueue(
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
{
|
||||
await this.dataGatheringService.addJobToQueue({
|
||||
data: {
|
||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||
},
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
);
|
||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||
opts: {
|
||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}`
|
||||
}
|
||||
});
|
||||
|
||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||
const isDraft =
|
||||
data.type === 'LIABILITY'
|
||||
? false
|
||||
: isAfter(data.date as Date, endOfToday());
|
||||
|
||||
if (!isDraft) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
@ -149,11 +157,12 @@ export class OrderService {
|
||||
delete data.dataSource;
|
||||
delete data.symbol;
|
||||
delete data.tags;
|
||||
delete data.updateAccountBalance;
|
||||
delete data.userId;
|
||||
|
||||
const orderData: Prisma.OrderCreateInput = data;
|
||||
|
||||
return this.prismaService.order.create({
|
||||
const order = await this.prismaService.order.create({
|
||||
data: {
|
||||
...orderData,
|
||||
Account,
|
||||
@ -165,6 +174,27 @@ export class OrderService {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (updateAccountBalance === true) {
|
||||
let amount = new Big(data.unitPrice)
|
||||
.mul(data.quantity)
|
||||
.plus(data.fee)
|
||||
.toNumber();
|
||||
|
||||
if (data.type === 'BUY') {
|
||||
amount = new Big(amount).mul(-1).toNumber();
|
||||
}
|
||||
|
||||
await this.accountService.updateAccountBalance({
|
||||
accountId,
|
||||
amount,
|
||||
currency,
|
||||
userId,
|
||||
date: data.date as Date
|
||||
});
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
public async deleteOrder(
|
||||
@ -174,13 +204,21 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
|
||||
if (order.type === 'ITEM') {
|
||||
if (order.type === 'ITEM' || order.type === 'LIABILITY') {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> {
|
||||
const { count } = await this.prismaService.order.deleteMany({
|
||||
where
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async getOrders({
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
@ -285,7 +323,11 @@ export class OrderService {
|
||||
})
|
||||
)
|
||||
.filter((order) => {
|
||||
return withExcludedAccounts || order.Account?.isExcluded === false;
|
||||
return (
|
||||
withExcludedAccounts ||
|
||||
!order.Account ||
|
||||
order.Account?.isExcluded === false
|
||||
);
|
||||
})
|
||||
.map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
@ -333,7 +375,7 @@ export class OrderService {
|
||||
|
||||
let isDraft = false;
|
||||
|
||||
if (data.type === 'ITEM') {
|
||||
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
||||
delete data.SymbolProfile.connect;
|
||||
} else {
|
||||
delete data.SymbolProfile.update;
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
|
9
apps/api/src/app/platform/create-platform.dto.ts
Normal file
9
apps/api/src/app/platform/create-platform.dto.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class CreatePlatformDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
url: string;
|
||||
}
|
114
apps/api/src/app/platform/platform.controller.ts
Normal file
114
apps/api/src/app/platform/platform.controller.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Platform } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { CreatePlatformDto } from './create-platform.dto';
|
||||
import { PlatformService } from './platform.service';
|
||||
import { UpdatePlatformDto } from './update-platform.dto';
|
||||
|
||||
@Controller('platform')
|
||||
export class PlatformController {
|
||||
public constructor(
|
||||
private readonly platformService: PlatformService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPlatforms() {
|
||||
return this.platformService.getPlatformsWithAccountCount();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async createPlatform(
|
||||
@Body() data: CreatePlatformDto
|
||||
): Promise<Platform> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createPlatform)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
return this.platformService.createPlatform(data);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updatePlatform(
|
||||
@Param('id') id: string,
|
||||
@Body() data: UpdatePlatformDto
|
||||
) {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.updatePlatform)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const originalPlatform = await this.platformService.getPlatform({
|
||||
id
|
||||
});
|
||||
|
||||
if (!originalPlatform) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.platformService.updatePlatform({
|
||||
data: {
|
||||
...data
|
||||
},
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deletePlatform(@Param('id') id: string) {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deletePlatform)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const originalPlatform = await this.platformService.getPlatform({
|
||||
id
|
||||
});
|
||||
|
||||
if (!originalPlatform) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.platformService.deletePlatform({ id });
|
||||
}
|
||||
}
|
13
apps/api/src/app/platform/platform.module.ts
Normal file
13
apps/api/src/app/platform/platform.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PlatformController } from './platform.controller';
|
||||
import { PlatformService } from './platform.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PlatformController],
|
||||
exports: [PlatformService],
|
||||
imports: [PrismaModule],
|
||||
providers: [PlatformService]
|
||||
})
|
||||
export class PlatformModule {}
|
83
apps/api/src/app/platform/platform.service.ts
Normal file
83
apps/api/src/app/platform/platform.service.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Platform, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async getPlatform(
|
||||
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
|
||||
): Promise<Platform> {
|
||||
return this.prismaService.platform.findUnique({
|
||||
where: platformWhereUniqueInput
|
||||
});
|
||||
}
|
||||
|
||||
public async getPlatforms({
|
||||
cursor,
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where
|
||||
}: {
|
||||
cursor?: Prisma.PlatformWhereUniqueInput;
|
||||
orderBy?: Prisma.PlatformOrderByWithRelationInput;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
where?: Prisma.PlatformWhereInput;
|
||||
} = {}) {
|
||||
return this.prismaService.platform.findMany({
|
||||
cursor,
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async getPlatformsWithAccountCount() {
|
||||
const platformsWithAccountCount =
|
||||
await this.prismaService.platform.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { Account: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return platformsWithAccountCount.map(({ _count, id, name, url }) => {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
url,
|
||||
accountCount: _count.Account
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async createPlatform(data: Prisma.PlatformCreateInput) {
|
||||
return this.prismaService.platform.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async updatePlatform({
|
||||
data,
|
||||
where
|
||||
}: {
|
||||
data: Prisma.PlatformUpdateInput;
|
||||
where: Prisma.PlatformWhereUniqueInput;
|
||||
}): Promise<Platform> {
|
||||
return this.prismaService.platform.update({
|
||||
data,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async deletePlatform(
|
||||
where: Prisma.PlatformWhereUniqueInput
|
||||
): Promise<Platform> {
|
||||
return this.prismaService.platform.delete({ where });
|
||||
}
|
||||
}
|
12
apps/api/src/app/platform/update-platform.dto.ts
Normal file
12
apps/api/src/app/platform/update-platform.dto.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class UpdatePlatformDto {
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
url: string;
|
||||
}
|
@ -2,6 +2,7 @@ import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
|
||||
function mockGetValue(symbol: string, date: Date) {
|
||||
@ -48,8 +49,9 @@ export const CurrentRateServiceMock = {
|
||||
getValues: ({
|
||||
dataGatheringItems,
|
||||
dateQuery
|
||||
}: GetValuesParams): Promise<GetValueObject[]> => {
|
||||
const result: GetValueObject[] = [];
|
||||
}: GetValuesParams): Promise<GetValuesObject> => {
|
||||
const values: GetValueObject[] = [];
|
||||
|
||||
if (dateQuery.lt) {
|
||||
for (
|
||||
let date = resetHours(dateQuery.gte);
|
||||
@ -57,7 +59,7 @@ export const CurrentRateServiceMock = {
|
||||
date = addDays(date, 1)
|
||||
) {
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
values.push({
|
||||
date,
|
||||
marketPriceInBaseCurrency: mockGetValue(
|
||||
dataGatheringItem.symbol,
|
||||
@ -70,7 +72,7 @@ export const CurrentRateServiceMock = {
|
||||
} else {
|
||||
for (const date of dateQuery.in) {
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
values.push({
|
||||
date,
|
||||
marketPriceInBaseCurrency: mockGetValue(
|
||||
dataGatheringItem.symbol,
|
||||
@ -81,6 +83,7 @@ export const CurrentRateServiceMock = {
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.resolve(result);
|
||||
|
||||
return Promise.resolve({ values, dataProviderInfos: [], errors: [] });
|
||||
}
|
||||
};
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||
|
||||
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||
jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
|
||||
return {
|
||||
MarketDataService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
@ -17,7 +18,8 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||
createdAt: date,
|
||||
dataSource: DataSource.YAHOO,
|
||||
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
||||
marketPrice: 1847.839966
|
||||
marketPrice: 1847.839966,
|
||||
state: 'CLOSE'
|
||||
});
|
||||
},
|
||||
getRange: ({
|
||||
@ -36,6 +38,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||
date: dateRangeStart,
|
||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||
marketPrice: 1841.823902,
|
||||
state: 'CLOSE',
|
||||
symbol: symbols[0]
|
||||
},
|
||||
{
|
||||
@ -44,6 +47,7 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||
date: dateRangeEnd,
|
||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||
marketPrice: 1847.839966,
|
||||
state: 'CLOSE',
|
||||
symbol: symbols[0]
|
||||
}
|
||||
]);
|
||||
@ -53,14 +57,27 @@ jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
|
||||
jest.mock(
|
||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||
() => {
|
||||
return {
|
||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
initialize: () => Promise.resolve(),
|
||||
toCurrency: (value: number) => {
|
||||
return 1 * value;
|
||||
}
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
jest.mock('@ghostfolio/api/services/property/property.service', () => {
|
||||
return {
|
||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||
PropertyService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
initialize: () => Promise.resolve(),
|
||||
toCurrency: (value: number) => {
|
||||
return 1 * value;
|
||||
}
|
||||
getByKey: (key: string) => Promise.resolve({})
|
||||
};
|
||||
})
|
||||
};
|
||||
@ -71,9 +88,19 @@ describe('CurrentRateService', () => {
|
||||
let dataProviderService: DataProviderService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let marketDataService: MarketDataService;
|
||||
let propertyService: PropertyService;
|
||||
|
||||
beforeAll(async () => {
|
||||
dataProviderService = new DataProviderService(null, [], null);
|
||||
propertyService = new PropertyService(null);
|
||||
|
||||
dataProviderService = new DataProviderService(
|
||||
null,
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
propertyService,
|
||||
null
|
||||
);
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
@ -103,17 +130,16 @@ describe('CurrentRateService', () => {
|
||||
},
|
||||
userCurrency: 'CHF'
|
||||
})
|
||||
).toMatchObject<GetValueObject[]>([
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1841.823902,
|
||||
symbol: 'AMZN'
|
||||
},
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1847.839966,
|
||||
symbol: 'AMZN'
|
||||
}
|
||||
]);
|
||||
).toMatchObject<GetValuesObject>({
|
||||
dataProviderInfos: [],
|
||||
errors: [],
|
||||
values: [
|
||||
{
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1841.823902,
|
||||
symbol: 'AMZN'
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
import { flatten, isEmpty, uniqBy } from 'lodash';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { GetValuesObject } from './interfaces/get-values-object.interface';
|
||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
|
||||
@Injectable()
|
||||
@ -22,34 +24,52 @@ export class CurrentRateService {
|
||||
dataGatheringItems,
|
||||
dateQuery,
|
||||
userCurrency
|
||||
}: GetValuesParams): Promise<GetValueObject[]> {
|
||||
}: GetValuesParams): Promise<GetValuesObject> {
|
||||
const dataProviderInfos: DataProviderInfo[] = [];
|
||||
const includeToday =
|
||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||
|
||||
const promises: Promise<GetValueObject[]>[] = [];
|
||||
const quoteErrors: ResponseError['errors'] = [];
|
||||
const today = resetHours(new Date());
|
||||
|
||||
if (includeToday) {
|
||||
const today = resetHours(new Date());
|
||||
promises.push(
|
||||
this.dataProviderService
|
||||
.getQuotes(dataGatheringItems)
|
||||
.then((dataResultProvider) => {
|
||||
const result: GetValueObject[] = [];
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
date: today,
|
||||
marketPriceInBaseCurrency:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
dataResultProvider?.[dataGatheringItem.symbol]
|
||||
?.marketPrice ?? 0,
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||
userCurrency
|
||||
),
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
if (
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
|
||||
) {
|
||||
dataProviderInfos.push(
|
||||
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
|
||||
);
|
||||
}
|
||||
|
||||
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
|
||||
result.push({
|
||||
date: today,
|
||||
marketPriceInBaseCurrency:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
dataResultProvider?.[dataGatheringItem.symbol]
|
||||
?.marketPrice,
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||
userCurrency
|
||||
),
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
} else {
|
||||
quoteErrors.push({
|
||||
dataSource: dataGatheringItem.dataSource,
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
@ -81,7 +101,60 @@ export class CurrentRateService {
|
||||
})
|
||||
);
|
||||
|
||||
return flatten(await Promise.all(promises));
|
||||
const values = flatten(await Promise.all(promises));
|
||||
|
||||
const response: GetValuesObject = {
|
||||
dataProviderInfos,
|
||||
errors: quoteErrors.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
}),
|
||||
values: uniqBy(values, ({ date, symbol }) => `${date}-${symbol}`)
|
||||
};
|
||||
|
||||
if (!isEmpty(quoteErrors)) {
|
||||
for (const { symbol } of quoteErrors) {
|
||||
try {
|
||||
// If missing quote, fallback to the latest available historical market price
|
||||
let value: GetValueObject = response.values.find((currentValue) => {
|
||||
return currentValue.symbol === symbol && isToday(currentValue.date);
|
||||
});
|
||||
|
||||
if (!value) {
|
||||
value = {
|
||||
symbol,
|
||||
date: today,
|
||||
marketPriceInBaseCurrency: 0
|
||||
};
|
||||
|
||||
response.values.push(value);
|
||||
}
|
||||
|
||||
const [latestValue] = response.values
|
||||
.filter((currentValue) => {
|
||||
return (
|
||||
currentValue.symbol === symbol &&
|
||||
currentValue.marketPriceInBaseCurrency
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.date < b.date) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.date > b.date) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
value.marketPriceInBaseCurrency =
|
||||
latestValue.marketPriceInBaseCurrency;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private containsToday(dates: Date[]): boolean {
|
||||
|
@ -0,0 +1,9 @@
|
||||
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { GetValueObject } from './get-value-object.interface';
|
||||
|
||||
export interface GetValuesObject {
|
||||
dataProviderInfos: DataProviderInfo[];
|
||||
errors: ResponseError['errors'];
|
||||
values: GetValueObject[];
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import {
|
||||
DataProviderInfo,
|
||||
EnhancedSymbolProfile,
|
||||
HistoricalDataItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -7,6 +8,7 @@ import { Tag } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
averagePrice: number;
|
||||
dataProviderInfo: DataProviderInfo;
|
||||
dividendInBaseCurrency: number;
|
||||
feeInBaseCurrency: number;
|
||||
firstBuyDate: string;
|
||||
|
@ -105,6 +105,40 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||
{ date: '2015-02-01', investment: new Big('0') },
|
||||
{ date: '2015-03-01', investment: new Big('0') },
|
||||
{ date: '2015-04-01', investment: new Big('0') },
|
||||
{ date: '2015-05-01', investment: new Big('0') },
|
||||
{ date: '2015-06-01', investment: new Big('0') },
|
||||
{ date: '2015-07-01', investment: new Big('0') },
|
||||
{ date: '2015-08-01', investment: new Big('0') },
|
||||
{ date: '2015-09-01', investment: new Big('0') },
|
||||
{ date: '2015-10-01', investment: new Big('0') },
|
||||
{ date: '2015-11-01', investment: new Big('0') },
|
||||
{ date: '2015-12-01', investment: new Big('0') },
|
||||
{ date: '2016-01-01', investment: new Big('0') },
|
||||
{ date: '2016-02-01', investment: new Big('0') },
|
||||
{ date: '2016-03-01', investment: new Big('0') },
|
||||
{ date: '2016-04-01', investment: new Big('0') },
|
||||
{ date: '2016-05-01', investment: new Big('0') },
|
||||
{ date: '2016-06-01', investment: new Big('0') },
|
||||
{ date: '2016-07-01', investment: new Big('0') },
|
||||
{ date: '2016-08-01', investment: new Big('0') },
|
||||
{ date: '2016-09-01', investment: new Big('0') },
|
||||
{ date: '2016-10-01', investment: new Big('0') },
|
||||
{ date: '2016-11-01', investment: new Big('0') },
|
||||
{ date: '2016-12-01', investment: new Big('0') },
|
||||
{ date: '2017-01-01', investment: new Big('0') },
|
||||
{ date: '2017-02-01', investment: new Big('0') },
|
||||
{ date: '2017-03-01', investment: new Big('0') },
|
||||
{ date: '2017-04-01', investment: new Big('0') },
|
||||
{ date: '2017-05-01', investment: new Big('0') },
|
||||
{ date: '2017-06-01', investment: new Big('0') },
|
||||
{ date: '2017-07-01', investment: new Big('0') },
|
||||
{ date: '2017-08-01', investment: new Big('0') },
|
||||
{ date: '2017-09-01', investment: new Big('0') },
|
||||
{ date: '2017-10-01', investment: new Big('0') },
|
||||
{ date: '2017-11-01', investment: new Big('0') },
|
||||
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
||||
]);
|
||||
});
|
||||
|
@ -86,7 +86,7 @@ describe('PortfolioCalculator', () => {
|
||||
netPerformanceInPercentage: 13.100263852242744,
|
||||
netPerformance: 19.86,
|
||||
totalInvestment: 0,
|
||||
value: 19.86
|
||||
value: 0
|
||||
});
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
ResponseError,
|
||||
TimelinePosition
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { GroupBy } from '@ghostfolio/common/types';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
@ -20,9 +24,10 @@ import {
|
||||
isSameYear,
|
||||
max,
|
||||
min,
|
||||
set
|
||||
set,
|
||||
subDays
|
||||
} from 'date-fns';
|
||||
import { first, flatten, isNumber, last, sortBy } from 'lodash';
|
||||
import { first, flatten, isNumber, last, sortBy, uniq } from 'lodash';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||
@ -45,6 +50,7 @@ export class PortfolioCalculator {
|
||||
|
||||
private currency: string;
|
||||
private currentRateService: CurrentRateService;
|
||||
private dataProviderInfos: DataProviderInfo[];
|
||||
private orders: PortfolioOrder[];
|
||||
private transactionPoints: TransactionPoint[];
|
||||
|
||||
@ -177,10 +183,10 @@ export class PortfolioCalculator {
|
||||
return isBefore(parseDate(transactionPoint.date), end);
|
||||
}) ?? [];
|
||||
|
||||
const firstIndex = transactionPointsBeforeEndDate.length;
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
const dates: Date[] = [];
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
const firstIndex = transactionPointsBeforeEndDate.length;
|
||||
|
||||
let day = start;
|
||||
|
||||
@ -202,14 +208,17 @@ export class PortfolioCalculator {
|
||||
symbols[item.symbol] = true;
|
||||
}
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
const { dataProviderInfos, values: marketSymbols } =
|
||||
await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
this.dataProviderInfos = dataProviderInfos;
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
@ -227,87 +236,100 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
const netPerformanceValuesBySymbol: {
|
||||
[symbol: string]: { [date: string]: Big };
|
||||
const valuesByDate: {
|
||||
[date: string]: {
|
||||
maxTotalInvestmentValue: Big;
|
||||
totalCurrentValue: Big;
|
||||
totalInvestmentValue: Big;
|
||||
totalNetPerformanceValue: Big;
|
||||
};
|
||||
} = {};
|
||||
|
||||
const investmentValuesBySymbol: {
|
||||
[symbol: string]: { [date: string]: Big };
|
||||
const valuesBySymbol: {
|
||||
[symbol: string]: {
|
||||
currentValues: { [date: string]: Big };
|
||||
investmentValues: { [date: string]: Big };
|
||||
maxInvestmentValues: { [date: string]: Big };
|
||||
netPerformanceValues: { [date: string]: Big };
|
||||
};
|
||||
} = {};
|
||||
|
||||
const 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)) {
|
||||
const { investmentValues, maxInvestmentValues, netPerformanceValues } =
|
||||
this.getSymbolMetrics({
|
||||
end,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
step,
|
||||
symbol,
|
||||
isChartMode: true
|
||||
});
|
||||
const {
|
||||
currentValues,
|
||||
investmentValues,
|
||||
maxInvestmentValues,
|
||||
netPerformanceValues
|
||||
} = this.getSymbolMetrics({
|
||||
end,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
step,
|
||||
symbol,
|
||||
isChartMode: true
|
||||
});
|
||||
|
||||
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
|
||||
investmentValuesBySymbol[symbol] = investmentValues;
|
||||
maxInvestmentValuesBySymbol[symbol] = maxInvestmentValues;
|
||||
valuesBySymbol[symbol] = {
|
||||
currentValues,
|
||||
investmentValues,
|
||||
maxInvestmentValues,
|
||||
netPerformanceValues
|
||||
};
|
||||
}
|
||||
|
||||
for (const currentDate of dates) {
|
||||
const dateString = format(currentDate, DATE_FORMAT);
|
||||
|
||||
for (const symbol of Object.keys(netPerformanceValuesBySymbol)) {
|
||||
totalNetPerformanceValues[dateString] =
|
||||
totalNetPerformanceValues[dateString] ?? new Big(0);
|
||||
for (const symbol of Object.keys(valuesBySymbol)) {
|
||||
const symbolValues = valuesBySymbol[symbol];
|
||||
|
||||
if (netPerformanceValuesBySymbol[symbol]?.[dateString]) {
|
||||
totalNetPerformanceValues[dateString] = totalNetPerformanceValues[
|
||||
dateString
|
||||
].add(netPerformanceValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
const currentValue =
|
||||
symbolValues.currentValues?.[dateString] ?? new Big(0);
|
||||
const investmentValue =
|
||||
symbolValues.investmentValues?.[dateString] ?? new Big(0);
|
||||
const maxInvestmentValue =
|
||||
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
|
||||
const netPerformanceValue =
|
||||
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
|
||||
|
||||
totalInvestmentValues[dateString] =
|
||||
totalInvestmentValues[dateString] ?? new Big(0);
|
||||
|
||||
maxTotalInvestmentValues[dateString] =
|
||||
maxTotalInvestmentValues[dateString] ?? new Big(0);
|
||||
|
||||
if (investmentValuesBySymbol[symbol]?.[dateString]) {
|
||||
totalInvestmentValues[dateString] = totalInvestmentValues[
|
||||
dateString
|
||||
].add(investmentValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
|
||||
if (maxInvestmentValuesBySymbol[symbol]?.[dateString]) {
|
||||
maxTotalInvestmentValues[dateString] = maxTotalInvestmentValues[
|
||||
dateString
|
||||
].add(maxInvestmentValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
valuesByDate[dateString] = {
|
||||
totalCurrentValue: (
|
||||
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
|
||||
).add(currentValue),
|
||||
totalInvestmentValue: (
|
||||
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
|
||||
).add(investmentValue),
|
||||
maxTotalInvestmentValue: (
|
||||
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
|
||||
).add(maxInvestmentValue),
|
||||
totalNetPerformanceValue: (
|
||||
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
|
||||
).add(netPerformanceValue)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(totalNetPerformanceValues).map((date) => {
|
||||
const netPerformanceInPercentage = maxTotalInvestmentValues[date].eq(0)
|
||||
return Object.entries(valuesByDate).map(([date, values]) => {
|
||||
const {
|
||||
maxTotalInvestmentValue,
|
||||
totalCurrentValue,
|
||||
totalInvestmentValue,
|
||||
totalNetPerformanceValue
|
||||
} = values;
|
||||
|
||||
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
|
||||
? 0
|
||||
: totalNetPerformanceValues[date]
|
||||
.div(maxTotalInvestmentValues[date])
|
||||
: totalNetPerformanceValue
|
||||
.div(maxTotalInvestmentValue)
|
||||
.mul(100)
|
||||
.toNumber();
|
||||
|
||||
return {
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
netPerformance: totalNetPerformanceValues[date].toNumber(),
|
||||
totalInvestment: totalInvestmentValues[date].toNumber(),
|
||||
value: totalInvestmentValues[date]
|
||||
.plus(totalNetPerformanceValues[date])
|
||||
.toNumber()
|
||||
netPerformance: totalNetPerformanceValue.toNumber(),
|
||||
totalInvestment: totalInvestmentValue.toNumber(),
|
||||
value: totalCurrentValue.toNumber()
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -339,7 +361,7 @@ export class PortfolioCalculator {
|
||||
|
||||
let firstTransactionPoint: TransactionPoint = null;
|
||||
let firstIndex = transactionPointsBeforeEndDate.length;
|
||||
const dates = [];
|
||||
let dates = [];
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
|
||||
@ -368,7 +390,30 @@ export class PortfolioCalculator {
|
||||
|
||||
dates.push(resetHours(end));
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
// Add dates of last week for fallback
|
||||
dates.push(subDays(resetHours(new Date()), 7));
|
||||
dates.push(subDays(resetHours(new Date()), 6));
|
||||
dates.push(subDays(resetHours(new Date()), 5));
|
||||
dates.push(subDays(resetHours(new Date()), 4));
|
||||
dates.push(subDays(resetHours(new Date()), 3));
|
||||
dates.push(subDays(resetHours(new Date()), 2));
|
||||
dates.push(subDays(resetHours(new Date()), 1));
|
||||
dates.push(resetHours(new Date()));
|
||||
|
||||
dates = uniq(
|
||||
dates.map((date) => {
|
||||
return date.getTime();
|
||||
})
|
||||
).map((timestamp) => {
|
||||
return new Date(timestamp);
|
||||
});
|
||||
dates.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
const {
|
||||
dataProviderInfos,
|
||||
errors: currentRateErrors,
|
||||
values: marketSymbols
|
||||
} = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
@ -377,6 +422,8 @@ export class PortfolioCalculator {
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
this.dataProviderInfos = dataProviderInfos;
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
@ -448,7 +495,13 @@ export class PortfolioCalculator {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@ -463,6 +516,10 @@ export class PortfolioCalculator {
|
||||
};
|
||||
}
|
||||
|
||||
public getDataProviderInfos() {
|
||||
return this.dataProviderInfos;
|
||||
}
|
||||
|
||||
public getInvestments(): { date: string; investment: Big }[] {
|
||||
if (this.transactionPoints.length === 0) {
|
||||
return [];
|
||||
@ -487,7 +544,7 @@ export class PortfolioCalculator {
|
||||
return [];
|
||||
}
|
||||
|
||||
const investments = [];
|
||||
const investments: { date: string; investment: Big }[] = [];
|
||||
let currentDate: Date;
|
||||
let investmentByGroup = new Big(0);
|
||||
|
||||
@ -497,13 +554,11 @@ export class PortfolioCalculator {
|
||||
(groupBy === 'year' || isSameMonth(parseDate(order.date), currentDate))
|
||||
) {
|
||||
// Same group: Add up investments
|
||||
|
||||
investmentByGroup = investmentByGroup.plus(
|
||||
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||
);
|
||||
} else {
|
||||
// New group: Store previous group and reset
|
||||
|
||||
if (currentDate) {
|
||||
investments.push({
|
||||
date: format(
|
||||
@ -538,7 +593,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(
|
||||
@ -694,7 +781,7 @@ export class PortfolioCalculator {
|
||||
);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
Logger.warn(
|
||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
|
||||
`Missing historical market data for symbol ${currentPosition.symbol}`,
|
||||
'PortfolioCalculator'
|
||||
);
|
||||
hasErrors = true;
|
||||
@ -748,7 +835,7 @@ export class PortfolioCalculator {
|
||||
let marketSymbols: GetValueObject[] = [];
|
||||
if (dataGatheringItems.length > 0) {
|
||||
try {
|
||||
marketSymbols = await this.currentRateService.getValues({
|
||||
const { values } = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
@ -757,6 +844,7 @@ export class PortfolioCalculator {
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
marketSymbols = values;
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Failed to fetch info for date ${startDate} with exception`,
|
||||
@ -890,12 +978,16 @@ export class PortfolioCalculator {
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
currentValues: {},
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
hasErrors: false,
|
||||
initialValue: new Big(0),
|
||||
investmentValues: {},
|
||||
maxInvestmentValues: {},
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0)
|
||||
netPerformanceValues: {}
|
||||
};
|
||||
}
|
||||
|
||||
@ -930,6 +1022,7 @@ export class PortfolioCalculator {
|
||||
let grossPerformanceFromSells = new Big(0);
|
||||
let initialValue: Big;
|
||||
let investmentAtStartDate: Big;
|
||||
const currentValues: { [date: string]: Big } = {};
|
||||
const investmentValues: { [date: string]: Big } = {};
|
||||
const maxInvestmentValues: { [date: string]: Big } = {};
|
||||
let lastAveragePrice = new Big(0);
|
||||
@ -1148,6 +1241,7 @@ export class PortfolioCalculator {
|
||||
}
|
||||
|
||||
if (isChartMode && i > indexOfStartOrder) {
|
||||
currentValues[order.date] = valueOfInvestment;
|
||||
netPerformanceValues[order.date] = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
@ -1245,15 +1339,16 @@ export class PortfolioCalculator {
|
||||
}
|
||||
|
||||
return {
|
||||
initialValue,
|
||||
currentValues,
|
||||
grossPerformancePercentage,
|
||||
initialValue,
|
||||
investmentValues,
|
||||
maxInvestmentValues,
|
||||
netPerformancePercentage,
|
||||
netPerformanceValues,
|
||||
grossPerformance: totalGrossPerformance,
|
||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||
netPerformance: totalNetPerformance,
|
||||
grossPerformance: totalGrossPerformance
|
||||
netPerformance: totalNetPerformance
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -8,8 +8,9 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
|
||||
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 { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioDividends,
|
||||
@ -65,7 +66,7 @@ export class PortfolioController {
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getDetails(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@ -90,6 +91,7 @@ export class PortfolioController {
|
||||
filteredValueInPercentage,
|
||||
hasErrors,
|
||||
holdings,
|
||||
platforms,
|
||||
summary,
|
||||
totalValueInBaseCurrency
|
||||
} = await this.portfolioService.getDetails({
|
||||
@ -135,9 +137,12 @@ export class PortfolioController {
|
||||
portfolioPosition.value / totalValue;
|
||||
}
|
||||
|
||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||
accounts[name].current = current / totalValue;
|
||||
accounts[name].original = original / totalInvestment;
|
||||
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
|
||||
accounts[name].valueInPercentage = valueInBaseCurrency / totalValue;
|
||||
}
|
||||
|
||||
for (const [name, { valueInBaseCurrency }] of Object.entries(platforms)) {
|
||||
platforms[name].valueInPercentage = valueInBaseCurrency / totalValue;
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,6 +162,7 @@ export class PortfolioController {
|
||||
'excludedAccountsAndActivities',
|
||||
'fees',
|
||||
'items',
|
||||
'liabilities',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalSell'
|
||||
@ -181,6 +187,7 @@ export class PortfolioController {
|
||||
filteredValueInPercentage,
|
||||
hasError,
|
||||
holdings,
|
||||
platforms,
|
||||
totalValueInBaseCurrency,
|
||||
summary: portfolioSummary
|
||||
};
|
||||
@ -189,7 +196,7 @@ export class PortfolioController {
|
||||
@Get('dividends')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getDividends(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@ -239,7 +246,7 @@ export class PortfolioController {
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getInvestments(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@ -252,11 +259,12 @@ export class PortfolioController {
|
||||
filterByTags
|
||||
});
|
||||
|
||||
let investments = await this.portfolioService.getInvestments({
|
||||
let { investments, streaks } = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
impersonationId,
|
||||
savingsRate: this.request.user?.Settings?.settings.savingsRate
|
||||
});
|
||||
|
||||
if (
|
||||
@ -272,6 +280,11 @@ export class PortfolioController {
|
||||
date: item.date,
|
||||
investment: item.investment / maxInvestment
|
||||
}));
|
||||
|
||||
streaks = nullifyValuesInObject(streaks, [
|
||||
'currentStreak',
|
||||
'longestStreak'
|
||||
]);
|
||||
}
|
||||
|
||||
if (
|
||||
@ -281,9 +294,14 @@ export class PortfolioController {
|
||||
investments = investments.map((item) => {
|
||||
return nullifyValuesInObject(item, ['investment']);
|
||||
});
|
||||
|
||||
streaks = nullifyValuesInObject(streaks, [
|
||||
'currentStreak',
|
||||
'longestStreak'
|
||||
]);
|
||||
}
|
||||
|
||||
return { investments };
|
||||
return { investments, streaks };
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
@ -291,7 +309,7 @@ export class PortfolioController {
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@Version('2')
|
||||
public async getPerformanceV2(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@ -360,7 +378,7 @@ export class PortfolioController {
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPositions(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@ -451,7 +469,7 @@ export class PortfolioController {
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('dataSource') dataSource,
|
||||
@Param('symbol') symbol
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
@ -474,7 +492,7 @@ export class PortfolioController {
|
||||
@Get('report')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getReport(
|
||||
@Headers('impersonation-id') impersonationId: string
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
||||
): Promise<PortfolioReport> {
|
||||
const report = await this.portfolioService.getReport(impersonationId);
|
||||
|
||||
|
@ -3,14 +3,14 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
|
@ -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 { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
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 { 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 { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
EMERGENCY_FUND_TAG_ID,
|
||||
MAX_CHART_ITEMS,
|
||||
@ -31,14 +28,14 @@ import {
|
||||
Filter,
|
||||
HistoricalDataItem,
|
||||
PortfolioDetails,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPosition,
|
||||
PortfolioReport,
|
||||
PortfolioSummary,
|
||||
Position,
|
||||
TimelinePosition,
|
||||
UserSettings,
|
||||
UserWithSettings
|
||||
UserSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import type {
|
||||
@ -47,7 +44,8 @@ import type {
|
||||
GroupBy,
|
||||
Market,
|
||||
OrderWithAccount,
|
||||
RequestWithUser
|
||||
RequestWithUser,
|
||||
UserWithSettings
|
||||
} from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
@ -149,7 +147,8 @@ export class PortfolioService {
|
||||
}
|
||||
}
|
||||
|
||||
const valueInBaseCurrency = details.accounts[account.id]?.current ?? 0;
|
||||
const valueInBaseCurrency =
|
||||
details.accounts[account.id]?.valueInBaseCurrency ?? 0;
|
||||
|
||||
const result = {
|
||||
...account,
|
||||
@ -254,13 +253,15 @@ export class PortfolioService {
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
impersonationId,
|
||||
savingsRate
|
||||
}: {
|
||||
dateRange: DateRange;
|
||||
filters?: Filter[];
|
||||
groupBy?: GroupBy;
|
||||
impersonationId: string;
|
||||
}): Promise<InvestmentItem[]> {
|
||||
savingsRate: number;
|
||||
}): Promise<PortfolioInvestments> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
@ -278,7 +279,10 @@ export class PortfolioService {
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
if (transactionPoints.length === 0) {
|
||||
return [];
|
||||
return {
|
||||
investments: [],
|
||||
streaks: { currentStreak: 0, longestStreak: 0 }
|
||||
};
|
||||
}
|
||||
|
||||
let investments: InvestmentItem[];
|
||||
@ -348,9 +352,23 @@ export class PortfolioService {
|
||||
parseDate(investments[0]?.date)
|
||||
);
|
||||
|
||||
return investments.filter(({ date }) => {
|
||||
investments = investments.filter(({ date }) => {
|
||||
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({
|
||||
@ -462,10 +480,18 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const holdings: PortfolioDetails['holdings'] = {};
|
||||
const totalInvestmentInBaseCurrency = currentPositions.totalInvestment.plus(
|
||||
const totalValueInBaseCurrency = currentPositions.currentValue.plus(
|
||||
cashDetails.balanceInBaseCurrency
|
||||
);
|
||||
let filteredValueInBaseCurrency = currentPositions.currentValue;
|
||||
|
||||
const isFilteredByAccount =
|
||||
filters?.some((filter) => {
|
||||
return filter.type === 'ACCOUNT';
|
||||
}) ?? false;
|
||||
|
||||
let filteredValueInBaseCurrency = isFilteredByAccount
|
||||
? totalValueInBaseCurrency
|
||||
: currentPositions.currentValue;
|
||||
|
||||
if (
|
||||
filters?.length === 0 ||
|
||||
@ -484,13 +510,10 @@ export class PortfolioService {
|
||||
symbol: position.symbol
|
||||
};
|
||||
});
|
||||
const symbols = currentPositions.positions.map(
|
||||
(position) => position.symbol
|
||||
);
|
||||
|
||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||
this.dataProviderService.getQuotes(dataGatheringItems),
|
||||
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
|
||||
this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
|
||||
]);
|
||||
|
||||
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
||||
@ -564,12 +587,11 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
filters?.length === 0 ||
|
||||
(filters?.length === 1 &&
|
||||
filters[0].type === 'ASSET_CLASS' &&
|
||||
filters[0].id === 'CASH')
|
||||
) {
|
||||
const isFilteredByCash = filters?.some((filter) => {
|
||||
return filter.type === 'ASSET_CLASS' && filter.id === 'CASH';
|
||||
});
|
||||
|
||||
if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) {
|
||||
const cashPositions = await this.getCashPositions({
|
||||
cashDetails,
|
||||
userCurrency,
|
||||
@ -581,7 +603,7 @@ export class PortfolioService {
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = await this.getValueOfAccounts({
|
||||
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
|
||||
filters,
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
@ -595,7 +617,7 @@ export class PortfolioService {
|
||||
filters[0].id === EMERGENCY_FUND_TAG_ID &&
|
||||
filters[0].type === 'TAG'
|
||||
) {
|
||||
const cashPositions = await this.getCashPositions({
|
||||
const emergencyFundCashPositions = await this.getCashPositions({
|
||||
cashDetails,
|
||||
userCurrency,
|
||||
value: filteredValueInBaseCurrency
|
||||
@ -614,13 +636,12 @@ export class PortfolioService {
|
||||
accounts[UNKNOWN_KEY] = {
|
||||
balance: 0,
|
||||
currency: userCurrency,
|
||||
current: emergencyFundInCash,
|
||||
name: UNKNOWN_KEY,
|
||||
original: emergencyFundInCash
|
||||
valueInBaseCurrency: emergencyFundInCash
|
||||
};
|
||||
|
||||
holdings[userCurrency] = {
|
||||
...cashPositions[userCurrency],
|
||||
...emergencyFundCashPositions[userCurrency],
|
||||
investment: emergencyFundInCash,
|
||||
value: emergencyFundInCash
|
||||
};
|
||||
@ -640,6 +661,7 @@ export class PortfolioService {
|
||||
return {
|
||||
accounts,
|
||||
holdings,
|
||||
platforms,
|
||||
summary,
|
||||
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
|
||||
filteredValueInPercentage: summary.netWorth
|
||||
@ -678,6 +700,7 @@ export class PortfolioService {
|
||||
return {
|
||||
tags,
|
||||
averagePrice: undefined,
|
||||
dataProviderInfo: undefined,
|
||||
dividendInBaseCurrency: undefined,
|
||||
feeInBaseCurrency: undefined,
|
||||
firstBuyDate: undefined,
|
||||
@ -791,16 +814,6 @@ export class PortfolioService {
|
||||
let maxPrice = Math.max(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]) {
|
||||
let j = -1;
|
||||
for (const [date, { marketPrice }] of Object.entries(
|
||||
@ -812,11 +825,16 @@ export class PortfolioService {
|
||||
) {
|
||||
j++;
|
||||
}
|
||||
|
||||
let currentAveragePrice = 0;
|
||||
let currentQuantity = 0;
|
||||
|
||||
const currentSymbol = transactionPoints[j].items.find(
|
||||
(item) => item.symbol === aSymbol
|
||||
({ symbol }) => {
|
||||
return symbol === aSymbol;
|
||||
}
|
||||
);
|
||||
|
||||
if (currentSymbol) {
|
||||
currentAveragePrice = currentSymbol.quantity.eq(0)
|
||||
? 0
|
||||
@ -826,14 +844,25 @@ export class PortfolioService {
|
||||
|
||||
historicalDataArray.push({
|
||||
date,
|
||||
marketPrice,
|
||||
averagePrice: currentAveragePrice,
|
||||
marketPrice:
|
||||
historicalDataArray.length > 0
|
||||
? marketPrice
|
||||
: currentAveragePrice,
|
||||
quantity: currentQuantity
|
||||
});
|
||||
|
||||
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
|
||||
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 {
|
||||
@ -849,6 +878,7 @@ export class PortfolioService {
|
||||
tags,
|
||||
transactionCount,
|
||||
averagePrice: averagePrice.toNumber(),
|
||||
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
|
||||
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
fee.toNumber(),
|
||||
@ -911,6 +941,7 @@ export class PortfolioService {
|
||||
SymbolProfile,
|
||||
tags,
|
||||
averagePrice: 0,
|
||||
dataProviderInfo: undefined,
|
||||
dividendInBaseCurrency: 0,
|
||||
feeInBaseCurrency: 0,
|
||||
firstBuyDate: undefined,
|
||||
@ -976,11 +1007,13 @@ export class PortfolioService {
|
||||
};
|
||||
});
|
||||
|
||||
const symbols = positions.map((position) => position.symbol);
|
||||
|
||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||
this.dataProviderService.getQuotes(dataGatheringItem),
|
||||
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
|
||||
this.symbolProfileService.getSymbolProfiles(
|
||||
positions.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
)
|
||||
]);
|
||||
|
||||
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
||||
@ -1165,7 +1198,7 @@ export class PortfolioService {
|
||||
portfolioItemsNow[position.symbol] = position;
|
||||
}
|
||||
|
||||
const accounts = await this.getValueOfAccounts({
|
||||
const { accounts } = await this.getValueOfAccountsAndPlatforms({
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
@ -1176,10 +1209,6 @@ export class PortfolioService {
|
||||
rules: {
|
||||
accountClusterRisk: await this.rulesService.evaluate(
|
||||
[
|
||||
new AccountClusterRiskInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
accounts
|
||||
),
|
||||
new AccountClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
accounts
|
||||
@ -1193,18 +1222,10 @@ export class PortfolioService {
|
||||
),
|
||||
currencyClusterRisk: await this.rulesService.evaluate(
|
||||
[
|
||||
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
positions
|
||||
),
|
||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
positions
|
||||
),
|
||||
new CurrencyClusterRiskInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
positions
|
||||
),
|
||||
new CurrencyClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
positions
|
||||
@ -1281,12 +1302,11 @@ export class PortfolioService {
|
||||
}: {
|
||||
activities: OrderWithAccount[];
|
||||
date?: Date;
|
||||
|
||||
userCurrency: string;
|
||||
}) {
|
||||
return activities
|
||||
.filter((activity) => {
|
||||
// Filter out all activities before given date and type dividend
|
||||
// Filter out all activities before given date (drafts) and type dividend
|
||||
return (
|
||||
isBefore(date, new Date(activity.date)) &&
|
||||
activity.type === TypeOfOrder.DIVIDEND
|
||||
@ -1410,7 +1430,7 @@ export class PortfolioService {
|
||||
}) {
|
||||
return activities
|
||||
.filter((activity) => {
|
||||
// Filter out all activities before given date
|
||||
// Filter out all activities before given date (drafts)
|
||||
return isBefore(date, new Date(activity.date));
|
||||
})
|
||||
.map(({ fee, SymbolProfile }) => {
|
||||
@ -1457,19 +1477,37 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
private getItems(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date and type item
|
||||
private getItems(activities: OrderWithAccount[], date = new Date(0)) {
|
||||
return activities
|
||||
.filter((activity) => {
|
||||
// Filter out all activities before given date (drafts) and type item
|
||||
return (
|
||||
isBefore(date, new Date(order.date)) &&
|
||||
order.type === TypeOfOrder.ITEM
|
||||
isBefore(date, new Date(activity.date)) &&
|
||||
activity.type === TypeOfOrder.ITEM
|
||||
);
|
||||
})
|
||||
.map((order) => {
|
||||
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||
order.SymbolProfile.currency,
|
||||
new Big(quantity).mul(unitPrice).toNumber(),
|
||||
SymbolProfile.currency,
|
||||
this.request.user.Settings.settings.baseCurrency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
(previous, current) => new Big(previous).plus(current),
|
||||
new Big(0)
|
||||
);
|
||||
}
|
||||
|
||||
private getLiabilities(activities: OrderWithAccount[]) {
|
||||
return activities
|
||||
.filter(({ type }) => {
|
||||
return type === TypeOfOrder.LIABILITY;
|
||||
})
|
||||
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(quantity).mul(unitPrice).toNumber(),
|
||||
SymbolProfile.currency,
|
||||
this.request.user.Settings.settings.baseCurrency
|
||||
);
|
||||
})
|
||||
@ -1509,6 +1547,28 @@ export class PortfolioService {
|
||||
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({
|
||||
balanceInBaseCurrency,
|
||||
emergencyFundPositionsValueInBaseCurrency,
|
||||
@ -1550,11 +1610,15 @@ export class PortfolioService {
|
||||
userCurrency
|
||||
}).toNumber();
|
||||
const emergencyFund = new Big(
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
Math.max(
|
||||
emergencyFundPositionsValueInBaseCurrency,
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
)
|
||||
);
|
||||
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
||||
const firstOrderDate = activities[0]?.date;
|
||||
const items = this.getItems(activities).toNumber();
|
||||
const liabilities = this.getLiabilities(activities).toNumber();
|
||||
|
||||
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
|
||||
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
|
||||
@ -1587,6 +1651,7 @@ export class PortfolioService {
|
||||
.plus(performanceInformation.performance.currentValue)
|
||||
.plus(items)
|
||||
.plus(excludedAccountsAndActivities)
|
||||
.minus(liabilities)
|
||||
.toNumber();
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||
@ -1613,6 +1678,7 @@ export class PortfolioService {
|
||||
fees,
|
||||
firstOrderDate,
|
||||
items,
|
||||
liabilities,
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
@ -1694,7 +1760,7 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getValueOfAccounts({
|
||||
private async getValueOfAccountsAndPlatforms({
|
||||
filters = [],
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
@ -1718,6 +1784,7 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const accounts: PortfolioDetails['accounts'] = {};
|
||||
const platforms: PortfolioDetails['platforms'] = {};
|
||||
|
||||
let currentAccounts: (Account & {
|
||||
Order?: Order[];
|
||||
@ -1728,6 +1795,7 @@ export class PortfolioService {
|
||||
currentAccounts = await this.accountService.getAccounts(userId);
|
||||
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
|
||||
currentAccounts = await this.accountService.accounts({
|
||||
include: { Platform: true },
|
||||
where: { id: filters[0].id }
|
||||
});
|
||||
} else {
|
||||
@ -1738,6 +1806,7 @@ export class PortfolioService {
|
||||
);
|
||||
|
||||
currentAccounts = await this.accountService.accounts({
|
||||
include: { Platform: true },
|
||||
where: { id: { in: accountIds } }
|
||||
});
|
||||
}
|
||||
@ -1762,65 +1831,76 @@ export class PortfolioService {
|
||||
accounts[account.id] = {
|
||||
balance: account.balance,
|
||||
currency: account.currency,
|
||||
current: this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
userCurrency
|
||||
),
|
||||
name: account.name,
|
||||
original: this.exchangeRateDataService.toCurrency(
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
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) {
|
||||
let currentValueOfSymbolInBaseCurrency =
|
||||
order.quantity *
|
||||
(portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ??
|
||||
order.unitPrice ??
|
||||
0);
|
||||
let originalValueOfSymbolInBaseCurrency =
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
order.quantity * order.unitPrice,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
);
|
||||
|
||||
if (order.type === 'SELL') {
|
||||
currentValueOfSymbolInBaseCurrency *= -1;
|
||||
originalValueOfSymbolInBaseCurrency *= -1;
|
||||
}
|
||||
|
||||
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
|
||||
accounts[order.Account?.id || UNKNOWN_KEY].current +=
|
||||
if (accounts[order.Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
|
||||
accounts[order.Account?.id || UNKNOWN_KEY].valueInBaseCurrency +=
|
||||
currentValueOfSymbolInBaseCurrency;
|
||||
accounts[order.Account?.id || UNKNOWN_KEY].original +=
|
||||
originalValueOfSymbolInBaseCurrency;
|
||||
} else {
|
||||
accounts[order.Account?.id || UNKNOWN_KEY] = {
|
||||
balance: 0,
|
||||
currency: order.Account?.currency,
|
||||
current: currentValueOfSymbolInBaseCurrency,
|
||||
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;
|
||||
}
|
||||
|
||||
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
aImpersonationId,
|
||||
aUserId
|
||||
);
|
||||
|
||||
return impersonationUserId || aUserId;
|
||||
return { accounts, platforms };
|
||||
}
|
||||
|
||||
private getTotalByType(
|
||||
@ -1849,4 +1929,11 @@ export class PortfolioService {
|
||||
this.baseCurrency
|
||||
);
|
||||
}
|
||||
|
||||
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(aImpersonationId);
|
||||
|
||||
return impersonationUserId || aUserId;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
||||
import * as redisStore from 'cache-manager-redis-store';
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
||||
import { Cache } from 'cache-manager';
|
||||
|
||||
@ -13,6 +14,10 @@ export class RedisCacheService {
|
||||
return await this.cache.get(key);
|
||||
}
|
||||
|
||||
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||
return `quote-${dataSource}-${symbol}`;
|
||||
}
|
||||
|
||||
public async remove(key: string) {
|
||||
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 {
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
@ -117,7 +117,7 @@ export class SubscriptionController {
|
||||
return await this.subscriptionService.createCheckoutSession({
|
||||
couponId,
|
||||
priceId,
|
||||
userId: this.request.user.id
|
||||
user: this.request.user
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(error, 'SubscriptionController');
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
|
@ -1,11 +1,8 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import {
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
PROPERTY_STRIPE_CONFIG
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Subscription as SubscriptionInterface } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Subscription } from '@prisma/client';
|
||||
import { addMilliseconds, isBefore } from 'date-fns';
|
||||
@ -23,7 +20,7 @@ export class SubscriptionService {
|
||||
this.stripe = new Stripe(
|
||||
this.configurationService.get('STRIPE_SECRET_KEY'),
|
||||
{
|
||||
apiVersion: '2020-08-27'
|
||||
apiVersion: '2022-11-15'
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -31,17 +28,17 @@ export class SubscriptionService {
|
||||
public async createCheckoutSession({
|
||||
couponId,
|
||||
priceId,
|
||||
userId
|
||||
user
|
||||
}: {
|
||||
couponId?: string;
|
||||
priceId: string;
|
||||
userId: string;
|
||||
user: UserWithSettings;
|
||||
}) {
|
||||
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
||||
cancel_url: `${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/account`,
|
||||
client_reference_id: userId,
|
||||
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
|
||||
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE
|
||||
}/account`,
|
||||
client_reference_id: user.id,
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
@ -100,33 +97,20 @@ export class SubscriptionService {
|
||||
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({
|
||||
price: price - coupon,
|
||||
price: session.amount_total / 100,
|
||||
userId: session.client_reference_id
|
||||
});
|
||||
|
||||
await this.stripe.customers.update(session.customer as string, {
|
||||
description: session.client_reference_id
|
||||
});
|
||||
|
||||
return session.client_reference_id;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'SubscriptionService');
|
||||
}
|
||||
}
|
||||
|
||||
public getSubscription(aSubscriptions: Subscription[]) {
|
||||
public getSubscription(
|
||||
aSubscriptions: Subscription[]
|
||||
): UserWithSettings['subscription'] {
|
||||
if (aSubscriptions.length > 0) {
|
||||
const latestSubscription = aSubscriptions.reduce((a, b) => {
|
||||
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||
@ -134,12 +118,14 @@ export class SubscriptionService {
|
||||
|
||||
return {
|
||||
expiresAt: latestSubscription.expiresAt,
|
||||
offer: latestSubscription.price === 0 ? 'default' : 'renewal',
|
||||
type: isBefore(new Date(), latestSubscription.expiresAt)
|
||||
? SubscriptionType.Premium
|
||||
: SubscriptionType.Basic
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
offer: 'default',
|
||||
type: SubscriptionType.Basic
|
||||
};
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||
|
||||
export interface LookupItem {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
name: string;
|
||||
|
@ -1,15 +1,18 @@
|
||||
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 { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
@ -21,7 +24,10 @@ import { SymbolService } from './symbol.service';
|
||||
|
||||
@Controller('symbol')
|
||||
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
|
||||
@ -33,7 +39,10 @@ export class SymbolController {
|
||||
@Query() { query = '' }
|
||||
): Promise<{ items: LookupItem[] }> {
|
||||
try {
|
||||
return this.symbolService.lookup(query.toLowerCase());
|
||||
return this.symbolService.lookup({
|
||||
query: query.toLowerCase(),
|
||||
user: this.request.user
|
||||
});
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
@ -51,7 +60,7 @@ export class SymbolController {
|
||||
public async getSymbolData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string,
|
||||
@Query('includeHistoricalData') includeHistoricalData?: number
|
||||
@Query('includeHistoricalData') includeHistoricalData = 0
|
||||
): Promise<SymbolItem> {
|
||||
if (!DataSource[dataSource]) {
|
||||
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 { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { SymbolController } from './symbol.controller';
|
||||
|
@ -3,9 +3,10 @@ import {
|
||||
IDataGatheringItem,
|
||||
IDataProviderHistoricalResponse
|
||||
} 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 { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { format, subDays } from 'date-fns';
|
||||
|
||||
@ -79,15 +80,24 @@ export class SymbolService {
|
||||
};
|
||||
}
|
||||
|
||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
public async lookup({
|
||||
query,
|
||||
user
|
||||
}: {
|
||||
query: string;
|
||||
user: UserWithSettings;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
const results: { items: LookupItem[] } = { items: [] };
|
||||
|
||||
if (!aQuery) {
|
||||
if (!query) {
|
||||
return results;
|
||||
}
|
||||
|
||||
try {
|
||||
const { items } = await this.dataProviderService.search(aQuery);
|
||||
const { items } = await this.dataProviderService.search({
|
||||
query,
|
||||
user
|
||||
});
|
||||
results.items = items;
|
||||
return results;
|
||||
} catch (error) {
|
||||
|
@ -5,6 +5,7 @@ import type {
|
||||
} from '@ghostfolio/common/types';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsISO8601,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@ -12,6 +13,10 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateUserSettingDto {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
annualInterestRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
baseCurrency?: string;
|
||||
@ -48,6 +53,14 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
locale?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
projectedTotalAmount?: number;
|
||||
|
||||
@IsISO8601()
|
||||
@IsOptional()
|
||||
retirementDate?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
savingsRate?: number;
|
||||
|
@ -79,7 +79,7 @@ export class UserController {
|
||||
const hasAdmin = await this.userService.hasAdmin();
|
||||
|
||||
const { accessToken, id, role } = await this.userService.createUser({
|
||||
role: hasAdmin ? 'USER' : 'ADMIN'
|
||||
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
|
||||
});
|
||||
|
||||
return {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
@ -1,19 +1,17 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
|
||||
import {
|
||||
User as IUser,
|
||||
UserSettings,
|
||||
UserWithSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
getPermissions,
|
||||
hasRole,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma, Role, User } from '@prisma/client';
|
||||
import { sortBy } from 'lodash';
|
||||
@ -168,7 +166,7 @@ export class UserService {
|
||||
this.subscriptionService.getSubscription(Subscription);
|
||||
|
||||
if (
|
||||
Analytics?.activityCount % 25 === 0 &&
|
||||
Analytics?.activityCount % 20 === 0 &&
|
||||
user.subscription?.type === 'Basic'
|
||||
) {
|
||||
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||
@ -199,6 +197,10 @@ export class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!environment.production && role === 'ADMIN') {
|
||||
currentPermissions.push(permissions.impersonateAllUsers);
|
||||
}
|
||||
|
||||
user.Account = sortBy(user.Account, (account) => {
|
||||
return account.name;
|
||||
});
|
||||
@ -231,7 +233,11 @@ export class UserService {
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
public async createUser(data: Prisma.UserCreateInput): Promise<User> {
|
||||
public async createUser({
|
||||
data
|
||||
}: {
|
||||
data: Prisma.UserCreateInput;
|
||||
}): Promise<User> {
|
||||
if (!data?.provider) {
|
||||
data.provider = 'ANONYMOUS';
|
||||
}
|
||||
@ -256,6 +262,14 @@ export class UserService {
|
||||
}
|
||||
});
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
await this.prismaService.analytics.create({
|
||||
data: {
|
||||
User: { connect: { id: user.id } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (data.provider === 'ANONYMOUS') {
|
||||
const accessToken = this.createAccessToken(
|
||||
user.id,
|
||||
@ -290,21 +304,29 @@ export class UserService {
|
||||
}
|
||||
|
||||
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
|
||||
await this.prismaService.access.deleteMany({
|
||||
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
|
||||
});
|
||||
try {
|
||||
await this.prismaService.access.deleteMany({
|
||||
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
|
||||
});
|
||||
} catch {}
|
||||
|
||||
await this.prismaService.account.deleteMany({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
try {
|
||||
await this.prismaService.account.deleteMany({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
} catch {}
|
||||
|
||||
await this.prismaService.analytics.delete({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
try {
|
||||
await this.prismaService.analytics.delete({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
} catch {}
|
||||
|
||||
await this.prismaService.order.deleteMany({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
try {
|
||||
await this.prismaService.order.deleteMany({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await this.prismaService.settings.delete({
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,4 @@
|
||||
import Big from 'big.js';
|
||||
import { cloneDeep, isArray, isObject } from 'lodash';
|
||||
|
||||
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||
@ -15,9 +16,11 @@ export function hasNotDefinedValuesInObject(aObject: Object): boolean {
|
||||
export function nullifyValuesInObject<T>(aObject: T, keys: string[]): T {
|
||||
const object = cloneDeep(aObject);
|
||||
|
||||
keys.forEach((key) => {
|
||||
object[key] = null;
|
||||
});
|
||||
if (object) {
|
||||
keys.forEach((key) => {
|
||||
object[key] = null;
|
||||
});
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
@ -59,7 +62,10 @@ export function redactAttributes({
|
||||
return redactAttributes({ options, object: currentObject });
|
||||
}
|
||||
);
|
||||
} else if (isObject(redactedObject[property])) {
|
||||
} else if (
|
||||
isObject(redactedObject[property]) &&
|
||||
!(redactedObject[property] instanceof Big)
|
||||
) {
|
||||
// Recursively call the function on the nested object
|
||||
redactedObject[property] = redactAttributes({
|
||||
options,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
@ -22,7 +23,8 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
return next.handle().pipe(
|
||||
map((data: any) => {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const hasImpersonationId = !!request.headers?.['impersonation-id'];
|
||||
const hasImpersonationId =
|
||||
!!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
|
||||
|
||||
if (
|
||||
hasImpersonationId ||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { decodeDataSource } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
CallHandler,
|
||||
@ -5,10 +6,9 @@ import {
|
||||
Injectable,
|
||||
NestInterceptor
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { ConfigurationService } from '../services/configuration.service';
|
||||
|
||||
@Injectable()
|
||||
export class TransformDataSourceInRequestInterceptor<T>
|
||||
implements NestInterceptor<T, any>
|
||||
@ -24,12 +24,25 @@ export class TransformDataSourceInRequestInterceptor<T>
|
||||
const http = context.switchToHttp();
|
||||
const request = http.getRequest();
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
|
||||
if (request.body.dataSource) {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (request.params.dataSource) {
|
||||
if (request.params.dataSource && !DataSource[request.params.dataSource]) {
|
||||
request.params.dataSource = decodeDataSource(request.params.dataSource);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
CallHandler,
|
||||
@ -10,8 +11,6 @@ import { DataSource } from '@prisma/client';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { ConfigurationService } from '../services/configuration.service';
|
||||
|
||||
@Injectable()
|
||||
export class TransformDataSourceInResponseInterceptor<T>
|
||||
implements NestInterceptor<T, any>
|
||||
@ -26,9 +25,7 @@ export class TransformDataSourceInResponseInterceptor<T>
|
||||
): Observable<any> {
|
||||
return next.handle().pipe(
|
||||
map((data: any) => {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||
) {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
data = redactAttributes({
|
||||
options: [
|
||||
{
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import * as bodyParser from 'body-parser';
|
||||
import helmet from 'helmet';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { environment } from './environments/environment';
|
||||
@ -9,16 +12,12 @@ async function bootstrap() {
|
||||
const configApp = await NestFactory.create(AppModule);
|
||||
const configService = configApp.get<ConfigService>(ConfigService);
|
||||
|
||||
const NODE_ENV =
|
||||
configService.get<'development' | 'production'>('NODE_ENV') ??
|
||||
'development';
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger:
|
||||
NODE_ENV === 'production'
|
||||
? ['error', 'log', 'warn']
|
||||
: ['debug', 'error', 'log', 'verbose', 'warn']
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: environment.production
|
||||
? ['error', 'log', 'warn']
|
||||
: ['debug', 'error', 'log', 'verbose', 'warn']
|
||||
});
|
||||
|
||||
app.enableCors();
|
||||
app.enableVersioning({
|
||||
defaultVersion: '1',
|
||||
@ -33,12 +32,41 @@ async function bootstrap() {
|
||||
})
|
||||
);
|
||||
|
||||
// Support 10mb csv/json files for importing activities
|
||||
app.use(bodyParser.json({ limit: '10mb' }));
|
||||
|
||||
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
|
||||
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
|
||||
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY');
|
||||
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
||||
const PORT = configService.get<number>('PORT') || 3333;
|
||||
|
||||
await app.listen(PORT, HOST, () => {
|
||||
logLogo();
|
||||
Logger.log(`Listening at http://${HOST}:${PORT}`);
|
||||
Logger.log('');
|
||||
|
||||
if (BASE_CURRENCY) {
|
||||
Logger.warn(
|
||||
`The environment variable "BASE_CURRENCY" is deprecated and will be removed in Ghostfolio 2.0.`
|
||||
);
|
||||
Logger.warn(
|
||||
'Please use the currency converter in the activity dialog instead.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user