Compare commits

..

2 Commits

Author SHA1 Message Date
5799b9e71c Refactoring 2023-12-19 20:02:00 +01:00
188389d26c Change from max investment to average investment 2023-12-19 20:00:23 +01:00
808 changed files with 120174 additions and 91193 deletions

View File

@ -1,25 +0,0 @@
COMPOSE_PROJECT_NAME=ghostfolio-development
# CACHE
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
# POSTGRES
POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
# DEVELOPMENT
# Nx 18 enables using plugins to infer targets by default
# This is disabled for existing workspaces to maintain compatibility
# For more info, see: https://nx.dev/concepts/inferred-tasks
NX_ADD_PLUGINS=false
NX_NATIVE_COMMAND_RUNNER=false

View File

@ -1,4 +1,4 @@
COMPOSE_PROJECT_NAME=ghostfolio COMPOSE_PROJECT_NAME=ghostfolio-development
# CACHE # CACHE
REDIS_HOST=localhost REDIS_HOST=localhost
@ -10,7 +10,6 @@ POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user POSTGRES_USER=user
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD> POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING> ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING> JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

View File

@ -24,18 +24,12 @@
{ {
"files": ["*.ts", "*.tsx"], "files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nx/typescript"], "extends": ["plugin:@nx/typescript"],
"rules": { "rules": {}
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
}, },
{ {
"files": ["*.js", "*.jsx"], "files": ["*.js", "*.jsx"],
"extends": ["plugin:@nx/javascript"], "extends": ["plugin:@nx/javascript"],
"rules": { "rules": {}
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
}, },
{ {
"files": ["*.ts"], "files": ["*.ts"],

View File

@ -6,13 +6,7 @@ labels: ''
assignees: '' assignees: ''
--- ---
**Important Notice** 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).
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).
Incomplete or non-reproducible issues may be closed, but we are here to help! If you encounter difficulties reproducing the bug or need assistance, please reach out to our community channels mentioned above.
Thank you for your understanding and cooperation!
**Bug Description** **Bug Description**
@ -42,9 +36,8 @@ Thank you for your understanding and cooperation!
<!-- Please complete the following information --> <!-- Please complete the following information -->
- Ghostfolio Version X.Y.Z
- Cloud or Self-hosted - Cloud or Self-hosted
- Experimental Features enabled or disabled - Ghostfolio Version X.Y.Z
- Browser - Browser
- OS - OS

View File

@ -13,7 +13,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node_version: node_version:
- 20 - 18
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -24,16 +24,16 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node_version }} node-version: ${{ matrix.node_version }}
cache: 'npm' cache: 'yarn'
- name: Install dependencies - name: Install dependencies
run: npm ci run: yarn install --frozen-lockfile
- name: Check formatting - name: Check formatting
run: npm run format:check run: yarn format:check
- name: Execute tests - name: Execute tests
run: npm test run: yarn test
- name: Build application - name: Build application
run: npm run build:production run: yarn build:production

5
.gitignore vendored
View File

@ -5,8 +5,8 @@
/tmp /tmp
# dependencies # dependencies
/.yarn
/node_modules /node_modules
npm-debug.log
# IDEs and editors # IDEs and editors
/.idea /.idea
@ -28,14 +28,15 @@ npm-debug.log
.env .env
.env.prod .env.prod
.nx/cache .nx/cache
.nx/workspace-data
/.sass-cache /.sass-cache
/connect.lock /connect.lock
/coverage /coverage
/dist /dist
/libpeerconnection.log /libpeerconnection.log
npm-debug.log
testem.log testem.log
/typings /typings
yarn-error.log
# System Files # System Files
.DS_Store .DS_Store

2
.nvmrc
View File

@ -1 +1 @@
v20 v18

View File

@ -1,5 +1,3 @@
/.nx/cache /.nx/cache
/.nx/workspace-data
/apps/client/src/polyfills.ts
/dist /dist
/test/import /test/import

View File

@ -9,26 +9,7 @@
], ],
"attributeSort": "ASC", "attributeSort": "ASC",
"endOfLine": "auto", "endOfLine": "auto",
"importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"], "plugins": ["prettier-plugin-organize-attributes"],
"importOrderSeparation": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
},
{
"files": "*.ts",
"options": {
"importOrderParserPlugins": ["decorators-legacy", "typescript"]
}
}
],
"plugins": [
"prettier-plugin-organize-attributes",
"@trivago/prettier-plugin-sort-imports"
],
"printWidth": 80, "printWidth": 80,
"singleQuote": true, "singleQuote": true,
"tabWidth": 2, "tabWidth": 2,

1
.yarnrc Normal file
View File

@ -0,0 +1 @@
network-timeout 600000

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ Remove permission in `UserService` using `without()`
### Frontend ### Frontend
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
## Git ## Git
@ -30,26 +30,26 @@ Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
#### Upgrade #### Upgrade
1. Run `npx nx migrate latest` 1. Run `yarn nx migrate latest`
1. Make sure `package.json` changes make sense and then run `npm install` 1. Make sure `package.json` changes make sense and then run `yarn install`
1. Run `npx nx migrate --run-migrations` 1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
### Prisma ### Prisma
#### Access database via GUI #### Access database via GUI
Run `npm run database:gui` Run `yarn database:gui`
https://www.prisma.io/studio https://www.prisma.io/studio
#### Synchronize schema with database for prototyping #### Synchronize schema with database for prototyping
Run `npm run database:push` Run `yarn database:push`
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
#### Create schema migration #### Create schema migration
Run `npm run prisma migrate dev --name added_job_title` Run `yarn prisma migrate dev --name added_job_title`
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate

View File

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:20-slim AS builder FROM --platform=$BUILDPLATFORM node:18-slim as builder
# Build application and add additional files # Build application and add additional files
WORKDIR /ghostfolio WORKDIR /ghostfolio
@ -8,17 +8,18 @@ WORKDIR /ghostfolio
COPY ./CHANGELOG.md CHANGELOG.md COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE COPY ./LICENSE LICENSE
COPY ./package.json package.json COPY ./package.json package.json
COPY ./package-lock.json package-lock.json COPY ./yarn.lock yarn.lock
COPY ./.yarnrc .yarnrc
COPY ./prisma/schema.prisma prisma/schema.prisma COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apt update && apt install -y \ RUN apt update && apt install -y \
g++ \ git \
git \ g++ \
make \ make \
openssl \ openssl \
python3 \ python3 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN npm install RUN yarn install
# See https://github.com/nrwl/nx/issues/6586 for further details # See https://github.com/nrwl/nx/issues/6586 for further details
COPY ./decorate-angular-cli.js decorate-angular-cli.js COPY ./decorate-angular-cli.js decorate-angular-cli.js
@ -32,34 +33,29 @@ COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs COPY ./libs libs
COPY ./apps apps COPY ./apps apps
RUN npm run build:production RUN yarn build:production
# Prepare the dist image with additional node_modules # Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original # package.json was generated by the build process, however the original
# package-lock.json needs to be used to ensure the same versions # yarn.lock needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
RUN npm install RUN yarn
COPY prisma /ghostfolio/dist/apps/api/prisma COPY prisma /ghostfolio/dist/apps/api/prisma
# Overwrite the generated package.json with the original one to ensure having # Overwrite the generated package.json with the original one to ensure having
# all the scripts # all the scripts
COPY package.json /ghostfolio/dist/apps/api COPY package.json /ghostfolio/dist/apps/api
RUN npm run database:generate-typings RUN yarn database:generate-typings
# Image to run, copy everything needed from builder # Image to run, copy everything needed from builder
FROM node:20-slim FROM node:18-slim
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
RUN apt update && apt install -y \ RUN apt update && apt install -y \
curl \ openssl \
openssl \ && rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333} EXPOSE ${PORT:-3333}
CMD [ "/ghostfolio/entrypoint.sh" ] CMD [ "yarn", "start:production" ]

106
README.md
View File

@ -7,12 +7,14 @@
**Open Source Wealth Management Software** **Open Source Wealth Management Software**
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) | [**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_) [**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio) [![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio)
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing) [![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing)
[![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) [![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
</div> </div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation. **Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
@ -47,7 +49,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions - ✅ Create, update and delete transactions
- ✅ Multi account management - ✅ Multi account management
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max` - ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions - ✅ Import and export transactions
@ -85,23 +87,19 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Type | Default Value | Description | | Name | Default Value | Description |
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | | `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | | `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | | `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | | `PORT` | `3333` | The port where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | | `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | | `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | | `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | | `REDIS_HOST` | | The host where _Redis_ is running |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | | `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | | `REDIS_PORT` | | The port where _Redis_ is running |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose ### Run with Docker Compose
@ -117,7 +115,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio): Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
```bash ```bash
docker compose --env-file ./.env -f docker/docker-compose.yml up -d docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
``` ```
#### b. Build and run environment #### b. Build and run environment
@ -125,8 +123,8 @@ docker compose --env-file ./.env -f docker/docker-compose.yml up -d
Run the following commands to build and start the Docker images: Run the following commands to build and start the Docker images:
```bash ```bash
docker compose --env-file ./.env -f docker/docker-compose.build.yml build docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
``` ```
#### Setup #### Setup
@ -137,61 +135,61 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
#### Upgrade Version #### Upgrade Version
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml` 1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
1. Run the following command to start the new Docker image: `docker compose --env-file ./.env -f docker/docker-compose.yml up -d` 1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
At each start, the container will automatically apply the database schema migrations if needed. At each start, the container will automatically apply the database schema migrations if needed.
### Home Server Systems (Community) ### Home Server Systems (Community)
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio). Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
## Development ## Development
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 20+) - [Node.js](https://nodejs.org/en/download) (version 18+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- Create a local copy of this Git repository (clone) - Create a local copy of this Git repository (clone)
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`) - Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
### Setup ### Setup
1. Run `npm install` 1. Run `yarn install`
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) 1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `npm run database:setup` to initialize the database schema 1. Run `yarn database:setup` to initialize the database schema
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
1. Start the server and the client (see [_Development_](#Development)) 1. Start the server and the client (see [_Development_](#Development))
1. Open https://localhost:4200/en in your browser 1. Open http://localhost:4200/en in your browser
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`) 1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
### Start Server ### Start Server
#### Debug #### Debug
Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com) Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
#### Serve #### Serve
Run `npm run start:server` Run `yarn start:server`
### Start Client ### Start Client
Run `npm run start:client` and open https://localhost:4200/en in your browser Run `yarn start:client` and open http://localhost:4200/en in your browser
### Start _Storybook_ ### Start _Storybook_
Run `npm run start:storybook` Run `yarn start:storybook`
### Migrate Database ### Migrate Database
With the following command you can keep your database schema in sync: With the following command you can keep your database schema in sync:
```bash ```bash
npm run database:push yarn database:push
``` ```
## Testing ## Testing
Run `npm test` Run `yarn test`
## Public API ## Public API
@ -203,7 +201,7 @@ Set the header for each request as follows:
"Authorization": "Bearer eyJh..." "Authorization": "Bearer eyJh..."
``` ```
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`) 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>`. 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>`.
@ -232,18 +230,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
} }
``` ```
| Field | Type | Description | | Field | Type | Description |
| ------------ | ------------------- | ----------------------------------------------------------------------------- | | ---------- | ------------------- | ----------------------------------------------------------------------------- |
| `accountId` | `string` (optional) | Id of the account | | accountId | string (`optional`) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity | | comment | string (`optional`) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. | | currency | string | `CHF` \| `EUR` \| `USD` etc. |
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` | | dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
| `date` | `string` | Date in the format `ISO-8601` | | date | string | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity | | fee | number | Fee of the activity |
| `quantity` | `number` | Quantity of the activity | | quantity | number | Quantity of the activity |
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) | | symbol | string | Symbol of the activity (suitable for `dataSource`) |
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` | | type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| `unitPrice` | `number` | Price per unit of the activity | | unitPrice | number | Price per unit of the activity |
#### Response #### Response
@ -274,16 +272,12 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. 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 post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio). If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
## Analytics
![Alt](https://repobeats.axiom.co/api/embed/281a80b2d0c4af1162866c24c803f1f18e5ed60e.svg 'Repobeats analytics image')
## License ## License
© 2021 - 2024 [Ghostfolio](https://ghostfol.io) © 2021 - 2023 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html). Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).

View File

@ -13,6 +13,7 @@ export default {
}, },
moduleFileExtensions: ['ts', 'js', 'html'], moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api', coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000,
testEnvironment: 'node', testEnvironment: 'node',
preset: '../../jest.preset.js' preset: '../../jest.preset.js'
}; };

View File

@ -7,15 +7,14 @@
"generators": {}, "generators": {},
"targets": { "targets": {
"build": { "build": {
"executor": "@nx/webpack:webpack", "executor": "@nrwl/webpack:webpack",
"options": { "options": {
"compiler": "tsc",
"deleteOutputPath": false,
"main": "apps/api/src/main.ts",
"outputPath": "dist/apps/api", "outputPath": "dist/apps/api",
"sourceMap": true, "main": "apps/api/src/main.ts",
"target": "node",
"tsConfig": "apps/api/tsconfig.app.json", "tsConfig": "apps/api/tsconfig.app.json",
"assets": ["apps/api/src/assets"],
"target": "node",
"compiler": "tsc",
"webpackConfig": "apps/api/webpack.config.js" "webpackConfig": "apps/api/webpack.config.js"
}, },
"configurations": { "configurations": {
@ -34,26 +33,6 @@
}, },
"outputs": ["{options.outputPath}"] "outputs": ["{options.outputPath}"]
}, },
"copy-assets": {
"executor": "nx:run-commands",
"options": {
"commands": [
{
"command": "shx rm -rf dist/apps/api"
},
{
"command": "shx mkdir -p dist/apps/api/assets/locales"
},
{
"command": "shx cp -r apps/api/src/assets/* dist/apps/api/assets"
},
{
"command": "shx cp -r apps/client/src/locales/* dist/apps/api/assets/locales"
}
],
"parallel": false
}
},
"serve": { "serve": {
"executor": "@nx/js:node", "executor": "@nx/js:node",
"options": { "options": {
@ -61,7 +40,7 @@
} }
}, },
"lint": { "lint": {
"executor": "@nx/eslint:lint", "executor": "@nrwl/linter:eslint",
"options": { "options": {
"lintFilePatterns": ["apps/api/**/*.ts"] "lintFilePatterns": ["apps/api/**/*.ts"]
} }

View File

@ -1,10 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -28,12 +24,11 @@ import { CreateAccessDto } from './create-access.dto';
export class AccessController { export class AccessController {
public constructor( public constructor(
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async getAllAccesses(): Promise<Access[]> { public async getAllAccesses(): Promise<Access[]> {
const accessesWithGranteeUser = await this.accessService.accesses({ const accessesWithGranteeUser = await this.accessService.accesses({
include: { include: {
@ -43,38 +38,32 @@ export class AccessController {
where: { userId: this.request.user.id } where: { userId: this.request.user.id }
}); });
return accessesWithGranteeUser.map( return accessesWithGranteeUser.map((access) => {
({ alias, GranteeUser, id, permissions }) => { if (access.GranteeUser) {
if (GranteeUser) {
return {
alias,
id,
permissions,
grantee: GranteeUser?.id,
type: 'PRIVATE'
};
}
return { return {
alias, alias: access.alias,
id, grantee: access.GranteeUser?.id,
permissions, id: access.id,
grantee: 'Public', type: 'RESTRICTED_VIEW'
type: 'PUBLIC'
}; };
} }
);
return {
alias: access.alias,
grantee: 'Public',
id: access.id,
type: 'PUBLIC'
};
});
} }
@HasPermission(permissions.createAccess)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async createAccess( public async createAccess(
@Body() data: CreateAccessDto @Body() data: CreateAccessDto
): Promise<AccessModel> { ): Promise<AccessModel> {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && !hasPermission(this.request.user.permissions, permissions.createAccess)
this.request.user.subscription.type === 'Basic'
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -82,30 +71,25 @@ export class AccessController {
); );
} }
try { return this.accessService.createAccess({
return this.accessService.createAccess({ alias: data.alias || undefined,
alias: data.alias || undefined, GranteeUser: data.granteeUserId
GranteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } }
? { connect: { id: data.granteeUserId } } : undefined,
: undefined, User: { connect: { id: this.request.user.id } }
permissions: data.permissions, });
User: { connect: { id: this.request.user.id } }
});
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
} }
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteAccess) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> { public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
const access = await this.accessService.access({ id }); const access = await this.accessService.access({ id });
if (!access || access.userId !== this.request.user.id) { if (
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
!access ||
access.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN

View File

@ -1,6 +1,4 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AccessController } from './access.controller'; import { AccessController } from './access.controller';
@ -9,7 +7,7 @@ import { AccessService } from './access.service';
@Module({ @Module({
controllers: [AccessController], controllers: [AccessController],
exports: [AccessService], exports: [AccessService],
imports: [ConfigurationModule, PrismaModule], imports: [PrismaModule],
providers: [AccessService] providers: [AccessService]
}) })
export class AccessModule {} export class AccessModule {}

View File

@ -1,6 +1,5 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccessWithGranteeUser } from '@ghostfolio/common/types'; import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Access, Prisma } from '@prisma/client'; import { Access, Prisma } from '@prisma/client';

View File

@ -1,5 +1,4 @@
import { AccessPermission } from '@prisma/client'; import { IsOptional, IsString } from 'class-validator';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateAccessDto { export class CreateAccessDto {
@IsOptional() @IsOptional()
@ -7,10 +6,10 @@ export class CreateAccessDto {
alias?: string; alias?: string;
@IsOptional() @IsOptional()
@IsUUID() @IsString()
granteeUserId?: string; granteeUserId?: string;
@IsEnum(AccessPermission, { each: true })
@IsOptional() @IsOptional()
permissions?: AccessPermission[]; @IsString()
type?: 'PUBLIC';
} }

View File

@ -1,13 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Body,
Post,
Delete, Delete,
HttpException, HttpException,
Inject, Inject,
@ -20,56 +14,31 @@ import { AccountBalance } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service'; import { AccountBalanceService } from './account-balance.service';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Controller('account-balance') @Controller('account-balance')
export class AccountBalanceController { export class AccountBalanceController {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@HasPermission(permissions.createAccountBalance)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createAccountBalance(
@Body() data: CreateAccountBalanceDto
): Promise<AccountBalance> {
const account = await this.accountService.account({
id_userId: {
id: data.accountId,
userId: this.request.user.id
}
});
if (!account) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accountBalanceService.createOrUpdateAccountBalance({
accountId: account.id,
balance: data.balance,
date: data.date,
userId: account.userId
});
}
@HasPermission(permissions.deleteAccountBalance)
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async deleteAccountBalance( public async deleteAccountBalance(
@Param('id') id: string @Param('id') id: string
): Promise<AccountBalance> { ): Promise<AccountBalance> {
const accountBalance = await this.accountBalanceService.accountBalance({ const accountBalance = await this.accountBalanceService.accountBalance({
id, id
userId: this.request.user.id
}); });
if (!accountBalance) { if (
!hasPermission(
this.request.user.permissions,
permissions.deleteAccountBalance
) ||
!accountBalance ||
accountBalance.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -77,8 +46,7 @@ export class AccountBalanceController {
} }
return this.accountBalanceService.deleteAccountBalance({ return this.accountBalanceService.deleteAccountBalance({
id: accountBalance.id, id
userId: accountBalance.userId
}); });
} }
} }

View File

@ -1,7 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AccountBalanceController } from './account-balance.controller'; import { AccountBalanceController } from './account-balance.controller';
@ -11,6 +9,6 @@ import { AccountBalanceService } from './account-balance.service';
controllers: [AccountBalanceController], controllers: [AccountBalanceController],
exports: [AccountBalanceService], exports: [AccountBalanceService],
imports: [ExchangeRateDataModule, PrismaModule], imports: [ExchangeRateDataModule, PrismaModule],
providers: [AccountBalanceService, AccountService] providers: [AccountBalanceService]
}) })
export class AccountBalanceModule {} export class AccountBalanceModule {}

View File

@ -1,21 +1,13 @@
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces'; import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client'; import { AccountBalance, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Injectable() @Injectable()
export class AccountBalanceService { export class AccountBalanceService {
public constructor( public constructor(
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
@ -31,63 +23,20 @@ export class AccountBalanceService {
}); });
} }
public async createOrUpdateAccountBalance({ public async createAccountBalance(
accountId, data: Prisma.AccountBalanceCreateInput
balance, ): Promise<AccountBalance> {
date, return this.prismaService.accountBalance.create({
userId data
}: CreateAccountBalanceDto & {
userId: string;
}): Promise<AccountBalance> {
const accountBalance = await this.prismaService.accountBalance.upsert({
create: {
Account: {
connect: {
id_userId: {
userId,
id: accountId
}
}
},
date: resetHours(parseISO(date)),
value: balance
},
update: {
value: balance
},
where: {
accountId_date: {
accountId,
date: resetHours(parseISO(date))
}
}
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
return accountBalance;
} }
public async deleteAccountBalance( public async deleteAccountBalance(
where: Prisma.AccountBalanceWhereUniqueInput where: Prisma.AccountBalanceWhereUniqueInput
): Promise<AccountBalance> { ): Promise<AccountBalance> {
const accountBalance = await this.prismaService.accountBalance.delete({ return this.prismaService.accountBalance.delete({
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: <string>where.userId
})
);
return accountBalance;
} }
public async getAccountBalances({ public async getAccountBalances({

View File

@ -1,12 +0,0 @@
import { IsISO8601, IsNumber, IsUUID } from 'class-validator';
export class CreateAccountBalanceDto {
@IsUUID()
accountId: string;
@IsNumber()
balance: number;
@IsISO8601()
date: string;
}

View File

@ -1,20 +1,17 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {
AccountBalancesResponse, AccountBalancesResponse,
Accounts Accounts
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { import type {
AccountWithValue, AccountWithValue,
RequestWithUser RequestWithUser
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -50,9 +47,17 @@ export class AccountController {
) {} ) {}
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteAccount) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> { public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
if (
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const account = await this.accountService.accountWithOrders( const account = await this.accountService.accountWithOrders(
{ {
id_userId: { id_userId: {
@ -63,7 +68,7 @@ export class AccountController {
{ Order: true } { Order: true }
); );
if (!account || account?.Order.length > 0) { if (account?.isDefault || account?.Order.length > 0) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -82,7 +87,7 @@ export class AccountController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAllAccounts( public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
@ -97,7 +102,7 @@ export class AccountController {
} }
@Get(':id') @Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById( public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@ -117,7 +122,7 @@ export class AccountController {
} }
@Get(':id/balances') @Get(':id/balances')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById( public async getAccountBalancesById(
@Param('id') id: string @Param('id') id: string
@ -128,12 +133,20 @@ export class AccountController {
}); });
} }
@HasPermission(permissions.createAccount)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async createAccount( public async createAccount(
@Body() data: CreateAccountDto @Body() data: CreateAccountDto
): Promise<AccountModel> { ): Promise<AccountModel> {
if (
!hasPermission(this.request.user.permissions, permissions.createAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (data.platformId) { if (data.platformId) {
const platformId = data.platformId; const platformId = data.platformId;
delete data.platformId; delete data.platformId;
@ -159,12 +172,20 @@ export class AccountController {
} }
} }
@HasPermission(permissions.updateAccount)
@Post('transfer-balance') @Post('transfer-balance')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async transferAccountBalance( public async transferAccountBalance(
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto @Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
) { ) {
if (
!hasPermission(this.request.user.permissions, permissions.updateAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const accountsOfUser = await this.accountService.getAccounts( const accountsOfUser = await this.accountService.getAccounts(
this.request.user.id this.request.user.id
); );
@ -213,10 +234,18 @@ export class AccountController {
}); });
} }
@HasPermission(permissions.updateAccount)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) { public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
if (
!hasPermission(this.request.user.permissions, permissions.updateAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalAccount = await this.accountService.account({ const originalAccount = await this.accountService.account({
id_userId: { id_userId: {
id, id,

View File

@ -1,11 +1,12 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.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/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/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AccountController } from './account.controller'; import { AccountController } from './account.controller';
@ -17,11 +18,13 @@ import { AccountService } from './account.service';
imports: [ imports: [
AccountBalanceModule, AccountBalanceModule,
ConfigurationModule, ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,
RedactValuesInResponseModule RedisCacheModule,
UserModule
], ],
providers: [AccountService] providers: [AccountService]
}) })

View File

@ -1,15 +1,10 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Account, Order, Platform, Prisma } from '@prisma/client'; import { Account, Order, Platform, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import Big from 'big.js';
import { format } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@ -18,7 +13,6 @@ import { CashDetails } from './interfaces/cash-details.interface';
export class AccountService { export class AccountService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
@ -26,8 +20,10 @@ export class AccountService {
public async account({ public async account({
id_userId id_userId
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> { }: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
const { id, userId } = id_userId;
const [account] = await this.accounts({ const [account] = await this.accounts({
where: id_userId where: { id, userId }
}); });
return account; return account;
@ -90,20 +86,17 @@ export class AccountService {
data data
}); });
await this.accountBalanceService.createOrUpdateAccountBalance({ await this.prismaService.accountBalance.create({
accountId: account.id, data: {
balance: data.balance, Account: {
date: format(new Date(), DATE_FORMAT), connect: {
userId: aUserId id_userId: { id: account.id, userId: aUserId }
}
},
value: data.balance
}
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account; return account;
} }
@ -111,18 +104,9 @@ export class AccountService {
where: Prisma.AccountWhereUniqueInput, where: Prisma.AccountWhereUniqueInput,
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
const account = await this.prismaService.account.delete({ return this.prismaService.account.delete({
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
} }
public async getAccounts(aUserId: string): Promise<Account[]> { public async getAccounts(aUserId: string): Promise<Account[]> {
@ -174,8 +158,8 @@ export class AccountService {
ACCOUNT: filtersByAccount, ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass, ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag TAG: filtersByTag
} = groupBy(filters, ({ type }) => { } = groupBy(filters, (filter) => {
return type; return filter.type;
}); });
if (filtersByAccount?.length > 0) { if (filtersByAccount?.length > 0) {
@ -213,26 +197,21 @@ export class AccountService {
): Promise<Account> { ): Promise<Account> {
const { data, where } = params; const { data, where } = params;
await this.accountBalanceService.createOrUpdateAccountBalance({ await this.prismaService.accountBalance.create({
accountId: <string>data.id, data: {
balance: <number>data.balance, Account: {
date: format(new Date(), DATE_FORMAT), connect: {
userId: aUserId id_userId: where.id_userId
}
},
value: <number>data.balance
}
}); });
const account = await this.prismaService.account.update({ return this.prismaService.account.update({
data, data,
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
} }
public async updateAccountBalance({ public async updateAccountBalance({
@ -264,11 +243,17 @@ export class AccountService {
); );
if (amountInCurrencyOfAccount) { if (amountInCurrencyOfAccount) {
await this.accountBalanceService.createOrUpdateAccountBalance({ await this.accountBalanceService.createAccountBalance({
accountId, date,
userId, Account: {
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(), connect: {
date: date.toISOString() id_userId: {
userId,
id: accountId
}
}
},
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
}); });
} }
} }

View File

@ -1,5 +1,3 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
@ -21,7 +19,7 @@ export class CreateAccountDto {
) )
comment?: string; comment?: string;
@IsCurrencyCode() @IsString()
currency: string; currency: string;
@IsOptional() @IsOptional()

View File

@ -1,5 +1,3 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
@ -21,7 +19,7 @@ export class UpdateAccountDto {
) )
comment?: string; comment?: string;
@IsCurrencyCode() @IsString()
currency: string; currency: string;
@IsString() @IsString()

View File

@ -1,30 +1,27 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import {
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
EnhancedSymbolProfile EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { import type {
MarketDataPreset, MarketDataPreset,
RequestWithUser RequestWithUser
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -32,7 +29,6 @@ import {
Get, Get,
HttpException, HttpException,
Inject, Inject,
Logger,
Param, Param,
Patch, Patch,
Post, Post,
@ -58,34 +54,65 @@ export class AdminController {
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly manualService: ManualService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Get() @Get()
@HasPermission(permissions.accessAdminControl) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAdminData(): Promise<AdminData> { public async getAdminData(): Promise<AdminData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.get(); return this.adminService.get();
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather') @Post('gather')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async gather7Days(): Promise<void> { public async gather7Days(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gather7Days(); this.dataGatheringService.gather7Days();
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather/max') @Post('gather/max')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
const assetProfileIdentifiers = if (
await this.dataGatheringService.getAllAssetProfileIdentifiers(); !hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => { uniqueAssets.map(({ dataSource, symbol }) => {
return { return {
data: { data: {
dataSource, dataSource,
@ -94,8 +121,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }), jobId: getAssetProfileIdentifier({ dataSource, symbol })
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
} }
}; };
}) })
@ -104,15 +130,25 @@ export class AdminController {
this.dataGatheringService.gatherMax(); this.dataGatheringService.gatherMax();
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather/profile-data') @Post('gather/profile-data')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async gatherProfileData(): Promise<void> { public async gatherProfileData(): Promise<void> {
const assetProfileIdentifiers = if (
await this.dataGatheringService.getAllAssetProfileIdentifiers(); !hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => { uniqueAssets.map(({ dataSource, symbol }) => {
return { return {
data: { data: {
dataSource, dataSource,
@ -121,21 +157,31 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }), jobId: getAssetProfileIdentifier({ dataSource, symbol })
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
} }
}; };
}) })
); );
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather/profile-data/:dataSource/:symbol') @Post('gather/profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async gatherProfileDataForSymbol( public async gatherProfileDataForSymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.dataGatheringService.addJobToQueue({ await this.dataGatheringService.addJobToQueue({
data: { data: {
dataSource, dataSource,
@ -144,32 +190,53 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }), jobId: getAssetProfileIdentifier({ dataSource, symbol })
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
} }
}); });
} }
@Post('gather/:dataSource/:symbol') @Post('gather/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.accessAdminControl)
public async gatherSymbol( public async gatherSymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherSymbol({ dataSource, symbol }); this.dataGatheringService.gatherSymbol({ dataSource, symbol });
return; return;
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather/:dataSource/:symbol/:dateString') @Post('gather/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async gatherSymbolForDate( public async gatherSymbolForDate(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<MarketData> { ): Promise<MarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = parseISO(dateString); const date = parseISO(dateString);
if (!isDate(date)) { if (!isDate(date)) {
@ -187,8 +254,7 @@ export class AdminController {
} }
@Get('market-data') @Get('market-data')
@HasPermission(permissions.accessAdminControl) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketData( public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string, @Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset, @Query('presetId') presetId?: MarketDataPreset,
@ -198,6 +264,18 @@ export class AdminController {
@Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take') take?: number @Query('take') take?: number
): Promise<AdminMarketData> { ): Promise<AdminMarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses, filterByAssetSubClasses,
filterBySearchQuery filterBySearchQuery
@ -214,53 +292,51 @@ export class AdminController {
} }
@Get('market-data/:dataSource/:symbol') @Get('market-data/:dataSource/:symbol')
@HasPermission(permissions.accessAdminControl) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataBySymbol( public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> { ): Promise<AdminMarketDataDetails> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketDataBySymbol({ dataSource, symbol }); return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
} }
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol/test')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async testMarketData(
@Body() data: { scraperConfiguration: string },
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<{ price: number }> {
try {
const scraperConfiguration = JSON.parse(data.scraperConfiguration);
const price = await this.manualService.test(scraperConfiguration);
if (price) {
return { price };
}
throw new Error('Could not parse the current market price');
} catch (error) {
Logger.error(error);
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
}
}
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol') @Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async updateMarketData( public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto, @Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
) { ) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map( const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({ ({ date, marketPrice }) => ({
dataSource, dataSource,
marketPrice, marketPrice,
symbol, symbol,
date: parseISO(date), date: resetHours(parseISO(date)),
state: 'CLOSE' state: 'CLOSE'
}) })
); );
@ -273,15 +349,26 @@ export class AdminController {
/** /**
* @deprecated * @deprecated
*/ */
@HasPermission(permissions.accessAdminControl)
@Put('market-data/:dataSource/:symbol/:dateString') @Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async update( public async update(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Body() data: UpdateMarketDataDto @Body() data: UpdateMarketDataDto
) { ) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = parseISO(dateString); const date = parseISO(dateString);
return this.marketDataService.updateMarketData({ return this.marketDataService.updateMarketData({
@ -296,14 +383,24 @@ export class AdminController {
}); });
} }
@HasPermission(permissions.accessAdminControl)
@Post('profile-data/:dataSource/:symbol') @Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async addProfileData( public async addProfileData(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<SymbolProfile | never> { ): Promise<SymbolProfile | never> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.addAssetProfile({ return this.adminService.addAssetProfile({
dataSource, dataSource,
symbol, symbol,
@ -312,23 +409,45 @@ export class AdminController {
} }
@Delete('profile-data/:dataSource/:symbol') @Delete('profile-data/:dataSource/:symbol')
@HasPermission(permissions.accessAdminControl) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteProfileData( public async deleteProfileData(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.deleteProfileData({ dataSource, symbol }); return this.adminService.deleteProfileData({ dataSource, symbol });
} }
@HasPermission(permissions.accessAdminControl)
@Patch('profile-data/:dataSource/:symbol') @Patch('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async patchAssetProfileData( public async patchAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDto, @Body() assetProfileData: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> { ): Promise<EnhancedSymbolProfile> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.patchAssetProfileData({ return this.adminService.patchAssetProfileData({
...assetProfileData, ...assetProfileData,
dataSource, dataSource,
@ -336,13 +455,24 @@ export class AdminController {
}); });
} }
@HasPermission(permissions.accessAdminControl)
@Put('settings/:key') @Put('settings/:key')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async updateProperty( public async updateProperty(
@Param('key') key: string, @Param('key') key: string,
@Body() data: PropertyDto @Body() data: PropertyDto
) { ) {
return this.adminService.putSetting(key, data.value); if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return await this.adminService.putSetting(key, data.value);
} }
} }

View File

@ -1,7 +1,4 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -11,7 +8,6 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller'; import { AdminController } from './admin.controller';
@ -21,19 +17,16 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
ApiModule, ApiModule,
BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
QueueModule, QueueModule,
SubscriptionModule, SubscriptionModule,
SymbolProfileModule, SymbolProfileModule
TransformDataSourceInRequestModule
], ],
controllers: [AdminController], controllers: [AdminController],
providers: [AdminService], providers: [AdminService],

View File

@ -1,5 +1,3 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -15,25 +13,20 @@ import {
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { isCurrency, getCurrencyFromSymbol } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem, AdminMarketDataItem,
EnhancedSymbolProfile,
Filter, Filter,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { import {
AssetClass,
AssetSubClass, AssetSubClass,
DataSource, DataSource,
Prisma, Prisma,
PrismaClient,
Property, Property,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
@ -43,12 +36,10 @@ import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
@ -79,7 +70,7 @@ export class AdminService {
); );
} }
return this.symbolProfileService.add( return await this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
); );
} catch (error) { } catch (error) {
@ -154,16 +145,7 @@ export class AdminService {
[{ symbol: 'asc' }]; [{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {}; const where: Prisma.SymbolProfileWhereInput = {};
if (presetId === 'BENCHMARKS') { if (presetId === 'CURRENCIES') {
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
where.id = {
in: benchmarkAssetProfiles.map(({ id }) => {
return id;
})
};
} else if (presetId === 'CURRENCIES') {
return this.getMarketDataForCurrencies(); return this.getMarketDataForCurrencies();
} else if ( } else if (
presetId === 'ETF_WITHOUT_COUNTRIES' || presetId === 'ETF_WITHOUT_COUNTRIES' ||
@ -194,7 +176,6 @@ export class AdminService {
if (searchQuery) { if (searchQuery) {
where.OR = [ where.OR = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } }, { isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } }, { name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } } { symbol: { mode: 'insensitive', startsWith: searchQuery } }
@ -213,129 +194,101 @@ export class AdminService {
} }
} }
const extendedPrismaClient = this.getExtendedPrismaClient(); let [assetProfiles, count] = await Promise.all([
this.prismaService.symbolProfile.findMany({
try { orderBy,
let [assetProfiles, count] = await Promise.all([ skip,
extendedPrismaClient.symbolProfile.findMany({ take,
orderBy, where,
skip, select: {
take, _count: {
where, select: { Order: true }
select: { },
_count: { assetClass: true,
select: { Order: true } assetSubClass: true,
}, comment: true,
assetClass: true, countries: true,
assetSubClass: true, currency: true,
comment: true, dataSource: true,
countries: true, name: true,
currency: true, Order: {
dataSource: true, orderBy: [{ date: 'asc' }],
id: true, select: { date: true },
isUsedByUsersWithSubscription: true, take: 1
name: true, },
Order: { scraperConfiguration: true,
orderBy: [{ date: 'asc' }], sectors: true,
select: { date: true }, symbol: true
take: 1
},
scraperConfiguration: true,
sectors: true,
symbol: true
}
}),
this.prismaService.symbolProfile.count({ where })
]);
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(
async ({
_count,
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
id,
isUsedByUsersWithSubscription,
name,
Order,
sectors,
symbol
}) => {
const countriesCount = countries
? Object.keys(countries).length
: 0;
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
currency,
countriesCount,
dataSource,
id,
name,
symbol,
marketDataItemCount,
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
};
}
)
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
} }
}),
this.prismaService.symbolProfile.count({ where })
]);
count = marketData.length; let marketData = assetProfiles.map(
({
_count,
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
name,
Order,
sectors,
symbol
}) => {
const countriesCount = countries ? Object.keys(countries).length : 0;
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
currency,
countriesCount,
dataSource,
name,
symbol,
marketDataItemCount,
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date
};
}
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
} }
return { count = marketData.length;
count,
marketData
};
} finally {
await extendedPrismaClient.$disconnect();
Logger.debug('Disconnect extended prisma client', 'AdminService');
} }
return {
count,
marketData
};
} }
public async getMarketDataBySymbol({ public async getMarketDataBySymbol({
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Promise<AdminMarketDataDetails> { }: UniqueAsset): Promise<AdminMarketDataDetails> {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency));
}
const [[assetProfile], marketData] = await Promise.all([ const [[assetProfile], marketData] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([ this.symbolProfileService.getSymbolProfiles([
{ {
@ -354,20 +307,11 @@ export class AdminService {
}) })
]); ]);
if (assetProfile) {
assetProfile.dataProviderInfo = this.dataProviderService
.getDataProvider(assetProfile.dataSource)
.getDataProviderInfo();
}
return { return {
marketData, marketData,
assetProfile: assetProfile ?? { assetProfile: assetProfile ?? {
activitiesCount, symbol,
currency, currency: '-'
dataSource,
dateOfFirstActivity,
symbol
} }
}; };
} }
@ -376,48 +320,22 @@ export class AdminService {
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
countries,
currency,
dataSource, dataSource,
holdings,
name, name,
scraperConfiguration, scraperConfiguration,
sectors,
symbol, symbol,
symbolMapping, symbolMapping
url
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
const symbolProfileOverrides = { await this.symbolProfileService.updateSymbolProfile({
assetClass: assetClass as AssetClass, assetClass,
assetSubClass: assetSubClass as AssetSubClass, assetSubClass,
name: name as string, comment,
url: url as string dataSource,
}; name,
scraperConfiguration,
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset = symbol,
{ symbolMapping
comment, });
countries,
currency,
dataSource,
holdings,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
}
})
};
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([ const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ {
@ -447,97 +365,35 @@ export class AdminService {
return response; return response;
} }
private getExtendedPrismaClient() {
Logger.debug('Connect extended prisma client', 'AdminService');
const symbolProfileExtension = Prisma.defineExtension((client) => {
return client.$extends({
result: {
symbolProfile: {
isUsedByUsersWithSubscription: {
compute: async ({ id }) => {
const { _count } =
await this.prismaService.symbolProfile.findUnique({
select: {
_count: {
select: {
Order: {
where: {
User: {
Subscription: {
some: {
expiresAt: {
gt: new Date()
}
}
}
}
}
}
}
}
},
where: {
id
}
});
return _count.Order > 0;
}
}
}
}
});
});
return new PrismaClient().$extends(symbolProfileExtension);
}
private async getMarketDataForCurrencies(): Promise<AdminMarketData> { private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const marketDataItems = await this.prismaService.marketData.groupBy({ const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true, _count: true,
by: ['dataSource', 'symbol'] by: ['dataSource', 'symbol']
}); });
const marketDataPromise: Promise<AdminMarketDataItem>[] = const marketData: AdminMarketDataItem[] = this.exchangeRateDataService
this.exchangeRateDataService .getCurrencyPairs()
.getCurrencyPairs() .map(({ dataSource, symbol }) => {
.map(async ({ dataSource, symbol }) => { const marketDataItemCount =
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; marketDataItems.find((marketDataItem) => {
let currency: EnhancedSymbolProfile['currency'] = '-'; return (
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
if (isCurrency(getCurrencyFromSymbol(symbol))) { return {
currency = getCurrencyFromSymbol(symbol); dataSource,
({ activitiesCount, dateOfFirstActivity } = marketDataItemCount,
await this.orderService.getStatisticsByCurrency(currency)); symbol,
} assetClass: 'CASH',
countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''),
name: symbol,
sectorsCount: 0
};
});
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
activitiesCount,
currency,
dataSource,
marketDataItemCount,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
name: symbol,
sectorsCount: 0
};
});
const marketData = await Promise.all(marketDataPromise);
return { marketData, count: marketData.length }; return { marketData, count: marketData.length };
} }
@ -576,14 +432,13 @@ export class AdminService {
}, },
createdAt: true, createdAt: true,
id: true, id: true,
role: true,
Subscription: true Subscription: true
}, },
take: 30 take: 30
}); });
return usersWithAnalytics.map( return usersWithAnalytics.map(
({ _count, Analytics, createdAt, id, role, Subscription }) => { ({ _count, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration = const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1; differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics const engagement = Analytics
@ -593,17 +448,13 @@ export class AdminService {
const subscription = this.configurationService.get( const subscription = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION' 'ENABLE_FEATURE_SUBSCRIPTION'
) )
? this.subscriptionService.getSubscription({ ? this.subscriptionService.getSubscription(Subscription)
createdAt,
subscriptions: Subscription
})
: undefined; : undefined;
return { return {
createdAt, createdAt,
engagement, engagement,
id, id,
role,
subscription, subscription,
accountCount: _count.Account || 0, accountCount: _count.Account || 0,
country: Analytics?.country, country: Analytics?.country,

View File

@ -1,56 +1,87 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { AdminJobs } from '@ghostfolio/common/interfaces'; import { AdminJobs } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Delete, Delete,
Get, Get,
HttpException,
Inject,
Param, Param,
Query, Query,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { JobStatus } from 'bull'; import { JobStatus } from 'bull';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { QueueService } from './queue.service'; import { QueueService } from './queue.service';
@Controller('admin/queue') @Controller('admin/queue')
export class QueueController { export class QueueController {
public constructor(private readonly queueService: QueueService) {} public constructor(
private readonly queueService: QueueService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete('job') @Delete('job')
@HasPermission(permissions.accessAdminControl) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteJobs( public async deleteJobs(
@Query('status') filterByStatus?: string @Query('status') filterByStatus?: string
): Promise<void> { ): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined; const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
return this.queueService.deleteJobs({ status }); return this.queueService.deleteJobs({ status });
} }
@Get('job') @Get('job')
@HasPermission(permissions.accessAdminControl) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getJobs( public async getJobs(
@Query('status') filterByStatus?: string @Query('status') filterByStatus?: string
): Promise<AdminJobs> { ): Promise<AdminJobs> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined; const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
return this.queueService.getJobs({ status }); return this.queueService.getJobs({ status });
} }
@Delete('job/:id') @Delete('job/:id')
@HasPermission(permissions.accessAdminControl) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteJob(@Param('id') id: string): Promise<void> { public async deleteJob(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.queueService.deleteJob(id); return this.queueService.deleteJob(id);
} }
@Get('job/:id/execute')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async executeJob(@Param('id') id: string): Promise<void> {
return this.queueService.executeJob(id);
}
} }

View File

@ -1,5 +1,4 @@
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { QueueController } from './queue.controller'; import { QueueController } from './queue.controller';

View File

@ -3,7 +3,6 @@ import {
QUEUE_JOB_STATUS_LIST QUEUE_JOB_STATUS_LIST
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces'; import { AdminJobs } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JobStatus, Queue } from 'bull'; import { JobStatus, Queue } from 'bull';
@ -32,10 +31,6 @@ export class QueueService {
} }
} }
public async executeJob(aId: string) {
return (await this.dataGatheringQueue.getJob(aId))?.promote();
}
public async getJobs({ public async getJobs({
limit = 1000, limit = 1000,
status = QUEUE_JOB_STATUS_LIST status = QUEUE_JOB_STATUS_LIST
@ -58,7 +53,6 @@ export class QueueService {
finishedOn: job.finishedOn, finishedOn: job.finishedOn,
id: job.id, id: job.id,
name: job.name, name: job.name,
opts: job.opts,
stacktrace: job.stacktrace, stacktrace: job.stacktrace,
state: await job.getState(), state: await job.getState(),
timestamp: job.timestamp timestamp: job.timestamp

View File

@ -1,14 +1,5 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client'; import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
import { import { IsEnum, IsObject, IsOptional, IsString } from 'class-validator';
IsArray,
IsEnum,
IsObject,
IsOptional,
IsString,
IsUrl
} from 'class-validator';
export class UpdateAssetProfileDto { export class UpdateAssetProfileDto {
@IsEnum(AssetClass, { each: true }) @IsEnum(AssetClass, { each: true })
@ -23,14 +14,6 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
comment?: string; comment?: string;
@IsArray()
@IsOptional()
countries?: Prisma.InputJsonArray;
@IsCurrencyCode()
@IsOptional()
currency?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
name?: string; name?: string;
@ -39,20 +22,9 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
scraperConfiguration?: Prisma.InputJsonObject; scraperConfiguration?: Prisma.InputJsonObject;
@IsArray()
@IsOptional()
sectors?: Prisma.InputJsonArray;
@IsObject() @IsObject()
@IsOptional() @IsOptional()
symbolMapping?: { symbolMapping?: {
[dataProvider: string]: string; [dataProvider: string]: string;
}; };
@IsOptional()
@IsUrl({
protocols: ['https'],
require_protocol: true
})
url?: string;
} }

View File

@ -1,5 +1,4 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { Controller } from '@nestjs/common'; import { Controller } from '@nestjs/common';
@Controller() @Controller()

View File

@ -1,31 +1,27 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { join } from 'path';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service'; import { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { join } from 'path';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module';
import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module'; import { BenchmarkModule } from './benchmark/benchmark.module';
@ -47,18 +43,15 @@ import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@Module({ @Module({
controllers: [AppController],
imports: [ imports: [
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
AssetModule,
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarkModule, BenchmarkModule,
BullModule.forRoot({ BullModule.forRoot({
redis: { redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10),
host: process.env.REDIS_HOST, host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10), port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
password: process.env.REDIS_PASSWORD password: process.env.REDIS_PASSWORD
@ -69,8 +62,6 @@ import { UserModule } from './user/user.module';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
EventEmitterModule.forRoot(),
EventsModule,
ExchangeRateModule, ExchangeRateModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ExportModule, ExportModule,
@ -82,7 +73,6 @@ import { UserModule } from './user/user.module';
PlatformModule, PlatformModule,
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,
PropertyModule,
RedisCacheModule, RedisCacheModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
@ -116,6 +106,7 @@ import { UserModule } from './user/user.module';
TwitterBotModule, TwitterBotModule,
UserModule UserModule
], ],
controllers: [AppController],
providers: [CronService] providers: [CronService]
}) })
export class AppModule {} export class AppModule {}

View File

@ -1,29 +0,0 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { pick } from 'lodash';
@Controller('asset')
export class AssetController {
public constructor(private readonly adminService: AdminService) {}
@Get(':dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAsset(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
const { assetProfile, marketData } =
await this.adminService.getMarketDataBySymbol({ dataSource, symbol });
return {
marketData,
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
};
}
}

View File

@ -1,17 +0,0 @@
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { Module } from '@nestjs/common';
import { AssetController } from './asset.controller';
@Module({
controllers: [AssetController],
imports: [
AdminModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
]
})
export class AssetModule {}

View File

@ -1,19 +1,40 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { permissions } from '@ghostfolio/common/permissions'; import {
Controller,
import { Controller, Delete, Param, UseGuards } from '@nestjs/common'; Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('auth-device') @Controller('auth-device')
export class AuthDeviceController { export class AuthDeviceController {
public constructor(private readonly authDeviceService: AuthDeviceService) {} public constructor(
private readonly authDeviceService: AuthDeviceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteAuthDevice) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAuthDevice(@Param('id') id: string): Promise<void> { public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.deleteAuthDevice
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.authDeviceService.deleteAuthDevice({ id }); await this.authDeviceService.deleteAuthDevice({ id });
} }
} }

View File

@ -1,13 +1,14 @@
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller'; import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@Module({ @Module({
controllers: [AuthDeviceController], controllers: [AuthDeviceController],
imports: [ imports: [
ConfigurationModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }

View File

@ -1,11 +1,14 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AuthDevice, Prisma } from '@prisma/client'; import { AuthDevice, Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class AuthDeviceService { export class AuthDeviceService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService
) {}
public async authDevice( public async authDevice(
where: Prisma.AuthDeviceWhereUniqueInput where: Prisma.AuthDeviceWhereUniqueInput

View File

@ -1,9 +1,7 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces'; import { OAuthResponse } from '@ghostfolio/common/interfaces';
import { import {
Body, Body,
Controller, Controller,
@ -120,13 +118,13 @@ export class AuthController {
} }
@Get('webauthn/generate-registration-options') @Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async generateRegistrationOptions() { public async generateRegistrationOptions() {
return this.webAuthService.generateRegistrationOptions(); return this.webAuthService.generateRegistrationOptions();
} }
@Post('webauthn/verify-attestation') @Post('webauthn/verify-attestation')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async verifyAttestation( public async verifyAttestation(
@Body() body: { deviceName: string; credential: AttestationCredentialJSON } @Body() body: { deviceName: string; credential: AttestationCredentialJSON }
) { ) {

View File

@ -5,7 +5,6 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';

View File

@ -1,7 +1,6 @@
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';

View File

@ -1,9 +1,8 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
import { Profile, Strategy } from 'passport-google-oauth20'; import { Strategy } from 'passport-google-oauth20';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@ -11,7 +10,7 @@ import { AuthService } from './auth.service';
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly configurationService: ConfigurationService readonly configurationService: ConfigurationService
) { ) {
super({ super({
callbackURL: `${configurationService.get( callbackURL: `${configurationService.get(
@ -20,7 +19,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
clientID: configurationService.get('GOOGLE_CLIENT_ID'), clientID: configurationService.get('GOOGLE_CLIENT_ID'),
clientSecret: configurationService.get('GOOGLE_SECRET'), clientSecret: configurationService.get('GOOGLE_SECRET'),
passReqToCallback: true, passReqToCallback: true,
scope: ['profile'] scope: ['email', 'profile']
}); });
} }
@ -28,17 +27,20 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
request: any, request: any,
token: string, token: string,
refreshToken: string, refreshToken: string,
profile: Profile, profile,
done: Function, done: Function,
done2: Function done2: Function
) { ) {
try { try {
const jwt = await this.authService.validateOAuthLogin({ const jwt: string = await this.authService.validateOAuthLogin({
provider: Provider.GOOGLE, provider: Provider.GOOGLE,
thirdPartyId: profile.id thirdPartyId: profile.id
}); });
const user = {
jwt
};
done(null, { jwt }); done(null, user);
} catch (error) { } catch (error) {
Logger.error(error, 'GoogleStrategy'); Logger.error(error, 'GoogleStrategy');
done(error, false); done(error, false);

View File

@ -1,5 +1,4 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
export interface AuthDeviceDialogParams { export interface AuthDeviceDialogParams {

View File

@ -2,12 +2,9 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config'; import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { HttpException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import * as countriesAndTimezones from 'countries-and-timezones'; import * as countriesAndTimezones from 'countries-and-timezones';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable() @Injectable()
@ -31,13 +28,6 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
if (user) { if (user) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
const country = const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id; countriesAndTimezones.getCountryForTimezone(timezone)?.id;
@ -54,20 +44,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return user; return user;
} else { } else {
throw new HttpException( throw '';
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
} catch (error) {
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) {
throw error;
} else {
throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED
);
} }
} catch (err) {
throw new UnauthorizedException('unauthorized', err.message);
} }
} }
} }

View File

@ -3,7 +3,6 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Inject, Inject,
Injectable, Injectable,
@ -41,7 +40,7 @@ export class WebAuthService {
) {} ) {}
get rpID() { get rpID() {
return new URL(this.configurationService.get('ROOT_URL')).hostname; return this.configurationService.get('WEB_AUTH_RP_ID');
} }
get expectedOrigin() { get expectedOrigin() {

View File

@ -1,16 +1,12 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import type { import type {
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkResponse, BenchmarkResponse,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -20,7 +16,6 @@ import {
Inject, Inject,
Param, Param,
Post, Post,
Query,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
@ -38,10 +33,21 @@ export class BenchmarkController {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@HasPermission(permissions.accessAdminControl)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) { public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try { try {
const benchmark = await this.benchmarkService.addBenchmark({ const benchmark = await this.benchmarkService.addBenchmark({
dataSource, dataSource,
@ -65,12 +71,23 @@ export class BenchmarkController {
} }
@Delete(':dataSource/:symbol') @Delete(':dataSource/:symbol')
@HasPermission(permissions.accessAdminControl) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteBenchmark( public async deleteBenchmark(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
) { ) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try { try {
const benchmark = await this.benchmarkService.deleteBenchmark({ const benchmark = await this.benchmarkService.deleteBenchmark({
dataSource, dataSource,
@ -103,26 +120,19 @@ export class BenchmarkController {
} }
@Get(':dataSource/:symbol/:startDateString') @Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataForUser( public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string
@Query('range') dateRange: DateRange = 'max'
): Promise<BenchmarkMarketDataDetails> { ): Promise<BenchmarkMarketDataDetails> {
const { endDate, startDate } = getInterval( const startDate = new Date(startDateString);
dateRange,
new Date(startDateString)
);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataForUser({ return this.benchmarkService.getMarketDataBySymbol({
dataSource, dataSource,
endDate,
startDate, startDate,
symbol, symbol
userCurrency
}); });
} }
} }

View File

@ -1,14 +1,11 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { BenchmarkController } from './benchmark.controller'; import { BenchmarkController } from './benchmark.controller';
@ -18,16 +15,14 @@ import { BenchmarkService } from './benchmark.service';
controllers: [BenchmarkController], controllers: [BenchmarkController],
exports: [BenchmarkService], exports: [BenchmarkService],
imports: [ imports: [
ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolModule, SymbolModule,
SymbolProfileModule, SymbolProfileModule
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
], ],
providers: [BenchmarkService] providers: [BenchmarkService]
}) })

View File

@ -11,7 +11,6 @@ describe('BenchmarkService', () => {
null, null,
null, null,
null, null,
null,
null null
); );
}); });

View File

@ -1,7 +1,6 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -12,9 +11,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
calculateBenchmarkTrend, calculateBenchmarkTrend
parseDate,
resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
Benchmark, Benchmark,
@ -24,18 +21,11 @@ import {
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { BenchmarkTrend } from '@ghostfolio/common/types'; import { BenchmarkTrend } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import Big from 'big.js';
import { import { format, subDays } from 'date-fns';
differenceInDays, import { uniqBy } from 'lodash';
eachDayOfInterval,
format,
isSameDay,
subDays
} from 'date-fns';
import { isNumber, last, uniqBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@Injectable() @Injectable()
@ -44,7 +34,6 @@ export class BenchmarkService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -117,9 +106,7 @@ export class BenchmarkService {
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}), })
requestTimeout: ms('30 seconds'),
useCache: false
}); });
for (const { dataSource, symbol } of benchmarkAssetProfiles) { for (const { dataSource, symbol } of benchmarkAssetProfiles) {
@ -135,7 +122,7 @@ export class BenchmarkService {
Promise.all(promisesAllTimeHighs), Promise.all(promisesAllTimeHighs),
Promise.all(promisesBenchmarkTrends) Promise.all(promisesBenchmarkTrends)
]); ]);
let storeInCache = useCache; let storeInCache = true;
benchmarks = allTimeHighs.map((allTimeHigh, index) => { benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } = const { marketPrice } =
@ -153,7 +140,6 @@ export class BenchmarkService {
} }
return { return {
dataSource: benchmarkAssetProfiles[index].dataSource,
marketCondition: this.getMarketCondition( marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh performancePercentFromAllTimeHigh
), ),
@ -161,13 +147,9 @@ export class BenchmarkService {
performances: { performances: {
allTimeHigh: { allTimeHigh: {
date: allTimeHigh?.date, date: allTimeHigh?.date,
performancePercent: performancePercent: performancePercentFromAllTimeHigh
performancePercentFromAllTimeHigh >= 0
? 0
: performancePercentFromAllTimeHigh
} }
}, },
symbol: benchmarkAssetProfiles[index].symbol,
trend50d: benchmarkTrends[index].trend50d, trend50d: benchmarkTrends[index].trend50d,
trend200d: benchmarkTrends[index].trend200d trend200d: benchmarkTrends[index].trend200d
}; };
@ -177,7 +159,7 @@ export class BenchmarkService {
await this.redisCacheService.set( await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS, this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks), JSON.stringify(benchmarks),
ms('2 hours') / 1000 ms('4 hours') / 1000
); );
} }
@ -218,30 +200,11 @@ export class BenchmarkService {
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
} }
public async getMarketDataForUser({ public async getMarketDataBySymbol({
dataSource, dataSource,
endDate = new Date(),
startDate, startDate,
symbol, symbol
userCurrency }: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
}: {
endDate?: Date;
startDate: Date;
userCurrency: string;
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = [];
const days = differenceInDays(endDate, startDate) + 1;
const dates = eachDayOfInterval(
{
start: startDate,
end: endDate
},
{ step: Math.round(days / Math.min(days, MAX_CHART_ITEMS)) }
).map((date) => {
return resetHours(date);
});
const [currentSymbolItem, marketDataItems] = await Promise.all([ const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({ this.symbolService.get({
dataGatheringItem: { dataGatheringItem: {
@ -257,92 +220,50 @@ export class BenchmarkService {
dataSource, dataSource,
symbol, symbol,
date: { date: {
in: dates gte: startDate
} }
} }
}) })
]); ]);
const exchangeRates = const step = Math.round(
await this.exchangeRateDataService.getExchangeRatesByCurrency({ marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
startDate,
currencies: [currentSymbolItem.currency],
targetCurrency: userCurrency
});
const exchangeRateAtStartDate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(startDate, DATE_FORMAT)
];
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
return isSameDay(date, startDate);
})?.marketPrice;
if (!marketPriceAtStartDate) {
Logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate,
DATE_FORMAT
)}`,
'BenchmarkService'
);
return { marketData };
}
for (let marketDataItem of marketDataItems) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(marketDataItem.date, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice * exchangeRateFactor
) * 100
});
}
const includesEndDate = isSameDay(
parseDate(last(marketData).date),
endDate
); );
if (currentSymbolItem?.marketPrice && !includesEndDate) { const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
const exchangeRate = const response = {
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ marketData: [
format(endDate, DATE_FORMAT) ...marketDataItems
]; .filter((marketDataItem, index) => {
return index % step === 0;
})
.map((marketDataItem) => {
return {
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice
) * 100
};
})
]
};
const exchangeRateFactor = if (currentSymbolItem?.marketPrice) {
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate) response.marketData.push({
? exchangeRate / exchangeRateAtStartDate date: format(new Date(), DATE_FORMAT),
: 1;
marketData.push({
date: format(endDate, DATE_FORMAT),
value: value:
this.calculateChangeInPercentage( this.calculateChangeInPercentage(
marketPriceAtStartDate, marketPriceAtStartDate,
currentSymbolItem.marketPrice * exchangeRateFactor currentSymbolItem.marketPrice
) * 100 ) * 100
}); });
} }
return { return response;
marketData
};
} }
public async addBenchmark({ public async addBenchmark({
@ -422,7 +343,7 @@ export class BenchmarkService {
private getMarketCondition( private getMarketCondition(
aPerformanceInPercent: number aPerformanceInPercent: number
): Benchmark['marketCondition'] { ): Benchmark['marketCondition'] {
if (aPerformanceInPercent >= 0) { if (aPerformanceInPercent === 0) {
return 'ALL_TIME_HIGH'; return 'ALL_TIME_HIGH';
} else if (aPerformanceInPercent <= -0.2) { } else if (aPerformanceInPercent <= -0.2) {
return 'BEAR_MARKET'; return 'BEAR_MARKET';

View File

@ -1,19 +1,39 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { permissions } from '@ghostfolio/common/permissions'; import {
Controller,
import { Controller, Post, UseGuards } from '@nestjs/common'; HttpException,
Inject,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('cache') @Controller('cache')
export class CacheController { export class CacheController {
public constructor(private readonly redisCacheService: RedisCacheService) {} public constructor(
private readonly redisCacheService: RedisCacheService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@HasPermission(permissions.accessAdminControl)
@Post('flush') @Post('flush')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async flushCache(): Promise<void> { public async flushCache(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.redisCacheService.reset(); return this.redisCacheService.reset();
} }
} }

View File

@ -1,11 +1,24 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CacheController } from './cache.controller'; import { CacheController } from './cache.controller';
@Module({ @Module({
controllers: [CacheController], controllers: [CacheController],
imports: [RedisCacheModule] imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule
]
}) })
export class CacheModule {} export class CacheModule {}

View File

@ -1,6 +1,4 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { import {
Controller, Controller,
Get, Get,
@ -21,7 +19,7 @@ export class ExchangeRateController {
) {} ) {}
@Get(':symbol/:dateString') @Get(':symbol/:dateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async getExchangeRate( public async getExchangeRate(
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string

View File

@ -1,5 +1,4 @@
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ExchangeRateController } from './exchange-rate.controller'; import { ExchangeRateController } from './exchange-rate.controller';

View File

@ -1,5 +1,4 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()

View File

@ -1,8 +1,5 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { Export } from '@ghostfolio/common/interfaces'; import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common'; import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -12,28 +9,17 @@ import { ExportService } from './export.service';
@Controller('export') @Controller('export')
export class ExportController { export class ExportController {
public constructor( public constructor(
private readonly apiService: ApiService,
private readonly exportService: ExportService, private readonly exportService: ExportService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query('activityIds') activityIds?: string[]
@Query('activityIds') activityIds?: string[],
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<Export> { ): Promise<Export> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
return this.exportService.export({ return this.exportService.export({
activityIds, activityIds,
filters,
userCurrency: this.request.user.Settings.settings.baseCurrency, userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id
}); });

View File

@ -1,14 +1,23 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module'; import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ExportController } from './export.controller'; import { ExportController } from './export.controller';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
@Module({ @Module({
imports: [AccountModule, ApiModule, OrderModule], imports: [
AccountModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
OrderModule,
RedisCacheModule
],
controllers: [ExportController], controllers: [ExportController],
providers: [ExportService] providers: [ExportService]
}) })

View File

@ -1,8 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { Filter, Export } from '@ghostfolio/common/interfaces'; import { Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
@ -14,12 +13,10 @@ export class ExportService {
public async export({ public async export({
activityIds, activityIds,
filters,
userCurrency, userCurrency,
userId userId
}: { }: {
activityIds?: string[]; activityIds?: string[];
filters?: Filter[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<Export> { }): Promise<Export> {
@ -45,7 +42,6 @@ export class ExportService {
); );
let { activities } = await this.orderService.getOrders({ let { activities } = await this.orderService.getOrders({
filters,
userCurrency, userCurrency,
userId, userId,
includeDrafts: true, includeDrafts: true,
@ -95,10 +91,7 @@ export class ExportService {
: SymbolProfile.symbol : SymbolProfile.symbol
}; };
} }
), )
user: {
settings: { currency: userCurrency }
}
}; };
} }
} }

View File

@ -1,5 +1,4 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { import {
Controller, Controller,
Get, Get,

View File

@ -1,7 +1,6 @@
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
@ -9,11 +8,7 @@ import { HealthService } from './health.service';
@Module({ @Module({
controllers: [HealthController], controllers: [HealthController],
imports: [ imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
DataEnhancerModule,
DataProviderModule,
TransformDataSourceInRequestModule
],
providers: [HealthService] providers: [HealthService]
}) })
export class HealthModule {} export class HealthModule {}

View File

@ -1,6 +1,5 @@
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service'; import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';

View File

@ -1,6 +1,5 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsOptional, ValidateNested } from 'class-validator'; import { IsArray, IsOptional, ValidateNested } from 'class-validator';

View File

@ -1,12 +1,9 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImportResponse } from '@ghostfolio/common/interfaces'; import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -37,18 +34,19 @@ export class ImportController {
) {} ) {}
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
@HasPermission(permissions.createOrder)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async import( public async import(
@Body() importData: ImportDataDto, @Body() importData: ImportDataDto,
@Query('dryRun') isDryRunParam = 'false' @Query('dryRun') isDryRun?: boolean
): Promise<ImportResponse> { ): Promise<ImportResponse> {
const isDryRun = isDryRunParam === 'true';
if ( if (
!hasPermission(this.request.user.permissions, permissions.createAccount) !hasPermission(
this.request.user.permissions,
permissions.createAccount
) ||
!hasPermission(this.request.user.permissions, permissions.createOrder)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -67,13 +65,16 @@ export class ImportController {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER; maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
} }
const userCurrency = this.request.user.Settings.settings.baseCurrency;
try { try {
const activities = await this.importService.import({ const activities = await this.importService.import({
isDryRun, isDryRun,
maxActivitiesToImport, maxActivitiesToImport,
userCurrency,
accountsDto: importData.accounts ?? [], accountsDto: importData.accounts ?? [],
activitiesDto: importData.activities, activitiesDto: importData.activities,
user: this.request.user userId: this.request.user.id
}); });
return { activities }; return { activities };
@ -91,7 +92,7 @@ export class ImportController {
} }
@Get('dividends/:dataSource/:symbol') @Get('dividends/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async gatherDividends( public async gatherDividends(

View File

@ -4,15 +4,12 @@ import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ImportController } from './import.controller'; import { ImportController } from './import.controller';
@ -32,9 +29,7 @@ import { ImportService } from './import.service';
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
], ],
providers: [ImportService] providers: [ImportService]
}) })

View File

@ -13,7 +13,6 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier, getAssetProfileIdentifier,
@ -22,13 +21,11 @@ import {
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { import {
AccountWithPlatform, AccountWithPlatform,
OrderWithAccount, OrderWithAccount
UserWithSettings
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import Big from 'big.js';
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns'; import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -72,13 +69,9 @@ export class ImportService {
}) })
]); ]);
const accounts = orders const accounts = orders.map((order) => {
.filter(({ Account }) => { return order.Account;
return !!Account; });
})
.map(({ Account }) => {
return Account;
});
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
@ -117,13 +110,12 @@ export class ImportService {
accountId: Account?.id, accountId: Account?.id,
accountUserId: undefined, accountUserId: undefined,
comment: undefined, comment: undefined,
currency: undefined,
createdAt: undefined, createdAt: undefined,
fee: 0, fee: 0,
feeInBaseCurrency: 0, feeInBaseCurrency: 0,
id: assetProfile.id, id: assetProfile.id,
isDraft: false, isDraft: false,
SymbolProfile: assetProfile, SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
symbolProfileId: assetProfile.id, symbolProfileId: assetProfile.id,
type: 'DIVIDEND', type: 'DIVIDEND',
unitPrice: marketPrice, unitPrice: marketPrice,
@ -146,16 +138,17 @@ export class ImportService {
activitiesDto, activitiesDto,
isDryRun = false, isDryRun = false,
maxActivitiesToImport, maxActivitiesToImport,
user userCurrency,
userId
}: { }: {
accountsDto: Partial<CreateAccountDto>[]; accountsDto: Partial<CreateAccountDto>[];
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
isDryRun?: boolean; isDryRun?: boolean;
maxActivitiesToImport: number; maxActivitiesToImport: number;
user: UserWithSettings; userCurrency: string;
userId: string;
}): Promise<Activity[]> { }): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {}; const accountIdMapping: { [oldAccountId: string]: string } = {};
const userCurrency = user.Settings.settings.baseCurrency;
if (!isDryRun && accountsDto?.length) { if (!isDryRun && accountsDto?.length) {
const [existingAccounts, existingPlatforms] = await Promise.all([ const [existingAccounts, existingPlatforms] = await Promise.all([
@ -178,7 +171,7 @@ export class ImportService {
); );
// If there is no account or if the account belongs to a different user then create a new account // If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== user.id) { if (!accountWithSameId || accountWithSameId.userId !== userId) {
let oldAccountId: string; let oldAccountId: string;
const platformId = account.platformId; const platformId = account.platformId;
@ -191,7 +184,7 @@ export class ImportService {
let accountObject: Prisma.AccountCreateInput = { let accountObject: Prisma.AccountCreateInput = {
...account, ...account,
User: { connect: { id: user.id } } User: { connect: { id: userId } }
}; };
if ( if (
@ -207,7 +200,7 @@ export class ImportService {
const newAccount = await this.accountService.createAccount( const newAccount = await this.accountService.createAccount(
accountObject, accountObject,
user.id userId
); );
// Store the new to old account ID mappings for updating activities // Store the new to old account ID mappings for updating activities
@ -238,17 +231,16 @@ export class ImportService {
const assetProfiles = await this.validateActivities({ const assetProfiles = await this.validateActivities({
activitiesDto, activitiesDto,
maxActivitiesToImport, maxActivitiesToImport
user
}); });
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
activitiesDto, activitiesDto,
userCurrency, userCurrency,
userId: user.id userId
}); });
const accounts = (await this.accountService.getAccounts(user.id)).map( const accounts = (await this.accountService.getAccounts(userId)).map(
({ id, name }) => { ({ id, name }) => {
return { id, name }; return { id, name };
} }
@ -267,7 +259,6 @@ export class ImportService {
{ {
accountId, accountId,
comment, comment,
currency,
date, date,
error, error,
fee, fee,
@ -292,11 +283,11 @@ export class ImportService {
assetSubClass, assetSubClass,
countries, countries,
createdAt, createdAt,
currency,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings,
id, id,
isin, isin,
name, name,
@ -349,12 +340,12 @@ export class ImportService {
if (isDryRun) { if (isDryRun) {
order = { order = {
comment, comment,
currency,
date, date,
fee, fee,
quantity, quantity,
type, type,
unitPrice, unitPrice,
userId,
accountId: validatedAccount?.id, accountId: validatedAccount?.id,
accountUserId: undefined, accountUserId: undefined,
createdAt: new Date(), createdAt: new Date(),
@ -365,11 +356,11 @@ export class ImportService {
assetSubClass, assetSubClass,
countries, countries,
createdAt, createdAt,
currency,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings,
id, id,
isin, isin,
name, name,
@ -379,13 +370,11 @@ export class ImportService {
symbolMapping, symbolMapping,
updatedAt, updatedAt,
url, url,
currency: assetProfile.currency,
comment: assetProfile.comment comment: assetProfile.comment
}, },
Account: validatedAccount, Account: validatedAccount,
symbolProfileId: undefined, symbolProfileId: undefined,
updatedAt: new Date(), updatedAt: new Date()
userId: user.id
}; };
} else { } else {
if (error) { if (error) {
@ -399,13 +388,14 @@ export class ImportService {
quantity, quantity,
type, type,
unitPrice, unitPrice,
userId,
accountId: validatedAccount?.id, accountId: validatedAccount?.id,
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: { create: {
currency,
dataSource, dataSource,
symbol, symbol
currency: assetProfile.currency
}, },
where: { where: {
dataSource_symbol: { dataSource_symbol: {
@ -416,14 +406,8 @@ export class ImportService {
} }
}, },
updateAccountBalance: false, updateAccountBalance: false,
User: { connect: { id: user.id } }, User: { connect: { id: userId } }
userId: user.id
}); });
if (order.SymbolProfile?.symbol) {
// Update symbol that may have been assigned in createOrder()
assetProfile.symbol = order.SymbolProfile.symbol;
}
} }
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
@ -434,14 +418,14 @@ export class ImportService {
value, value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee, fee,
assetProfile.currency, currency,
userCurrency userCurrency
), ),
// @ts-ignore // @ts-ignore
SymbolProfile: assetProfile, SymbolProfile: assetProfile,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value, value,
assetProfile.currency, currency,
userCurrency userCurrency
) )
}); });
@ -460,16 +444,15 @@ export class ImportService {
}); });
}); });
this.dataGatheringService.gatherSymbols({ this.dataGatheringService.gatherSymbols(
dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => { uniqueActivities.map(({ date, SymbolProfile }) => {
return { return {
date, date,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol symbol: SymbolProfile.symbol
}; };
}), })
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH );
});
} }
return activities; return activities;
@ -536,15 +519,22 @@ export class ImportService {
currency, currency,
dataSource, dataSource,
symbol, symbol,
activitiesCount: undefined, assetClass: null,
assetClass: undefined, assetSubClass: null,
assetSubClass: undefined, comment: null,
countries: undefined, countries: null,
createdAt: undefined, createdAt: undefined,
holdings: undefined, figi: null,
figiComposite: null,
figiShareClass: null,
id: undefined, id: undefined,
sectors: undefined, isin: null,
updatedAt: undefined name: null,
scraperConfiguration: null,
sectors: null,
symbolMapping: null,
updatedAt: undefined,
url: null
} }
}; };
} }
@ -563,12 +553,10 @@ export class ImportService {
private async validateActivities({ private async validateActivities({
activitiesDto, activitiesDto,
maxActivitiesToImport, maxActivitiesToImport
user
}: { }: {
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
maxActivitiesToImport: number; maxActivitiesToImport: number;
user: UserWithSettings;
}) { }) {
if (activitiesDto?.length > maxActivitiesToImport) { if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
@ -578,53 +566,46 @@ export class ImportService {
[assetProfileIdentifier: string]: Partial<SymbolProfile>; [assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {}; } = {};
const uniqueActivitiesDto = uniqBy(
activitiesDto,
({ dataSource, symbol }) => {
return getAssetProfileIdentifier({ dataSource, symbol });
}
);
for (const [ for (const [
index, index,
{ currency, dataSource, symbol, type } { currency, dataSource, symbol }
] of activitiesDto.entries()) { ] of uniqueActivitiesDto.entries()) {
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) { if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
throw new Error( throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid` `activities.${index}.dataSource ("${dataSource}") is not valid`
); );
} }
if ( if (dataSource !== 'MANUAL') {
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && const assetProfile = (
user.subscription.type === 'Basic' await this.dataProviderService.getAssetProfiles([
) { { dataSource, symbol }
const dataProvider = this.dataProviderService.getDataProvider( ])
DataSource[dataSource] )?.[symbol];
);
if (dataProvider.getDataProviderInfo().isPremium) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
} }
}
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) { if (
const assetProfile = { assetProfile.currency !== currency &&
currency, !this.exchangeRateDataService.hasCurrencyPair(
...( currency,
await this.dataProviderService.getAssetProfiles([ assetProfile.currency
{ dataSource, symbol } )
]) ) {
)?.[symbol] throw new Error(
}; `activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
);
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
if (assetProfile.currency !== currency) {
throw new Error(
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
);
}
} }
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =

View File

@ -1,6 +1,5 @@
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseInterceptors } from '@nestjs/common'; import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { InfoService } from './info.service'; import { InfoService } from './info.service';

View File

@ -2,15 +2,14 @@ import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -34,7 +33,6 @@ import { InfoService } from './info.service';
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
TagModule, TagModule,
TransformDataSourceInResponseModule,
UserModule UserModule
], ],
providers: [InfoService] providers: [InfoService]

View File

@ -8,6 +8,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT,
PROPERTY_BETTER_UPTIME_MONITOR_ID, PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
@ -28,7 +29,6 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOffer } from '@ghostfolio/common/types'; import { SubscriptionOffer } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
@ -60,6 +60,10 @@ export class InfoService {
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
if (this.configurationService.get('ENABLE_FEATURE_BLOG')) {
globalPermissions.push(permissions.enableBlog);
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource( info.fearAndGreedDataSource = encodeDataSource(
@ -158,7 +162,7 @@ export class InfoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT')); }, DEFAULT_REQUEST_TIMEOUT);
const { pull_count } = await got( const { pull_count } = await got(
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`, `https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
@ -183,7 +187,7 @@ export class InfoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT')); }, DEFAULT_REQUEST_TIMEOUT);
const { body } = await got('https://github.com/ghostfolio/ghostfolio', { const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
// @ts-ignore // @ts-ignore
@ -192,11 +196,11 @@ export class InfoService {
const $ = cheerio.load(body); const $ = cheerio.load(body);
return extractNumberFromString({ return extractNumberFromString(
value: $( $(
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter` `a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
).text() ).text()
}); );
} catch (error) { } catch (error) {
Logger.error(error, 'InfoService - GitHub'); Logger.error(error, 'InfoService - GitHub');
@ -210,7 +214,7 @@ export class InfoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT')); }, DEFAULT_REQUEST_TIMEOUT);
const { stargazers_count } = await got( const { stargazers_count } = await got(
`https://api.github.com/repos/ghostfolio/ghostfolio`, `https://api.github.com/repos/ghostfolio/ghostfolio`,
@ -338,7 +342,7 @@ export class InfoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT')); }, DEFAULT_REQUEST_TIMEOUT);
const { data } = await got( const { data } = await got(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
@ -348,7 +352,7 @@ export class InfoService {
{ {
headers: { headers: {
Authorization: `Bearer ${this.configurationService.get( Authorization: `Bearer ${this.configurationService.get(
'API_KEY_BETTER_UPTIME' 'BETTER_UPTIME_API_KEY'
)}` )}`
}, },
// @ts-ignore // @ts-ignore

View File

@ -1,5 +1,4 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { import {
Controller, Controller,
Get, Get,

View File

@ -1,7 +1,5 @@
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { LogoController } from './logo.controller'; import { LogoController } from './logo.controller';
@ -9,11 +7,7 @@ import { LogoService } from './logo.service';
@Module({ @Module({
controllers: [LogoController], controllers: [LogoController],
imports: [ imports: [ConfigurationModule, SymbolProfileModule],
ConfigurationModule,
SymbolProfileModule,
TransformDataSourceInRequestModule
],
providers: [LogoService] providers: [LogoService]
}) })
export class LogoModule {} export class LogoModule {}

View File

@ -1,7 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import got from 'got'; import got from 'got';
@ -10,7 +9,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable() @Injectable()
export class LogoService { export class LogoService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -48,7 +46,7 @@ export class LogoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT')); }, DEFAULT_REQUEST_TIMEOUT);
return got( return got(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,

View File

@ -1,6 +1,3 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -17,8 +14,7 @@ import {
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
Min, Min
Validate
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
@ -42,19 +38,14 @@ export class CreateOrderDto {
) )
comment?: string; comment?: string;
@IsCurrencyCode() @IsString()
currency: string; currency: string;
@IsCurrencyCode()
@IsOptional()
customCurrency?: string;
@IsOptional() @IsOptional()
@IsEnum(DataSource, { each: true }) @IsEnum(DataSource, { each: true })
dataSource?: DataSource; dataSource?: DataSource;
@IsISO8601() @IsISO8601()
@Validate(IsAfter1970Constraint)
date: string; date: string;
@IsNumber() @IsNumber()

View File

@ -1,19 +1,13 @@
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { AccountWithPlatform } from '@ghostfolio/common/types';
import { Order, Tag } from '@prisma/client';
export interface Activities { export interface Activities {
activities: Activity[]; activities: Activity[];
count: number; count: number;
} }
export interface Activity extends Order { export interface Activity extends OrderWithAccount {
Account?: AccountWithPlatform;
error?: ActivityError; error?: ActivityError;
feeInBaseCurrency: number; feeInBaseCurrency: number;
SymbolProfile?: EnhancedSymbolProfile;
tags?: Tag[];
updateAccountBalance?: boolean; updateAccountBalance?: boolean;
value: number; value: number;
valueInBaseCurrency: number; valueInBaseCurrency: number;

View File

@ -1,19 +1,12 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
DATA_GATHERING_QUEUE_PRIORITY_HIGH, import { hasPermission, permissions } from '@ghostfolio/common/permissions';
HEADER_KEY_IMPERSONATION import type { RequestWithUser } from '@ghostfolio/common/types';
} from '@ghostfolio/common/config';
import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -51,35 +44,32 @@ export class OrderController {
) {} ) {}
@Delete() @Delete()
@HasPermission(permissions.deleteOrder) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async deleteOrders(): Promise<number> {
public async deleteOrders( if (
@Query('accounts') filterByAccounts?: string, !hasPermission(this.request.user.permissions, permissions.deleteOrder)
@Query('assetClasses') filterByAssetClasses?: string, ) {
@Query('tags') filterByTags?: string throw new HttpException(
): Promise<number> { getReasonPhrase(StatusCodes.FORBIDDEN),
const filters = this.apiService.buildFiltersFromQueryParams({ StatusCodes.FORBIDDEN
filterByAccounts, );
filterByAssetClasses, }
filterByTags
});
return this.orderService.deleteOrders({ return this.orderService.deleteOrders({
filters,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteOrder) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
const order = await this.orderService.order({ const order = await this.orderService.order({ id });
id,
userId: this.request.user.id
});
if (!order) { if (
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
!order ||
order.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -92,27 +82,19 @@ export class OrderController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders( public async getAllOrders(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange?: DateRange,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string, @Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('take') take?: number @Query('take') take?: number
): Promise<Activities> { ): Promise<Activities> {
let endDate: Date;
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getInterval(dateRange));
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -124,11 +106,9 @@ export class OrderController {
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { activities, count } = await this.orderService.getOrders({ const { activities, count } = await this.orderService.getOrders({
endDate,
filters, filters,
sortColumn, sortColumn,
sortDirection, sortDirection,
startDate,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
skip: isNaN(skip) ? undefined : skip, skip: isNaN(skip) ? undefined : skip,
@ -140,18 +120,17 @@ export class OrderController {
return { activities, count }; return { activities, count };
} }
@HasPermission(permissions.createOrder)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
const currency = data.currency; if (
const customCurrency = data.customCurrency; !hasPermission(this.request.user.permissions, permissions.createOrder)
) {
if (customCurrency) { throw new HttpException(
data.currency = customCurrency; getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
delete data.customCurrency; );
} }
const order = await this.orderService.createOrder({ const order = await this.orderService.createOrder({
@ -160,7 +139,7 @@ export class OrderController {
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: { create: {
currency, currency: data.currency,
dataSource: data.dataSource, dataSource: data.dataSource,
symbol: data.symbol symbol: data.symbol
}, },
@ -179,31 +158,31 @@ export class OrderController {
if (data.dataSource && !order.isDraft) { if (data.dataSource && !order.isDraft) {
// Gather symbol data in the background, if data source is set // Gather symbol data in the background, if data source is set
// (not MANUAL) and not draft // (not MANUAL) and not draft
this.dataGatheringService.gatherSymbols({ this.dataGatheringService.gatherSymbols([
dataGatheringItems: [ {
{ dataSource: data.dataSource,
dataSource: data.dataSource, date: order.date,
date: order.date, symbol: data.symbol
symbol: data.symbol }
} ]);
],
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
} }
return order; return order;
} }
@HasPermission(permissions.updateOrder)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
const originalOrder = await this.orderService.order({ const originalOrder = await this.orderService.order({
id id
}); });
if (!originalOrder || originalOrder.userId !== this.request.user.id) { if (
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
!originalOrder ||
originalOrder.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -213,16 +192,8 @@ export class OrderController {
const date = parseISO(data.date); const date = parseISO(data.date);
const accountId = data.accountId; const accountId = data.accountId;
const customCurrency = data.customCurrency;
delete data.accountId; delete data.accountId;
if (customCurrency) {
data.currency = customCurrency;
delete data.customCurrency;
}
return this.orderService.updateOrder({ return this.orderService.updateOrder({
data: { data: {
...data, ...data,

View File

@ -2,17 +2,15 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { OrderController } from './order.controller'; import { OrderController } from './order.controller';
@ -24,16 +22,15 @@ import { OrderService } from './order.service';
imports: [ imports: [
ApiModule, ApiModule,
CacheModule, CacheModule,
ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
PrismaModule, PrismaModule,
RedactValuesInResponseModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
TransformDataSourceInRequestModule, UserModule
TransformDataSourceInResponseModule
], ],
providers: [AccountBalanceService, AccountService, OrderService] providers: [AccountBalanceService, AccountService, OrderService]
}) })

View File

@ -1,24 +1,16 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import { Filter } from '@ghostfolio/common/interfaces';
EnhancedSymbolProfile,
Filter,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -26,11 +18,11 @@ import {
Order, Order,
Prisma, Prisma,
Tag, Tag,
Type as ActivityType Type as TypeOfOrder
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import Big from 'big.js';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Activities } from './interfaces/activities.interface'; import { Activities } from './interfaces/activities.interface';
@ -40,7 +32,6 @@ export class OrderService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
@ -73,13 +64,20 @@ export class OrderService {
} }
const accountId = data.accountId; const accountId = data.accountId;
let currency = data.currency;
const tags = data.tags ?? []; const tags = data.tags ?? [];
const updateAccountBalance = data.updateAccountBalance ?? false; const updateAccountBalance = data.updateAccountBalance ?? false;
const userId = data.userId; const userId = data.userId;
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) { if (
data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY'
) {
const assetClass = data.assetClass; const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
currency = data.SymbolProfile.connectOrCreate.create.currency;
const dataSource: DataSource = 'MANUAL'; const dataSource: DataSource = 'MANUAL';
const id = uuidv4(); const id = uuidv4();
const name = data.SymbolProfile.connectOrCreate.create.symbol; const name = data.SymbolProfile.connectOrCreate.create.symbol;
@ -87,6 +85,7 @@ export class OrderService {
data.id = id; data.id = id;
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass; data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass; data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
data.SymbolProfile.connectOrCreate.create.currency = currency;
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource; data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name; data.SymbolProfile.connectOrCreate.create.name = name;
data.SymbolProfile.connectOrCreate.create.symbol = id; data.SymbolProfile.connectOrCreate.create.symbol = id;
@ -108,8 +107,7 @@ export class OrderService {
jobId: getAssetProfileIdentifier({ jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
}), })
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
} }
}); });
} }
@ -122,6 +120,7 @@ export class OrderService {
delete data.comment; delete data.comment;
} }
delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
@ -130,9 +129,13 @@ export class OrderService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type) const isDraft =
? false data.type === 'FEE' ||
: isAfter(data.date as Date, endOfToday()); data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({ const order = await this.prismaService.order.create({
data: { data: {
@ -144,8 +147,7 @@ export class OrderService {
return { id }; return { id };
}) })
} }
}, }
include: { SymbolProfile: true }
}); });
if (updateAccountBalance === true) { if (updateAccountBalance === true) {
@ -154,26 +156,19 @@ export class OrderService {
.plus(data.fee) .plus(data.fee)
.toNumber(); .toNumber();
if (['BUY', 'FEE'].includes(data.type)) { if (data.type === 'BUY') {
amount = new Big(amount).mul(-1).toNumber(); amount = new Big(amount).mul(-1).toNumber();
} }
await this.accountService.updateAccountBalance({ await this.accountService.updateAccountBalance({
accountId, accountId,
amount, amount,
currency,
userId, userId,
currency: data.SymbolProfile.connectOrCreate.create.currency,
date: data.date as Date date: data.date as Date
}); });
} }
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order; return order;
} }
@ -184,142 +179,62 @@ export class OrderService {
where where
}); });
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesByIds([
order.symbolProfileId
]);
if ( if (
['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type) || order.type === 'FEE' ||
symbolProfile.activitiesCount === 0 order.type === 'INTEREST' ||
order.type === 'ITEM' ||
order.type === 'LIABILITY'
) { ) {
await this.symbolProfileService.deleteById(order.symbolProfileId); await this.symbolProfileService.deleteById(order.symbolProfileId);
} }
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order; return order;
} }
public async deleteOrders({ public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> {
filters,
userId
}: {
filters?: Filter[];
userId: string;
}): Promise<number> {
const { activities } = await this.getOrders({
filters,
userId,
includeDrafts: true,
userCurrency: undefined,
withExcludedAccounts: true
});
const { count } = await this.prismaService.order.deleteMany({ const { count } = await this.prismaService.order.deleteMany({
where: { where
id: {
in: activities.map(({ id }) => {
return id;
})
}
}
}); });
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesByIds(
activities.map(({ symbolProfileId }) => {
return symbolProfileId;
})
);
for (const { activitiesCount, id } of symbolProfiles) {
if (activitiesCount === 0) {
await this.symbolProfileService.deleteById(id);
}
}
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ userId })
);
return count; return count;
} }
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
return this.prismaService.order.findFirst({
orderBy: {
date: 'desc'
},
where: {
SymbolProfile: { dataSource, symbol }
}
});
}
public async getOrders({ public async getOrders({
endDate,
filters, filters,
includeDrafts = false, includeDrafts = false,
skip, skip,
sortColumn, sortColumn,
sortDirection, sortDirection,
startDate,
take = Number.MAX_SAFE_INTEGER, take = Number.MAX_SAFE_INTEGER,
types, types,
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
endDate?: Date;
filters?: Filter[]; filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
skip?: number; skip?: number;
sortColumn?: string; sortColumn?: string;
sortDirection?: Prisma.SortOrder; sortDirection?: Prisma.SortOrder;
startDate?: Date;
take?: number; take?: number;
types?: ActivityType[]; types?: TypeOfOrder[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<Activities> { }): Promise<Activities> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [ let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' }, { date: 'asc' }
{ id: 'asc' }
]; ];
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
if (endDate || startDate) {
where.AND = [];
if (endDate) {
where.AND.push({ date: { lte: endDate } });
}
if (startDate) {
where.AND.push({ date: { gt: startDate } });
}
}
const { const {
ACCOUNT: filtersByAccount, ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass, ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag TAG: filtersByTag
} = groupBy(filters, ({ type }) => { } = groupBy(filters, (filter) => {
return type; return filter.type;
}); });
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (filtersByAccount?.length > 0) { if (filtersByAccount?.length > 0) {
where.accountId = { where.accountId = {
in: filtersByAccount.map(({ id }) => { in: filtersByAccount.map(({ id }) => {
@ -361,30 +276,6 @@ export class OrderService {
}; };
} }
if (searchQuery) {
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
if (where.SymbolProfile) {
where.SymbolProfile = {
AND: [
where.SymbolProfile,
{
OR: searchQueryWhereInput
}
]
};
} else {
where.SymbolProfile = {
OR: searchQueryWhereInput
};
}
}
if (filtersByTag?.length > 0) { if (filtersByTag?.length > 0) {
where.tags = { where.tags = {
some: { some: {
@ -396,18 +287,17 @@ export class OrderService {
} }
if (sortColumn) { if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
} }
if (types) { if (types) {
where.type = { in: types }; where.OR = types.map((type) => {
} return {
type: {
if (withExcludedAccounts === false) { equals: type
where.OR = [ }
{ Account: null }, };
{ Account: { NOT: { isExcluded: true } } } });
];
} }
const [orders, count] = await Promise.all([ const [orders, count] = await Promise.all([
@ -431,76 +321,36 @@ export class OrderService {
this.prismaService.order.count({ where }) this.prismaService.order.count({ where })
]); ]);
const uniqueAssets = uniqBy( const activities = orders
orders.map(({ SymbolProfile }) => { .filter((order) => {
return {
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
}),
({ dataSource, symbol }) => {
return getAssetProfileIdentifier({
dataSource,
symbol
});
}
);
const assetProfiles =
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
const activities = orders.map((order) => {
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
return ( return (
dataSource === order.SymbolProfile.dataSource && withExcludedAccounts ||
symbol === order.SymbolProfile.symbol !order.Account ||
order.Account?.isExcluded === false
); );
})
.map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return {
...order,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.SymbolProfile.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.SymbolProfile.currency,
userCurrency
)
};
}); });
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return {
...order,
value,
// TODO: Use exchange rate of date
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.SymbolProfile.currency,
userCurrency
),
SymbolProfile: assetProfile,
// TODO: Use exchange rate of date
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.SymbolProfile.currency,
userCurrency
)
};
});
return { activities, count }; return { activities, count };
} }
public async getStatisticsByCurrency(
currency: EnhancedSymbolProfile['currency']
): Promise<{
activitiesCount: EnhancedSymbolProfile['activitiesCount'];
dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
}> {
const { _count, _min } = await this.prismaService.order.aggregate({
_count: true,
_min: {
date: true
},
where: { SymbolProfile: { currency } }
});
return {
activitiesCount: _count as number,
dateOfFirstActivity: _min.date
};
}
public async order( public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> { ): Promise<Order | null> {
@ -520,10 +370,13 @@ export class OrderService {
dataSource?: DataSource; dataSource?: DataSource;
symbol?: string; symbol?: string;
tags?: Tag[]; tags?: Tag[];
type?: ActivityType;
}; };
where: Prisma.OrderWhereUniqueInput; where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> { }): Promise<Order> {
if (data.Account.connect.id_userId.id === null) {
delete data.Account;
}
if (!data.comment) { if (!data.comment) {
data.comment = null; data.comment = null;
} }
@ -532,12 +385,13 @@ export class OrderService {
let isDraft = false; let isDraft = false;
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) { if (
data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY'
) {
delete data.SymbolProfile.connect; delete data.SymbolProfile.connect;
if (data.Account?.connect?.id_userId?.id === null) {
data.Account = { disconnect: true };
}
} else { } else {
delete data.SymbolProfile.update; delete data.SymbolProfile.update;
@ -545,22 +399,19 @@ export class OrderService {
if (!isDraft) { if (!isDraft) {
// Gather symbol data of order in the background, if not draft // Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols({ this.dataGatheringService.gatherSymbols([
dataGatheringItems: [ {
{ dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource,
dataSource: date: <Date>data.date,
data.SymbolProfile.connect.dataSource_symbol.dataSource, symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
date: <Date>data.date, }
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol ]);
}
],
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
} }
} }
delete data.assetClass; delete data.assetClass;
delete data.assetSubClass; delete data.assetSubClass;
delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
@ -571,7 +422,7 @@ export class OrderService {
where where
}); });
const order = await this.prismaService.order.update({ return this.prismaService.order.update({
data: { data: {
...data, ...data,
isDraft, isDraft,
@ -583,15 +434,6 @@ export class OrderService {
}, },
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order;
} }
private async orders(params: { private async orders(params: {

View File

@ -1,6 +1,3 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -16,8 +13,7 @@ import {
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
Min, Min
Validate
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
@ -41,18 +37,13 @@ export class UpdateOrderDto {
) )
comment?: string; comment?: string;
@IsCurrencyCode() @IsString()
currency: string; currency: string;
@IsCurrencyCode()
@IsOptional()
customCurrency?: string;
@IsString() @IsString()
dataSource: DataSource; dataSource: DataSource;
@IsISO8601() @IsISO8601()
@Validate(IsAfter1970Constraint)
date: string; date: string;
@IsNumber() @IsNumber()

View File

@ -1,12 +1,9 @@
import { IsString, IsUrl } from 'class-validator'; import { IsString } from 'class-validator';
export class CreatePlatformDto { export class CreatePlatformDto {
@IsString() @IsString()
name: string; name: string;
@IsUrl({ @IsString()
protocols: ['https'],
require_protocol: true
})
url: string; url: string;
} }

View File

@ -1,18 +1,18 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { permissions } from '@ghostfolio/common/permissions';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
HttpException, HttpException,
Inject,
Param, Param,
Post, Post,
Put, Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Platform } from '@prisma/client'; import { Platform } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -23,30 +23,49 @@ import { UpdatePlatformDto } from './update-platform.dto';
@Controller('platform') @Controller('platform')
export class PlatformController { export class PlatformController {
public constructor(private readonly platformService: PlatformService) {} public constructor(
private readonly platformService: PlatformService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async getPlatforms() { public async getPlatforms() {
return this.platformService.getPlatformsWithAccountCount(); return this.platformService.getPlatformsWithAccountCount();
} }
@HasPermission(permissions.createPlatform)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async createPlatform( public async createPlatform(
@Body() data: CreatePlatformDto @Body() data: CreatePlatformDto
): Promise<Platform> { ): Promise<Platform> {
if (
!hasPermission(this.request.user.permissions, permissions.createPlatform)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.platformService.createPlatform(data); return this.platformService.createPlatform(data);
} }
@HasPermission(permissions.updatePlatform)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'))
public async updatePlatform( public async updatePlatform(
@Param('id') id: string, @Param('id') id: string,
@Body() data: UpdatePlatformDto @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({ const originalPlatform = await this.platformService.getPlatform({
id id
}); });
@ -69,9 +88,17 @@ export class PlatformController {
} }
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deletePlatform) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deletePlatform(@Param('id') id: string) { 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({ const originalPlatform = await this.platformService.getPlatform({
id id
}); });

View File

@ -1,5 +1,4 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PlatformController } from './platform.controller'; import { PlatformController } from './platform.controller';

View File

@ -1,5 +1,4 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Platform, Prisma } from '@prisma/client'; import { Platform, Prisma } from '@prisma/client';

View File

@ -1,4 +1,4 @@
import { IsString, IsUrl } from 'class-validator'; import { IsString } from 'class-validator';
export class UpdatePlatformDto { export class UpdatePlatformDto {
@IsString() @IsString()
@ -7,9 +7,6 @@ export class UpdatePlatformDto {
@IsString() @IsString()
name: string; name: string;
@IsUrl({ @IsString()
protocols: ['https'],
require_protocol: true
})
url: string; url: string;
} }

View File

@ -1,33 +0,0 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
export class MWRPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
throw new Error('Method not implemented.');
}
protected getSymbolMetrics({
dataSource,
end,
exchangeRates,
isChartMode = false,
marketSymbolMap,
start,
step = 1,
symbol
}: {
end: Date;
exchangeRates: { [dateString: string]: number };
isChartMode?: boolean;
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & UniqueAsset): SymbolMetrics {
throw new Error('Method not implemented.');
}
}

View File

@ -1,31 +0,0 @@
export const activityDummyData = {
accountId: undefined,
accountUserId: undefined,
comment: undefined,
createdAt: new Date(),
currency: undefined,
feeInBaseCurrency: undefined,
id: undefined,
isDraft: false,
symbolProfileId: undefined,
updatedAt: new Date(),
userId: undefined,
value: undefined,
valueInBaseCurrency: undefined
};
export const symbolProfileDummyData = {
activitiesCount: undefined,
assetClass: undefined,
assetSubClass: undefined,
countries: [],
createdAt: undefined,
holdings: [],
id: undefined,
sectors: [],
updatedAt: undefined
};
export const userDummyData = {
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
};

View File

@ -1,81 +0,0 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator';
import { TWRPortfolioCalculator } from './twr/portfolio-calculator';
export enum PerformanceCalculationType {
MWR = 'MWR', // Money-Weighted Rate of Return
TWR = 'TWR' // Time-Weighted Rate of Return
}
@Injectable()
export class PortfolioCalculatorFactory {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService
) {}
public createCalculator({
accountBalanceItems = [],
activities,
calculationType,
currency,
dateRange = 'max',
hasFilters,
isExperimentalFeatures = false,
userId
}: {
accountBalanceItems?: HistoricalDataItem[];
activities: Activity[];
calculationType: PerformanceCalculationType;
currency: string;
dateRange?: DateRange;
hasFilters: boolean;
isExperimentalFeatures?: boolean;
userId: string;
}): PortfolioCalculator {
const useCache = !hasFilters && isExperimentalFeatures;
switch (calculationType) {
case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
dateRange,
useCache,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.TWR:
return new TWRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
dateRange,
useCache,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
});
default:
throw new Error('Invalid calculation type');
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,219 +0,0 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell in two activities', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-22'),
fee: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.65,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-22')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-22')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
hasErrors: false,
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.05528341497550734703'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.05528341497550734703'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.05528341497550734703'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.05528341497550734703'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),
symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('285.80000000000000396627'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'285.80000000000000396627'
),
transactionCount: 3,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 0 },
{ date: '2021-12-01', investment: 0 }
]);
});
});
});

View File

@ -1,202 +0,0 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-22'),
fee: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-22')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-22')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.0440867739678096571'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
hasErrors: false,
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.0552834149755073478'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.0552834149755073478'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.0440867739678096571'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.0552834149755073478'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.0552834149755073478'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),
symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('285.8'),
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
transactionCount: 2,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 0 },
{ date: '2021-12-01', investment: 0 }
]);
});
});
});

View File

@ -1,186 +0,0 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BALN.SW buy', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-30'),
fee: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 136.6
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-30')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-30')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('297.8'),
errors: [],
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.09004392386530014641'
),
grossPerformanceWithCurrencyEffect: new Big('24.6'),
hasErrors: false,
netPerformance: new Big('23.05'),
netPerformancePercentage: new Big('0.08437042459736456808'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.08437042459736456808'
),
netPerformanceWithCurrencyEffect: new Big('23.05'),
positions: [
{
averagePrice: new Big('136.6'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1.55'),
feeInBaseCurrency: new Big('1.55'),
firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.09004392386530014641'
),
grossPerformanceWithCurrencyEffect: new Big('24.6'),
investment: new Big('273.2'),
investmentWithCurrencyEffect: new Big('273.2'),
netPerformance: new Big('23.05'),
netPerformancePercentage: new Big('0.08437042459736456808'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.08437042459736456808'
),
netPerformanceWithCurrencyEffect: new Big('23.05'),
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('2'),
symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('273.2'),
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
transactionCount: 1,
valueInBaseCurrency: new Big('297.8')
}
],
totalFeesWithCurrencyEffect: new Big('1.55'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('273.2'),
totalInvestmentWithCurrencyEffect: new Big('273.2'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-11-30', investment: new Big('273.2') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 273.2 },
{ date: '2021-12-01', investment: 0 }
]);
});
});
});

View File

@ -1,256 +0,0 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BTCUSD buy and sell partially', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2018-01-01').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2015-01-01'),
fee: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Bitcoin USD',
symbol: 'BTCUSD'
},
type: 'BUY',
unitPrice: 320.43
},
{
...activityDummyData,
date: new Date('2017-12-31'),
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Bitcoin USD',
symbol: 'BTCUSD'
},
type: 'SELL',
unitPrice: 14156.4
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2015-01-01')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2015-01-01')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('13298.425356'),
errors: [],
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.41978276196153750666'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686'
),
grossPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
hasErrors: false,
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.41978276196153750666'),
netPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686'
),
netPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
positions: [
{
averagePrice: new Big('320.43'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.41978276196153750666'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686'
),
grossPerformanceWithCurrencyEffect: new Big(
'26516.208701400000064086'
),
investment: new Big('320.43'),
investmentWithCurrencyEffect: new Big('318.542667299999967957'),
marketPrice: 13657.2,
marketPriceInBaseCurrency: 13298.425356,
netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.41978276196153750666'),
netPerformancePercentageWithCurrencyEffect: new Big(
'41.6401219622042072686'
),
netPerformanceWithCurrencyEffect: new Big(
'26516.208701400000064086'
),
quantity: new Big('1'),
symbol: 'BTCUSD',
tags: [],
timeWeightedInvestment: new Big('640.56763686131386861314'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'636.79469348020066587024'
),
transactionCount: 2,
valueInBaseCurrency: new Big('13298.425356')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('320.43'),
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2015-01-01', investment: new Big('640.86') },
{ date: '2017-12-31', investment: new Big('320.43') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2015-01-01', investment: 637.0853345999999 },
{ date: '2015-02-01', investment: 0 },
{ date: '2015-03-01', investment: 0 },
{ date: '2015-04-01', investment: 0 },
{ date: '2015-05-01', investment: 0 },
{ date: '2015-06-01', investment: 0 },
{ date: '2015-07-01', investment: 0 },
{ date: '2015-08-01', investment: 0 },
{ date: '2015-09-01', investment: 0 },
{ date: '2015-10-01', investment: 0 },
{ date: '2015-11-01', investment: 0 },
{ date: '2015-12-01', investment: 0 },
{ date: '2016-01-01', investment: 0 },
{ date: '2016-02-01', investment: 0 },
{ date: '2016-03-01', investment: 0 },
{ date: '2016-04-01', investment: 0 },
{ date: '2016-05-01', investment: 0 },
{ date: '2016-06-01', investment: 0 },
{ date: '2016-07-01', investment: 0 },
{ date: '2016-08-01', investment: 0 },
{ date: '2016-09-01', investment: 0 },
{ date: '2016-10-01', investment: 0 },
{ date: '2016-11-01', investment: 0 },
{ date: '2016-12-01', investment: 0 },
{ date: '2017-01-01', investment: 0 },
{ date: '2017-02-01', investment: 0 },
{ date: '2017-03-01', investment: 0 },
{ date: '2017-04-01', investment: 0 },
{ date: '2017-05-01', investment: 0 },
{ date: '2017-06-01', investment: 0 },
{ date: '2017-07-01', investment: 0 },
{ date: '2017-08-01', investment: 0 },
{ date: '2017-09-01', investment: 0 },
{ date: '2017-10-01', investment: 0 },
{ date: '2017-11-01', investment: 0 },
{ date: '2017-12-01', investment: -318.54266729999995 },
{ date: '2018-01-01', investment: 0 }
]);
});
});
});

View File

@ -1,158 +0,0 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('compute portfolio snapshot', () => {
it.only('with fee activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-09-01'),
fee: 49,
quantity: 0,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Account Opening Fee',
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
},
type: 'FEE',
unitPrice: 0
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2021-11-30')
);
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [
{
averagePrice: new Big('0'),
currency: 'USD',
dataSource: 'MANUAL',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('49'),
feeInBaseCurrency: new Big('49'),
firstBuyDate: '2021-09-01',
grossPerformance: null,
grossPerformancePercentage: null,
grossPerformancePercentageWithCurrencyEffect: null,
grossPerformanceWithCurrencyEffect: null,
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
marketPrice: null,
marketPriceInBaseCurrency: 0,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
quantity: new Big('0'),
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141',
tags: [],
timeWeightedInvestment: new Big('0'),
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
transactionCount: 1,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('49'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
});
});
});

View File

@ -1,222 +0,0 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with GOOGL buy', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2023-01-03'),
fee: 1,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Alphabet Inc.',
symbol: 'GOOGL'
},
type: 'BUY',
unitPrice: 89.12
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2023-01-03')
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2023-01-03')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('103.10483'),
errors: [],
grossPerformance: new Big('27.33'),
grossPerformancePercentage: new Big('0.3066651705565529623'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.25235044599563974109'
),
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
hasErrors: false,
netPerformance: new Big('26.33'),
netPerformancePercentage: new Big('0.29544434470377019749'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.24112962014285697628'
),
netPerformanceWithCurrencyEffect: new Big('19.851974'),
positions: [
{
averagePrice: new Big('89.12'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1'),
feeInBaseCurrency: new Big('0.9238'),
firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33'),
grossPerformancePercentage: new Big('0.3066651705565529623'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.25235044599563974109'
),
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
investment: new Big('89.12'),
investmentWithCurrencyEffect: new Big('82.329056'),
netPerformance: new Big('26.33'),
netPerformancePercentage: new Big('0.29544434470377019749'),
netPerformancePercentageWithCurrencyEffect: new Big(
'0.24112962014285697628'
),
netPerformanceWithCurrencyEffect: new Big('19.851974'),
marketPrice: 116.45,
marketPriceInBaseCurrency: 103.10483,
quantity: new Big('1'),
symbol: 'GOOGL',
tags: [],
timeWeightedInvestment: new Big('89.12'),
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
transactionCount: 1,
valueInBaseCurrency: new Big('103.10483')
}
],
totalFeesWithCurrencyEffect: new Big('0.9238'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12'),
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2023-01-03', investment: new Big('89.12') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2023-01-01', investment: 82.329056 },
{
date: '2023-02-01',
investment: 0
},
{
date: '2023-03-01',
investment: 0
},
{
date: '2023-04-01',
investment: 0
},
{
date: '2023-05-01',
investment: 0
},
{
date: '2023-06-01',
investment: 0
},
{
date: '2023-07-01',
investment: 0
}
]);
});
});
});

View File

@ -1,158 +0,0 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('compute portfolio snapshot', () => {
it.only('with item activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-01-31').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2022-01-01'),
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Penthouse Apartment',
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
},
type: 'ITEM',
unitPrice: 500000
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-01-01')
);
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [
{
averagePrice: new Big('500000'),
currency: 'USD',
dataSource: 'MANUAL',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-01-01',
grossPerformance: null,
grossPerformancePercentage: null,
grossPerformancePercentageWithCurrencyEffect: null,
grossPerformanceWithCurrencyEffect: null,
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
marketPrice: null,
marketPriceInBaseCurrency: 500000,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
quantity: new Big('0'),
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde',
tags: [],
timeWeightedInvestment: new Big('0'),
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
transactionCount: 1,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
});
});
});

View File

@ -1,108 +0,0 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('compute portfolio snapshot', () => {
it.only('with liability activity', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-01-31').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2023-01-01'), // Date in future
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Loan',
symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
},
type: 'LIABILITY',
unitPrice: 3000
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
spy.mockRestore();
const liabilitiesInBaseCurrency =
await portfolioCalculator.getLiabilitiesInBaseCurrency();
expect(liabilitiesInBaseCurrency).toEqual(new Big(3000));
});
});
});

View File

@ -1,165 +0,0 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with MSFT buy', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-09-16'),
fee: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'BUY',
unitPrice: 298.58
},
{
...activityDummyData,
date: new Date('2021-11-16'),
fee: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'DIVIDEND',
unitPrice: 0.62
}
];
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2023-07-10')
);
spy.mockRestore();
expect(portfolioSnapshot).toMatchObject({
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('298.58'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0.62'),
dividendInBaseCurrency: new Big('0.62'),
fee: new Big('19'),
firstBuyDate: '2021-09-16',
investment: new Big('298.58'),
investmentWithCurrencyEffect: new Big('298.58'),
marketPrice: 331.83,
marketPriceInBaseCurrency: 331.83,
quantity: new Big('1'),
symbol: 'MSFT',
tags: [],
transactionCount: 2
}
],
totalFeesWithCurrencyEffect: new Big('19'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('298.58'),
totalInvestmentWithCurrencyEffect: new Big('298.58'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
});
});
});

View File

@ -1,124 +0,0 @@
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { subDays } from 'date-fns';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService
);
});
describe('get current positions', () => {
it('with no orders', async () => {
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const portfolioCalculator = factory.createCalculator({
activities: [],
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const start = subDays(new Date(Date.now()), 10);
const chartData = await portfolioCalculator.getChartData({ start });
const portfolioSnapshot =
await portfolioCalculator.computeSnapshot(start);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffect: new Big(0),
netPerformanceWithCurrencyEffect: new Big(0),
positions: [],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([
{
date: '2021-12-01',
investment: 0
}
]);
});
});
});

Some files were not shown because too many files have changed in this diff Show More