Browse Source

Merge branch 'main' into featuere/type-info-response

pull/5803/head
vitalymatyushik 2 weeks ago
committed by GitHub
parent
commit
5a448153ed
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      .env.dev
  2. 4
      .env.example
  3. 157
      .eslintrc.json
  4. 3
      .github/FUNDING.yml
  5. 4
      .github/ISSUE_TEMPLATE/bug_report.md
  6. 2
      .github/workflows/build-code.yml
  7. 2
      .github/workflows/docker-image.yml
  8. 40
      .github/workflows/extract-locales.yml
  9. 2
      .gitignore
  10. 2
      .husky/pre-commit
  11. 2
      .nvmrc
  12. 1703
      CHANGELOG.md
  13. 25
      DEVELOPMENT.md
  14. 34
      Dockerfile
  15. 61
      README.md
  16. 13
      SECURITY.md
  17. 22
      apps/api/.eslintrc.json
  18. 31
      apps/api/eslint.config.cjs
  19. 71
      apps/api/src/app/access/access.controller.ts
  20. 21
      apps/api/src/app/access/access.service.ts
  21. 19
      apps/api/src/app/access/update-access.dto.ts
  22. 14
      apps/api/src/app/account-balance/account-balance.service.ts
  23. 32
      apps/api/src/app/account/account.controller.ts
  24. 44
      apps/api/src/app/account/account.service.ts
  25. 144
      apps/api/src/app/admin/admin.controller.ts
  26. 6
      apps/api/src/app/admin/admin.module.ts
  27. 480
      apps/api/src/app/admin/admin.service.ts
  28. 92
      apps/api/src/app/admin/create-asset-profile.dto.ts
  29. 4
      apps/api/src/app/admin/queue/queue.controller.ts
  30. 2
      apps/api/src/app/admin/queue/queue.service.ts
  31. 19
      apps/api/src/app/admin/update-asset-profile.dto.ts
  32. 54
      apps/api/src/app/app.module.ts
  33. 70
      apps/api/src/app/auth/api-key.strategy.ts
  34. 16
      apps/api/src/app/auth/auth.controller.ts
  35. 4
      apps/api/src/app/auth/auth.module.ts
  36. 8
      apps/api/src/app/auth/auth.service.ts
  37. 4
      apps/api/src/app/auth/interfaces/simplewebauthn.ts
  38. 20
      apps/api/src/app/auth/jwt.strategy.ts
  39. 88
      apps/api/src/app/auth/web-auth.service.ts
  40. 59
      apps/api/src/app/endpoints/ai/ai.controller.ts
  41. 59
      apps/api/src/app/endpoints/ai/ai.module.ts
  42. 117
      apps/api/src/app/endpoints/ai/ai.service.ts
  43. 25
      apps/api/src/app/endpoints/api-keys/api-keys.controller.ts
  44. 11
      apps/api/src/app/endpoints/api-keys/api-keys.module.ts
  45. 46
      apps/api/src/app/endpoints/assets/assets.controller.ts
  46. 11
      apps/api/src/app/endpoints/assets/assets.module.ts
  47. 44
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  48. 65
      apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
  49. 163
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  50. 15
      apps/api/src/app/endpoints/data-providers/ghostfolio/get-dividends.dto.ts
  51. 15
      apps/api/src/app/endpoints/data-providers/ghostfolio/get-historical.dto.ts
  52. 10
      apps/api/src/app/endpoints/data-providers/ghostfolio/get-quotes.dto.ts
  53. 249
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  54. 83
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts
  55. 375
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  56. 189
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  57. 19
      apps/api/src/app/endpoints/market-data/market-data.module.ts
  58. 24
      apps/api/src/app/endpoints/market-data/update-bulk-market-data.dto.ts
  59. 55
      apps/api/src/app/endpoints/public/public.controller.ts
  60. 4
      apps/api/src/app/endpoints/public/public.module.ts
  61. 52
      apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
  62. 5
      apps/api/src/app/endpoints/sitemap/sitemap.module.ts
  63. 252
      apps/api/src/app/endpoints/sitemap/sitemap.service.ts
  64. 14
      apps/api/src/app/endpoints/tags/create-tag.dto.ts
  65. 89
      apps/api/src/app/endpoints/tags/tags.controller.ts
  66. 12
      apps/api/src/app/endpoints/tags/tags.module.ts
  67. 13
      apps/api/src/app/endpoints/tags/update-tag.dto.ts
  68. 10
      apps/api/src/app/endpoints/watchlist/create-watchlist-item.dto.ts
  69. 100
      apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
  70. 27
      apps/api/src/app/endpoints/watchlist/watchlist.module.ts
  71. 155
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  72. 20
      apps/api/src/app/export/export.controller.ts
  73. 12
      apps/api/src/app/export/export.module.ts
  74. 203
      apps/api/src/app/export/export.service.ts
  75. 59
      apps/api/src/app/health/health.controller.ts
  76. 4
      apps/api/src/app/health/health.module.ts
  77. 27
      apps/api/src/app/health/health.service.ts
  78. 10
      apps/api/src/app/import/create-account-with-balances.dto.ts
  79. 17
      apps/api/src/app/import/create-asset-profile-with-market-data.dto.ts
  80. 23
      apps/api/src/app/import/import-data.dto.ts
  81. 8
      apps/api/src/app/import/import.controller.ts
  82. 4
      apps/api/src/app/import/import.module.ts
  83. 404
      apps/api/src/app/import/import.service.ts
  84. 4
      apps/api/src/app/info/info.module.ts
  85. 136
      apps/api/src/app/info/info.service.ts
  86. 15
      apps/api/src/app/logo/logo.controller.ts
  87. 29
      apps/api/src/app/logo/logo.service.ts
  88. 19
      apps/api/src/app/order/create-order.dto.ts
  89. 5
      apps/api/src/app/order/interfaces/activities.interface.ts
  90. 45
      apps/api/src/app/order/order.controller.ts
  91. 203
      apps/api/src/app/order/order.service.ts
  92. 10
      apps/api/src/app/order/update-order.dto.ts
  93. 4
      apps/api/src/app/platform/platform.service.ts
  94. 7
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  95. 13
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  96. 48
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  97. 143
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  98. 208
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  99. 26
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  100. 22
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts

1
.env.dev

@ -22,4 +22,3 @@ JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
# For more info, see: https://nx.dev/concepts/inferred-tasks # For more info, see: https://nx.dev/concepts/inferred-tasks
NX_ADD_PLUGINS=false NX_ADD_PLUGINS=false
NX_NATIVE_COMMAND_RUNNER=false

4
.env.example

@ -1,7 +1,7 @@
COMPOSE_PROJECT_NAME=ghostfolio COMPOSE_PROJECT_NAME=ghostfolio
# CACHE # CACHE
REDIS_HOST=localhost REDIS_HOST=redis
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD> REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
@ -12,5 +12,5 @@ POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS # 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}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING> JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

157
.eslintrc.json

@ -1,157 +0,0 @@
{
"root": true,
"ignorePatterns": ["**/*"],
"plugins": ["@nx"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {
"@nx/enforce-module-boundaries": [
"warn",
{
"enforceBuildableLibDependency": true,
"allow": [],
"depConstraints": [
{
"sourceTag": "*",
"onlyDependOnLibsWithTags": ["*"]
}
]
}
],
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
},
{
"files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nx/typescript"]
},
{
"files": ["*.js", "*.jsx"],
"extends": ["plugin:@nx/javascript"]
},
{
"files": ["*.ts"],
"plugins": ["eslint-plugin-import", "@typescript-eslint"],
"extends": [
"plugin:@typescript-eslint/recommended-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked"
],
"rules": {
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/explicit-member-accessibility": [
"off",
{
"accessibility": "explicit"
}
],
"@typescript-eslint/member-ordering": "warn",
"@typescript-eslint/naming-convention": [
"off",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow",
"trailingUnderscore": "allow"
},
{
"selector": ["variable", "classProperty", "typeProperty"],
"format": ["camelCase", "UPPER_CASE"],
"leadingUnderscore": "allow",
"trailingUnderscore": "allow"
},
{
"selector": "objectLiteralProperty",
"format": null
},
{
"selector": "enumMember",
"format": ["camelCase", "UPPER_CASE", "PascalCase"]
},
{
"selector": "typeLike",
"format": ["PascalCase"]
}
],
"@typescript-eslint/no-empty-interface": "warn",
"@typescript-eslint/no-inferrable-types": [
"warn",
{
"ignoreParameters": true
}
],
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-shadow": [
"warn",
{
"hoist": "all"
}
],
"@typescript-eslint/unified-signatures": "error",
"@typescript-eslint/no-loss-of-precision": "warn",
"@typescript-eslint/no-var-requires": "warn",
"@typescript-eslint/ban-types": "warn",
"arrow-body-style": "off",
"constructor-super": "error",
"eqeqeq": ["error", "smart"],
"guard-for-in": "warn",
"id-blacklist": "off",
"id-match": "off",
"import/no-deprecated": "warn",
"no-bitwise": "error",
"no-caller": "error",
"no-debugger": "error",
"no-empty": "off",
"no-eval": "error",
"no-fallthrough": "error",
"no-new-wrappers": "error",
"no-restricted-imports": ["error", "rxjs/Rx"],
"no-undef-init": "error",
"no-underscore-dangle": "off",
"no-var": "error",
"radix": "error",
"no-unsafe-optional-chaining": "warn",
"no-extra-boolean-cast": "warn",
"no-empty-pattern": "warn",
"no-useless-catch": "warn",
"no-unsafe-finally": "warn",
"no-prototype-builtins": "warn",
"no-async-promise-executor": "warn",
"no-constant-condition": "warn",
// The following rules are part of @typescript-eslint/recommended-type-checked
// and can be remove once solved
"@typescript-eslint/await-thenable": "warn",
"@typescript-eslint/ban-ts-comment": "warn",
"@typescript-eslint/no-base-to-string": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-misused-promises": "warn",
"@typescript-eslint/no-redundant-type-constituents": "warn",
"@typescript-eslint/no-unnecessary-type-assertion": "warn",
"@typescript-eslint/no-unsafe-argument": "warn",
"@typescript-eslint/no-unsafe-assignment": "warn",
"@typescript-eslint/no-unsafe-enum-comparison": "warn",
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-return": "warn",
"@typescript-eslint/no-unsafe-call": "warn",
"@typescript-eslint/require-await": "warn",
"@typescript-eslint/restrict-template-expressions": "warn",
"@typescript-eslint/unbound-method": "warn",
// The following rules are part of @typescript-eslint/stylistic-type-checked
// and can be remove once solved
"@typescript-eslint/consistent-type-definitions": "warn",
"@typescript-eslint/prefer-function-type": "warn",
"@typescript-eslint/no-empty-function": "warn",
"@typescript-eslint/prefer-nullish-coalescing": "warn", // TODO: Requires strictNullChecks: true
"@typescript-eslint/consistent-type-assertions": "warn",
"@typescript-eslint/prefer-optional-chain": "warn",
"@typescript-eslint/consistent-indexed-object-style": "warn",
"@typescript-eslint/consistent-generic-constructors": "warn"
}
}
],
"extends": ["plugin:storybook/recommended"]
}

3
.github/FUNDING.yml

@ -1 +1,2 @@
custom: ['https://www.buymeacoffee.com/ghostfolio'] buy_me_a_coffee: ghostfolio
github: ghostfolio

4
.github/ISSUE_TEMPLATE/bug_report.md

@ -26,7 +26,7 @@ Thank you for your understanding and cooperation!
2. 2.
3. 3.
**Expected behavior** **Expected Behavior**
<!-- A clear and concise description of what you expected to happen. --> <!-- A clear and concise description of what you expected to happen. -->
@ -48,6 +48,6 @@ Thank you for your understanding and cooperation!
- Browser - Browser
- OS - OS
**Additional context** **Additional Context**
<!-- Add any other context about the problem here. --> <!-- Add any other context about the problem here. -->

2
.github/workflows/build-code.yml

@ -13,7 +13,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node_version: node_version:
- 20 - 22
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4

2
.github/workflows/docker-image.yml

@ -19,7 +19,7 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: ghostfolio/ghostfolio images: ${{ vars.DOCKER_REPOSITORY || 'ghostfolio/ghostfolio' }}
tags: | tags: |
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=semver,pattern={{version}} type=semver,pattern={{version}}

40
.github/workflows/extract-locales.yml

@ -0,0 +1,40 @@
name: Extract locales
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
extract_locales:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
run: npm ci
- name: Extract locales
run: npm run extract-locales
- name: Check changes
id: verify-changed-files
uses: tj-actions/verify-changed-files@v20
- name: Create pull request
if: steps.verify-changed-files.outputs.files_changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
author: 'github-actions[bot] <github-actions[bot]@users.noreply.github.com>'
branch: 'feature/update-locales'
commit-message: 'Update locales'
delete-branch: true
title: 'Feature/update locales'
token: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore

@ -25,8 +25,10 @@ npm-debug.log
# misc # misc
/.angular/cache /.angular/cache
.cursor/rules/nx-rules.mdc
.env .env
.env.prod .env.prod
.github/instructions/nx.instructions.md
.nx/cache .nx/cache
.nx/workspace-data .nx/workspace-data
/.sass-cache /.sass-cache

2
.husky/pre-commit

@ -1,6 +1,6 @@
# Run linting and stop the commit process if any errors are found # Run linting and stop the commit process if any errors are found
# --quiet suppresses warnings (temporary until all warnings are fixed) # --quiet suppresses warnings (temporary until all warnings are fixed)
npm run lint --quiet || exit 1 npm run affected:lint --base=main --head=HEAD --parallel=2 --quiet || exit 1
# Check formatting on modified and uncommitted files, stop the commit if issues are found # Check formatting on modified and uncommitted files, stop the commit if issues are found
npm run format:check --uncommitted || exit 1 npm run format:check --uncommitted || exit 1

2
.nvmrc

@ -1 +1 @@
v20 v22

1703
CHANGELOG.md

File diff suppressed because it is too large

25
DEVELOPMENT.md

@ -5,14 +5,14 @@
### 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 `>=22.18.0`)
- 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.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
### Setup ### Setup
1. Run `npm install` 1. Run `npm 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 -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 `npm run database:setup` to initialize the database schema
1. Start the [server](#start-server) and the [client](#start-client) 1. Start the [server](#start-server) and the [client](#start-client)
1. Open https://localhost:4200/en in your browser 1. Open https://localhost:4200/en in your browser
@ -30,7 +30,13 @@ Run `npm run start:server`
### Start Client ### Start Client
Run `npm run start:client` and open https://localhost:4200/en in your browser #### English (Default)
Run `npm run start:client` and open https://localhost:4200/en in your browser.
#### Other Languages
To start the client in a different language, such as German (`de`), adapt the `start:client` script in the `package.json` file by changing `--configuration=development-en` to `--configuration=development-de`. Then, run `npm run start:client` and open https://localhost:4200/de in your browser.
### Start _Storybook_ ### Start _Storybook_
@ -60,6 +66,10 @@ Remove permission in `UserService` using `without()`
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
## Component Library (_Storybook_)
https://ghostfol.io/development/storybook
## Git ## Git
### Rebase ### Rebase
@ -101,3 +111,12 @@ https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
Run `npm run prisma migrate dev --name added_job_title` Run `npm run 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
## SSL
Generate `localhost.cert` and `localhost.pem` files.
```
openssl req -x509 -newkey rsa:2048 -nodes -keyout apps/client/localhost.pem -out apps/client/localhost.cert -days 365 \
-subj "/C=CH/ST=State/L=City/O=Organization/OU=Unit/CN=localhost"
```

34
Dockerfile

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:20-slim AS builder FROM --platform=$BUILDPLATFORM node:22-slim AS builder
# Build application and add additional files # Build application and add additional files
WORKDIR /ghostfolio WORKDIR /ghostfolio
@ -17,7 +17,8 @@ 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 ./package-lock.json package-lock.json
COPY ./prisma/schema.prisma prisma/schema.prisma COPY ./prisma.config.ts prisma.config.ts
COPY ./prisma/schema.prisma prisma/
RUN npm install RUN npm install
@ -25,32 +26,34 @@ RUN npm install
COPY ./decorate-angular-cli.js decorate-angular-cli.js COPY ./decorate-angular-cli.js decorate-angular-cli.js
RUN node decorate-angular-cli.js RUN node decorate-angular-cli.js
COPY ./nx.json nx.json COPY ./apps apps/
COPY ./replace.build.js replace.build.js COPY ./libs libs/
COPY ./jest.preset.js jest.preset.js
COPY ./jest.config.ts jest.config.ts COPY ./jest.config.ts jest.config.ts
COPY ./jest.preset.js jest.preset.js
COPY ./nx.json nx.json
COPY ./replace.build.mjs replace.build.mjs
COPY ./tsconfig.base.json tsconfig.base.json COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs
COPY ./apps apps
ENV NX_DAEMON=false
RUN npm run build:production RUN npm run 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 # package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json COPY ./package-lock.json /ghostfolio/dist/apps/api/
RUN npm install RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma COPY prisma.config.ts /ghostfolio/dist/apps/api/
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 npm run 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:22-slim
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio" LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
ENV NODE_ENV=production ENV NODE_ENV=production
@ -59,9 +62,8 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
openssl \ openssl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps/
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/
RUN chmod 0700 /ghostfolio/entrypoint.sh
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333} EXPOSE ${PORT:-3333}
USER node USER node

61
README.md

@ -7,7 +7,7 @@
**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) | [**LinkedIn**](https://www.linkedin.com/company/ghostfolio) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.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-limegreen.svg)](#contributing) [![Shield: Docker Pulls](https://img.shields.io/docker/pulls/ghostfolio/ghostfolio?label=Docker%20Pulls)](https://hub.docker.com/r/ghostfolio/ghostfolio) [![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-limegreen.svg)](#contributing) [![Shield: Docker Pulls](https://img.shields.io/docker/pulls/ghostfolio/ghostfolio?label=Docker%20Pulls)](https://hub.docker.com/r/ghostfolio/ghostfolio)
@ -25,7 +25,7 @@
## Ghostfolio Premium ## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development. Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover operational costs for the hosting infrastructure and professional data providers, and to fund ongoing development.
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section. If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
@ -47,7 +47,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: Return on Average Investment (ROAI) for `Today`, `WTD`, `MTD`, `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
@ -118,7 +118,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 -f docker/docker-compose.yml up -d
``` ```
#### b. Build and run environment #### b. Build and run environment
@ -126,8 +126,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 -f docker/docker-compose.build.yml build
docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d docker compose -f docker/docker-compose.build.yml up -d
``` ```
#### Setup #### Setup
@ -137,9 +137,18 @@ 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. Update the _Ghostfolio_ Docker image
1. Run the following command to start the new Docker image: `docker compose --env-file ./.env -f docker/docker-compose.yml up -d` - Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
At each start, the container will automatically apply the database schema migrations if needed. - Run the following command if `ghostfolio:latest` is set:
```bash
docker compose -f docker/docker-compose.yml pull
```
1. Run the following command to start the new Docker image:
```bash
docker compose -f docker/docker-compose.yml up -d
```
The container will automatically apply any required database schema migrations during startup.
### Home Server Systems (Community) ### Home Server Systems (Community)
@ -177,6 +186,12 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
`200 OK` `200 OK`
```
{
"status": "OK"
}
```
### Import Activities ### Import Activities
#### Prerequisites #### Prerequisites
@ -206,18 +221,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` \| `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` \| `LIABILITY` \| `SELL` |
| `unitPrice` | `number` | Price per unit of the activity | | `unitPrice` | `number` | Price per unit of the activity |
#### Response #### Response
@ -280,7 +295,7 @@ 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 [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). 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.
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).
@ -290,6 +305,6 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
## License ## License
© 2021 - 2024 [Ghostfolio](https://ghostfol.io) © 2021 - 2025 [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).

13
SECURITY.md

@ -0,0 +1,13 @@
# Security Policy
## Reporting Security Issues
If you discover a security vulnerability in this repository, please report it to security[at]ghostfol.io. We will acknowledge your report and provide guidance on the next steps.
To help us resolve the issue, please include the following details:
- A description of the vulnerability
- Steps to reproduce the vulnerability
- Affected versions of the software
We appreciate your responsible disclosure and will work to address the issue promptly.

22
apps/api/.eslintrc.json

@ -1,22 +0,0 @@
{
"extends": "../../.eslintrc.json",
"ignorePatterns": ["!**/*"],
"rules": {},
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"parserOptions": {
"project": ["apps/api/tsconfig.*?.json"]
},
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

31
apps/api/eslint.config.cjs

@ -0,0 +1,31 @@
const baseConfig = require('../../eslint.config.cjs');
module.exports = [
{
ignores: ['**/dist']
},
...baseConfig,
{
rules: {}
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {},
languageOptions: {
parserOptions: {
project: ['apps/api/tsconfig.*?.json']
}
}
},
{
files: ['**/*.ts', '**/*.tsx'],
// Override or add rules here
rules: {}
},
{
files: ['**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {}
}
];

71
apps/api/src/app/access/access.controller.ts

@ -14,6 +14,7 @@ import {
Inject, Inject,
Param, Param,
Post, Post,
Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
@ -23,6 +24,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto'; import { CreateAccessDto } from './create-access.dto';
import { UpdateAccessDto } from './update-access.dto';
@Controller('access') @Controller('access')
export class AccessController { export class AccessController {
@ -37,20 +39,20 @@ export class AccessController {
public async getAllAccesses(): Promise<Access[]> { public async getAllAccesses(): Promise<Access[]> {
const accessesWithGranteeUser = await this.accessService.accesses({ const accessesWithGranteeUser = await this.accessService.accesses({
include: { include: {
GranteeUser: true granteeUser: true
}, },
orderBy: { granteeUserId: 'asc' }, orderBy: [{ granteeUserId: 'desc' }, { createdAt: 'asc' }],
where: { userId: this.request.user.id } where: { userId: this.request.user.id }
}); });
return accessesWithGranteeUser.map( return accessesWithGranteeUser.map(
({ alias, GranteeUser, id, permissions }) => { ({ alias, granteeUser, id, permissions }) => {
if (GranteeUser) { if (granteeUser) {
return { return {
alias, alias,
id, id,
permissions, permissions,
grantee: GranteeUser?.id, grantee: granteeUser?.id,
type: 'PRIVATE' type: 'PRIVATE'
}; };
} }
@ -85,11 +87,11 @@ export class AccessController {
try { 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,
permissions: data.permissions, permissions: data.permissions,
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}); });
} catch { } catch {
throw new HttpException( throw new HttpException(
@ -103,9 +105,12 @@ export class AccessController {
@HasPermission(permissions.deleteAccess) @HasPermission(permissions.deleteAccess)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @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 originalAccess = await this.accessService.access({
id,
userId: this.request.user.id
});
if (!access || access.userId !== this.request.user.id) { if (!originalAccess) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -116,4 +121,52 @@ export class AccessController {
id id
}); });
} }
@HasPermission(permissions.updateAccess)
@Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateAccess(
@Body() data: UpdateAccessDto,
@Param('id') id: string
): Promise<AccessModel> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalAccess = await this.accessService.access({
id,
userId: this.request.user.id
});
if (!originalAccess) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
return this.accessService.updateAccess({
data: {
alias: data.alias,
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: { disconnect: true },
permissions: data.permissions
},
where: { id }
});
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
} }

21
apps/api/src/app/access/access.service.ts

@ -13,21 +13,21 @@ export class AccessService {
): Promise<AccessWithGranteeUser | null> { ): Promise<AccessWithGranteeUser | null> {
return this.prismaService.access.findFirst({ return this.prismaService.access.findFirst({
include: { include: {
GranteeUser: true granteeUser: true
}, },
where: accessWhereInput where: accessWhereInput
}); });
} }
public async accesses(params: { public async accesses(params: {
cursor?: Prisma.AccessWhereUniqueInput;
include?: Prisma.AccessInclude; include?: Prisma.AccessInclude;
orderBy?: Prisma.Enumerable<Prisma.AccessOrderByWithRelationInput>;
skip?: number; skip?: number;
take?: number; take?: number;
cursor?: Prisma.AccessWhereUniqueInput;
where?: Prisma.AccessWhereInput; where?: Prisma.AccessWhereInput;
orderBy?: Prisma.AccessOrderByWithRelationInput;
}): Promise<AccessWithGranteeUser[]> { }): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { cursor, include, orderBy, skip, take, where } = params;
return this.prismaService.access.findMany({ return this.prismaService.access.findMany({
cursor, cursor,
@ -52,4 +52,17 @@ export class AccessService {
where where
}); });
} }
public async updateAccess({
data,
where
}: {
data: Prisma.AccessUpdateInput;
where: Prisma.AccessWhereUniqueInput;
}): Promise<Access> {
return this.prismaService.access.update({
data,
where
});
}
} }

19
apps/api/src/app/access/update-access.dto.ts

@ -0,0 +1,19 @@
import { AccessPermission } from '@prisma/client';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateAccessDto {
@IsOptional()
@IsString()
alias?: string;
@IsOptional()
@IsUUID()
granteeUserId?: string;
@IsString()
id: string;
@IsEnum(AccessPermission, { each: true })
@IsOptional()
permissions?: AccessPermission[];
}

14
apps/api/src/app/account-balance/account-balance.service.ts

@ -30,7 +30,7 @@ export class AccountBalanceService {
): Promise<AccountBalance | null> { ): Promise<AccountBalance | null> {
return this.prismaService.accountBalance.findFirst({ return this.prismaService.accountBalance.findFirst({
include: { include: {
Account: true account: true
}, },
where: accountBalanceWhereInput where: accountBalanceWhereInput
}); });
@ -46,7 +46,7 @@ export class AccountBalanceService {
}): Promise<AccountBalance> { }): Promise<AccountBalance> {
const accountBalance = await this.prismaService.accountBalance.upsert({ const accountBalance = await this.prismaService.accountBalance.upsert({
create: { create: {
Account: { account: {
connect: { connect: {
id_userId: { id_userId: {
userId, userId,
@ -88,7 +88,7 @@ export class AccountBalanceService {
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({
userId: <string>where.userId userId: where.userId as string
}) })
); );
@ -154,7 +154,7 @@ export class AccountBalanceService {
} }
if (withExcludedAccounts === false) { if (withExcludedAccounts === false) {
where.Account = { isExcluded: false }; where.account = { isExcluded: false };
} }
const balances = await this.prismaService.accountBalance.findMany({ const balances = await this.prismaService.accountBalance.findMany({
@ -163,7 +163,7 @@ export class AccountBalanceService {
date: 'asc' date: 'asc'
}, },
select: { select: {
Account: true, account: true,
date: true, date: true,
id: true, id: true,
value: true value: true
@ -174,10 +174,10 @@ export class AccountBalanceService {
balances: balances.map((balance) => { balances: balances.map((balance) => {
return { return {
...balance, ...balance,
accountId: balance.Account.id, accountId: balance.account.id,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value, balance.value,
balance.Account.currency, balance.account.currency,
userCurrency userCurrency
) )
}; };

32
apps/api/src/app/account/account.controller.ts

@ -9,7 +9,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation/imp
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {
AccountBalancesResponse, AccountBalancesResponse,
Accounts AccountsResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type {
@ -57,17 +57,17 @@ export class AccountController {
@HasPermission(permissions.deleteAccount) @HasPermission(permissions.deleteAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> { public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
const account = await this.accountService.accountWithOrders( const account = await this.accountService.accountWithActivities(
{ {
id_userId: { id_userId: {
id, id,
userId: this.request.user.id userId: this.request.user.id
} }
}, },
{ Order: true } { activities: true }
); );
if (!account || account?.Order.length > 0) { if (!account || account?.activities.length > 0) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -87,15 +87,17 @@ export class AccountController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts( public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('query') filterBySearchQuery?: string,
@Query('symbol') filterBySymbol?: string @Query('symbol') filterBySymbol?: string
): Promise<Accounts> { ): Promise<AccountsResponse> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByDataSource, filterByDataSource,
filterBySearchQuery,
filterBySymbol filterBySymbol
}); });
@ -110,7 +112,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById( public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string @Param('id') id: string
): Promise<AccountWithValue> { ): Promise<AccountWithValue> {
const impersonationUserId = const impersonationUserId =
@ -134,7 +136,7 @@ export class AccountController {
): Promise<AccountBalancesResponse> { ): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({ return this.accountBalanceService.getAccountBalances({
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
userCurrency: this.request.user.Settings.settings.baseCurrency, userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@ -152,8 +154,8 @@ export class AccountController {
return this.accountService.createAccount( return this.accountService.createAccount(
{ {
...data, ...data,
Platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id
); );
@ -163,7 +165,7 @@ export class AccountController {
return this.accountService.createAccount( return this.accountService.createAccount(
{ {
...data, ...data,
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id
); );
@ -250,8 +252,8 @@ export class AccountController {
{ {
data: { data: {
...data, ...data,
Platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id_userId: { id_userId: {
@ -270,10 +272,10 @@ export class AccountController {
{ {
data: { data: {
...data, ...data,
Platform: originalAccount.platformId platform: originalAccount.platformId
? { disconnect: true } ? { disconnect: true }
: undefined, : undefined,
User: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id_userId: { id_userId: {

44
apps/api/src/app/account/account.service.ts

@ -7,7 +7,14 @@ import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Account, Order, Platform, Prisma } from '@prisma/client'; import {
Account,
AccountBalance,
Order,
Platform,
Prisma,
SymbolProfile
} from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@ -33,12 +40,12 @@ export class AccountService {
return account; return account;
} }
public async accountWithOrders( public async accountWithActivities(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput, accountWhereUniqueInput: Prisma.AccountWhereUniqueInput,
accountInclude: Prisma.AccountInclude accountInclude: Prisma.AccountInclude
): Promise< ): Promise<
Account & { Account & {
Order?: Order[]; activities?: Order[];
} }
> { > {
return this.prismaService.account.findUnique({ return this.prismaService.account.findUnique({
@ -56,13 +63,19 @@ export class AccountService {
orderBy?: Prisma.AccountOrderByWithRelationInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(Account & { (Account & {
Order?: Order[]; activities?: (Order & { SymbolProfile?: SymbolProfile })[];
Platform?: Platform; balances?: AccountBalance[];
platform?: Platform;
})[] })[]
> { > {
const { include = {}, skip, take, cursor, where, orderBy } = params; const { include = {}, skip, take, cursor, where, orderBy } = params;
include.balances = { orderBy: { date: 'desc' }, take: 1 }; const isBalancesIncluded = !!include.balances;
include.balances = {
orderBy: { date: 'desc' },
...(isBalancesIncluded ? {} : { take: 1 })
};
const accounts = await this.prismaService.account.findMany({ const accounts = await this.prismaService.account.findMany({
cursor, cursor,
@ -76,7 +89,9 @@ export class AccountService {
return accounts.map((account) => { return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 }; account = { ...account, balance: account.balances[0]?.value ?? 0 };
delete account.balances; if (!isBalancesIncluded) {
delete account.balances;
}
return account; return account;
}); });
@ -126,7 +141,10 @@ export class AccountService {
public async getAccounts(aUserId: string): Promise<Account[]> { public async getAccounts(aUserId: string): Promise<Account[]> {
const accounts = await this.accounts({ const accounts = await this.accounts({
include: { Order: true, Platform: true }, include: {
activities: true,
platform: true
},
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
where: { userId: aUserId } where: { userId: aUserId }
}); });
@ -134,15 +152,15 @@ export class AccountService {
return accounts.map((account) => { return accounts.map((account) => {
let transactionCount = 0; let transactionCount = 0;
for (const order of account.Order) { for (const { isDraft } of account.activities) {
if (!order.isDraft) { if (!isDraft) {
transactionCount += 1; transactionCount += 1;
} }
} }
const result = { ...account, transactionCount }; const result = { ...account, transactionCount };
delete result.Order; delete result.activities;
return result; return result;
}); });
@ -209,8 +227,8 @@ export class AccountService {
const { data, where } = params; const { data, where } = params;
await this.accountBalanceService.createOrUpdateAccountBalance({ await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: <string>data.id, accountId: data.id as string,
balance: <number>data.balance, balance: data.balance as number,
date: format(new Date(), DATE_FORMAT), date: format(new Date(), DATE_FORMAT),
userId: aUserId userId: aUserId
}); });

144
apps/api/src/app/admin/admin.controller.ts

@ -3,25 +3,27 @@ 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 { 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 { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { DemoService } from '@ghostfolio/api/services/demo/demo.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails,
AdminUsers, AdminUsers,
EnhancedSymbolProfile EnhancedSymbolProfile,
ScraperConfiguration
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type {
DateRange,
MarketDataPreset, MarketDataPreset,
RequestWithUser RequestWithUser
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
@ -50,8 +52,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto'; import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
@ -59,8 +59,8 @@ 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 demoService: DemoService,
private readonly manualService: ManualService, private readonly manualService: ManualService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -71,6 +71,13 @@ export class AdminController {
return this.adminService.get(); return this.adminService.get();
} }
@Get('demo-user/sync')
@HasPermission(permissions.syncDemoUserAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async syncDemoUserAccount(): Promise<Prisma.BatchPayload> {
return this.demoService.syncDemoUserAccount();
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('gather') @Post('gather')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -83,7 +90,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
const assetProfileIdentifiers = const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -92,9 +99,9 @@ export class AdminController {
dataSource, dataSource,
symbol symbol
}, },
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }), jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
} }
@ -110,7 +117,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> { public async gatherProfileData(): Promise<void> {
const assetProfileIdentifiers = const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers(); await this.dataGatheringService.getAllActiveAssetProfileIdentifiers();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
assetProfileIdentifiers.map(({ dataSource, symbol }) => { assetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -119,9 +126,9 @@ export class AdminController {
dataSource, dataSource,
symbol symbol
}, },
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }), jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
} }
@ -142,9 +149,9 @@ export class AdminController {
dataSource, dataSource,
symbol symbol
}, },
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }), jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
} }
@ -156,9 +163,21 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl) @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,
@Query('range') dateRange: DateRange
): Promise<void> { ): Promise<void> {
this.dataGatheringService.gatherSymbol({ dataSource, symbol }); let date: Date;
if (dateRange) {
const { startDate } = getIntervalFromDateRange(dateRange);
date = startDate;
}
this.dataGatheringService.gatherSymbol({
dataSource,
date,
symbol
});
return; return;
} }
@ -192,6 +211,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketData( public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string, @Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('presetId') presetId?: MarketDataPreset, @Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string, @Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@ -201,6 +221,7 @@ export class AdminController {
): Promise<AdminMarketData> { ): Promise<AdminMarketData> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses, filterByAssetSubClasses,
filterByDataSource,
filterBySearchQuery filterBySearchQuery
}); });
@ -214,27 +235,16 @@ export class AdminController {
}); });
} }
@Get('market-data/:dataSource/:symbol')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol/test') @Post('market-data/:dataSource/:symbol/test')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async testMarketData( public async testMarketData(
@Body() data: { scraperConfiguration: string }, @Body() data: { scraperConfiguration: ScraperConfiguration },
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<{ price: number }> { ): Promise<{ price: number }> {
try { try {
const scraperConfiguration = JSON.parse(data.scraperConfiguration); const price = await this.manualService.test(data.scraperConfiguration);
const price = await this.manualService.test(scraperConfiguration);
if (price) { if (price) {
return { price }; return { price };
@ -250,55 +260,6 @@ export class AdminController {
} }
} }
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({
dataSource,
marketPrice,
symbol,
date: parseISO(date),
state: 'CLOSE'
})
);
return this.marketDataService.updateMany({
data: dataBulkUpdate
});
}
/**
* @deprecated
*/
@HasPermission(permissions.accessAdminControl)
@Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async update(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string,
@Body() data: UpdateMarketDataDto
) {
const date = parseISO(dateString);
return this.marketDataService.updateMarketData({
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
where: {
dataSource_date_symbol: {
dataSource,
date,
symbol
}
}
});
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('profile-data/:dataSource/:symbol') @Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -310,7 +271,7 @@ export class AdminController {
return this.adminService.addAssetProfile({ return this.adminService.addAssetProfile({
dataSource, dataSource,
symbol, symbol,
currency: this.request.user.Settings.settings.baseCurrency currency: this.request.user.settings.settings.baseCurrency
}); });
} }
@ -328,15 +289,14 @@ export class AdminController {
@Patch('profile-data/:dataSource/:symbol') @Patch('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async patchAssetProfileData( public async patchAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDto, @Body() assetProfile: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> { ): Promise<EnhancedSymbolProfile> {
return this.adminService.patchAssetProfileData({ return this.adminService.patchAssetProfileData(
...assetProfileData, { dataSource, symbol },
dataSource, assetProfile
symbol );
});
} }
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@ -352,7 +312,13 @@ export class AdminController {
@Get('user') @Get('user')
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUsers(): Promise<AdminUsers> { public async getUsers(
return this.adminService.getUsers(); @Query('skip') skip?: number,
@Query('take') take?: number
): Promise<AdminUsers> {
return this.adminService.getUsers({
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
} }
} }

6
apps/api/src/app/admin/admin.module.ts

@ -1,10 +1,10 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.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 { 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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { DemoModule } from '@ghostfolio/api/services/demo/demo.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 { 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';
@ -25,13 +25,13 @@ import { QueueModule } from './queue/queue.module';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
DemoModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
QueueModule, QueueModule,
SubscriptionModule,
SymbolProfileModule, SymbolProfileModule,
TransformDataSourceInRequestModule TransformDataSourceInRequestModule
], ],

480
apps/api/src/app/admin/admin.service.ts

@ -1,7 +1,6 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -10,7 +9,6 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_CURRENCY,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
@ -30,9 +28,15 @@ import {
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import {
BadRequestException,
HttpException,
Injectable,
Logger
} from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -43,6 +47,7 @@ import {
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@Injectable() @Injectable()
@ -56,7 +61,6 @@ export class AdminService {
private readonly orderService: OrderService, 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 symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -108,43 +112,75 @@ export class AdminService {
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol });
const currency = getCurrencyFromSymbol(symbol);
const customCurrencies =
await this.propertyService.getByKey<string[]>(PROPERTY_CURRENCIES);
if (customCurrencies.includes(currency)) {
const updatedCustomCurrencies = customCurrencies.filter(
(customCurrency) => {
return customCurrency !== currency;
}
);
await this.putSetting(
PROPERTY_CURRENCIES,
JSON.stringify(updatedCustomCurrencies)
);
} else {
await this.symbolProfileService.delete({ dataSource, symbol });
}
} }
public async get(): Promise<AdminData> { public async get(): Promise<AdminData> {
const exchangeRates = this.exchangeRateDataService const dataSources = Object.values(DataSource);
.getCurrencies()
.filter((currency) => { const [enabledDataSources, settings, transactionCount, userCount] =
return currency !== DEFAULT_CURRENCY; await Promise.all([
}) this.dataProviderService.getDataSources(),
.map((currency) => { this.propertyService.get(),
const label1 = DEFAULT_CURRENCY; this.prismaService.order.count(),
const label2 = currency; this.countUsersWithAnalytics()
]);
return { const dataProviders = (
label1, await Promise.all(
label2, dataSources.map(async (dataSource) => {
dataSource: const assetProfileCount =
DataSource[ await this.prismaService.symbolProfile.count({
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES') where: {
], dataSource
symbol: `${label1}${label2}`, }
value: this.exchangeRateDataService.toCurrency( });
1,
DEFAULT_CURRENCY,
currency
)
};
});
const [settings, transactionCount, userCount] = await Promise.all([ const isEnabled = enabledDataSources.includes(dataSource);
this.propertyService.get(),
this.prismaService.order.count(), if (
this.prismaService.user.count() assetProfileCount > 0 ||
]); dataSource === 'GHOSTFOLIO' ||
isEnabled
) {
const dataProviderInfo = this.dataProviderService
.getDataProvider(dataSource)
.getDataProviderInfo();
return {
...dataProviderInfo,
assetProfileCount,
useForExchangeRates:
dataSource ===
this.dataProviderService.getDataSourceForExchangeRates()
};
}
return null;
})
)
).filter(Boolean);
return { return {
exchangeRates, dataProviders,
settings, settings,
transactionCount, transactionCount,
userCount, userCount,
@ -156,7 +192,7 @@ export class AdminService {
filters, filters,
presetId, presetId,
sortColumn, sortColumn,
sortDirection, sortDirection = 'asc',
skip, skip,
take = Number.MAX_SAFE_INTEGER take = Number.MAX_SAFE_INTEGER
}: { }: {
@ -193,12 +229,12 @@ export class AdminService {
return type === 'SEARCH_QUERY'; return type === 'SEARCH_QUERY';
})?.id; })?.id;
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy( const {
filters, ASSET_SUB_CLASS: filtersByAssetSubClass,
({ type }) => { DATA_SOURCE: filtersByDataSource
return type; } = groupBy(filters, ({ type }) => {
} return type;
); });
const marketDataItems = await this.prismaService.marketData.groupBy({ const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true, _count: true,
@ -209,6 +245,10 @@ export class AdminService {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
} }
if (filtersByDataSource) {
where.dataSource = DataSource[filtersByDataSource[0].id];
}
if (searchQuery) { if (searchQuery) {
where.OR = [ where.OR = [
{ id: { mode: 'insensitive', startsWith: searchQuery } }, { id: { mode: 'insensitive', startsWith: searchQuery } },
@ -222,11 +262,13 @@ export class AdminService {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') { if (sortColumn === 'activitiesCount') {
orderBy = { orderBy = [
Order: { {
_count: sortDirection activities: {
_count: sortDirection
}
} }
}; ];
} }
} }
@ -235,13 +277,21 @@ export class AdminService {
try { try {
const symbolProfileResult = await Promise.all([ const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({ extendedPrismaClient.symbolProfile.findMany({
orderBy,
skip, skip,
take, take,
where, where,
orderBy: [...orderBy, { id: sortDirection }],
select: { select: {
_count: { _count: {
select: { Order: true } select: {
activities: true,
watchedBy: true
}
},
activities: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
}, },
assetClass: true, assetClass: true,
assetSubClass: true, assetSubClass: true,
@ -250,16 +300,13 @@ export class AdminService {
currency: true, currency: true,
dataSource: true, dataSource: true,
id: true, id: true,
isActive: true,
isUsedByUsersWithSubscription: true, isUsedByUsersWithSubscription: true,
name: true, name: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true, scraperConfiguration: true,
sectors: true, sectors: true,
symbol: true symbol: true,
SymbolProfileOverrides: true
} }
}), }),
this.prismaService.symbolProfile.count({ where }) this.prismaService.symbolProfile.count({ where })
@ -302,6 +349,7 @@ export class AdminService {
assetProfiles.map( assetProfiles.map(
async ({ async ({
_count, _count,
activities,
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
@ -309,15 +357,14 @@ export class AdminService {
currency, currency,
dataSource, dataSource,
id, id,
isActive,
isUsedByUsersWithSubscription, isUsedByUsersWithSubscription,
name, name,
Order,
sectors, sectors,
symbol symbol,
SymbolProfileOverrides
}) => { }) => {
const countriesCount = countries let countriesCount = countries ? Object.keys(countries).length : 0;
? Object.keys(countries).length
: 0;
const lastMarketPrice = lastMarketPriceMap.get( const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol }) getAssetProfileIdentifier({ dataSource, symbol })
@ -331,7 +378,34 @@ export class AdminService {
); );
})?._count ?? 0; })?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0; let sectorsCount = sectors ? Object.keys(sectors).length : 0;
if (SymbolProfileOverrides) {
assetClass = SymbolProfileOverrides.assetClass ?? assetClass;
assetSubClass =
SymbolProfileOverrides.assetSubClass ?? assetSubClass;
if (
(
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
)?.length > 0
) {
countriesCount = (
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
).length;
}
name = SymbolProfileOverrides.name ?? name;
if (
(SymbolProfileOverrides.sectors as unknown as Sector[])
?.length > 0
) {
sectorsCount = (
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
).length;
}
}
return { return {
assetClass, assetClass,
@ -341,14 +415,17 @@ export class AdminService {
countriesCount, countriesCount,
dataSource, dataSource,
id, id,
isActive,
lastMarketPrice, lastMarketPrice,
name, name,
symbol, symbol,
marketDataItemCount, marketDataItemCount,
sectorsCount, sectorsCount,
activitiesCount: _count.Order, activitiesCount: _count.activities,
date: Order?.[0]?.date, date: activities?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription isUsedByUsersWithSubscription:
await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy
}; };
} }
) )
@ -424,70 +501,147 @@ export class AdminService {
currency, currency,
dataSource, dataSource,
dateOfFirstActivity, dateOfFirstActivity,
symbol symbol,
isActive: true
} }
}; };
} }
public async getUsers(): Promise<AdminUsers> { public async getUsers({
return { users: await this.getUsersWithAnalytics() }; skip,
} take = Number.MAX_SAFE_INTEGER
}: {
skip?: number;
take?: number;
}): Promise<AdminUsers> {
const [count, users] = await Promise.all([
this.countUsersWithAnalytics(),
this.getUsersWithAnalytics({ skip, take })
]);
public async patchAssetProfileData({ return { count, users };
assetClass, }
assetSubClass,
comment,
countries,
currency,
dataSource,
holdings,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
const symbolProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
name: name as string,
url: url as string
};
const updatedSymbolProfile: AssetProfileIdentifier & public async patchAssetProfileData(
Prisma.SymbolProfileUpdateInput = { { dataSource, symbol }: AssetProfileIdentifier,
{
assetClass,
assetSubClass,
comment, comment,
countries, countries,
currency, currency,
dataSource, dataSource: newDataSource,
holdings, holdings,
isActive,
name,
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbol, symbol: newSymbol,
symbolMapping, symbolMapping,
...(dataSource === 'MANUAL' url
? { assetClass, assetSubClass, name, url } }: Prisma.SymbolProfileUpdateInput
: { ) {
SymbolProfileOverrides: { if (
upsert: { newSymbol &&
create: symbolProfileOverrides, newDataSource &&
update: symbolProfileOverrides (newSymbol !== symbol || newDataSource !== dataSource)
} ) {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
]);
if (assetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.CONFLICT),
StatusCodes.CONFLICT
);
}
try {
Promise.all([
await this.symbolProfileService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
} }
}) ),
}; await this.marketDataService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
)
]);
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile); return this.symbolProfileService.getSymbolProfiles([
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
])?.[0];
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
} else {
const symbolProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
name: name as string,
url: url as string
};
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([ const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
{ comment,
countries,
currency,
dataSource, dataSource,
symbol holdings,
} isActive,
]); scraperConfiguration,
sectors,
symbol,
symbolMapping,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
}
})
};
return symbolProfile; await this.symbolProfileService.updateSymbolProfile(
{
dataSource,
symbol
},
updatedSymbolProfile
);
return this.symbolProfileService.getSymbolProfiles([
{
dataSource: dataSource as DataSource,
symbol: symbol as string
}
])?.[0];
}
} }
public async putSetting(key: string, value: string) { public async putSetting(key: string, value: string) {
@ -508,6 +662,22 @@ export class AdminService {
return response; return response;
} }
private async countUsersWithAnalytics() {
let where: Prisma.UserWhereInput;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
where = {
NOT: {
analytics: null
}
};
}
return this.prismaService.user.count({
where
});
}
private getExtendedPrismaClient() { private getExtendedPrismaClient() {
Logger.debug('Connect extended prisma client', 'AdminService'); Logger.debug('Connect extended prisma client', 'AdminService');
@ -522,10 +692,10 @@ export class AdminService {
select: { select: {
_count: { _count: {
select: { select: {
Order: { activities: {
where: { where: {
User: { user: {
Subscription: { subscriptions: {
some: { some: {
expiresAt: { expiresAt: {
gt: new Date() gt: new Date()
@ -543,7 +713,7 @@ export class AdminService {
} }
}); });
return _count.Order > 0; return _count.activities > 0;
} }
} }
} }
@ -630,8 +800,10 @@ export class AdminService {
countriesCount: 0, countriesCount: 0,
date: dateOfFirstActivity, date: dateOfFirstActivity,
id: undefined, id: undefined,
isActive: true,
name: symbol, name: symbol,
sectorsCount: 0 sectorsCount: 0,
watchedByCount: 0
}; };
} }
); );
@ -640,63 +812,82 @@ export class AdminService {
return { marketData, count: marketData.length }; return { marketData, count: marketData.length };
} }
private async getUsersWithAnalytics(): Promise<AdminUsers['users']> { private async getUsersWithAnalytics({
let orderBy: any = { skip,
createdAt: 'desc' take
}; }: {
skip?: number;
take?: number;
}): Promise<AdminUsers['users']> {
let orderBy: Prisma.Enumerable<Prisma.UserOrderByWithRelationInput> = [
{ createdAt: 'desc' }
];
let where: Prisma.UserWhereInput; let where: Prisma.UserWhereInput;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = { orderBy = [
Analytics: { {
updatedAt: 'desc' analytics: {
lastRequestAt: 'desc'
}
} }
}; ];
where = { where = {
NOT: { NOT: {
Analytics: null analytics: null
} }
}; };
} }
const usersWithAnalytics = await this.prismaService.user.findMany({ const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy, skip,
take,
where, where,
orderBy: [...orderBy, { id: 'desc' }],
select: { select: {
_count: { _count: {
select: { Account: true, Order: true } select: { accounts: true, activities: true }
}, },
Analytics: { analytics: {
select: { select: {
activityCount: true, activityCount: true,
country: true, country: true,
dataProviderGhostfolioDailyRequests: true,
updatedAt: true updatedAt: true
} }
}, },
createdAt: true, createdAt: true,
id: true, id: true,
role: true, role: true,
Subscription: true subscriptions: {
}, orderBy: {
take: 30 expiresAt: 'desc'
},
take: 1,
where: {
expiresAt: {
gt: new Date()
}
}
}
}
}); });
return usersWithAnalytics.map( return usersWithAnalytics.map(
({ _count, Analytics, createdAt, id, role, Subscription }) => { ({ _count, analytics, createdAt, id, role, subscriptions }) => {
const daysSinceRegistration = const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1; differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics const engagement = analytics
? Analytics.activityCount / daysSinceRegistration ? analytics.activityCount / daysSinceRegistration
: undefined; : undefined;
const subscription = this.configurationService.get( const subscription =
'ENABLE_FEATURE_SUBSCRIPTION' this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
) subscriptions?.length > 0
? this.subscriptionService.getSubscription({ ? subscriptions[0]
createdAt, : undefined;
subscriptions: Subscription
})
: undefined;
return { return {
createdAt, createdAt,
@ -704,10 +895,11 @@ export class AdminService {
id, id,
role, role,
subscription, subscription,
accountCount: _count.Account || 0, accountCount: _count.accounts || 0,
country: Analytics?.country, activityCount: _count.activities || 0,
lastActivity: Analytics?.updatedAt, country: analytics?.country,
transactionCount: _count.Order || 0 dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0,
lastActivity: analytics?.updatedAt
}; };
} }
); );

92
apps/api/src/app/admin/create-asset-profile.dto.ts

@ -0,0 +1,92 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import {
IsArray,
IsBoolean,
IsEnum,
IsObject,
IsOptional,
IsString,
IsUrl
} from 'class-validator';
export class CreateAssetProfileDto {
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsOptional()
@IsString()
comment?: string;
@IsArray()
@IsOptional()
countries?: Prisma.InputJsonArray;
@IsCurrencyCode()
currency: string;
@IsOptional()
@IsString()
cusip?: string;
@IsEnum(DataSource)
dataSource: DataSource;
@IsOptional()
@IsString()
figi?: string;
@IsOptional()
@IsString()
figiComposite?: string;
@IsOptional()
@IsString()
figiShareClass?: string;
@IsArray()
@IsOptional()
holdings?: Prisma.InputJsonArray;
@IsBoolean()
@IsOptional()
isActive?: boolean;
@IsOptional()
@IsString()
isin?: string;
@IsOptional()
@IsString()
name?: string;
@IsObject()
@IsOptional()
scraperConfiguration?: Prisma.InputJsonObject;
@IsArray()
@IsOptional()
sectors?: Prisma.InputJsonArray;
@IsString()
symbol: string;
@IsObject()
@IsOptional()
symbolMapping?: {
[dataProvider: string]: string;
};
@IsOptional()
@IsUrl({
protocols: ['https'],
require_protocol: true
})
url?: string;
}

4
apps/api/src/app/admin/queue/queue.controller.ts

@ -26,7 +26,7 @@ export class QueueController {
public async deleteJobs( public async deleteJobs(
@Query('status') filterByStatus?: string @Query('status') filterByStatus?: string
): Promise<void> { ): Promise<void> {
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined; const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
return this.queueService.deleteJobs({ status }); return this.queueService.deleteJobs({ status });
} }
@ -36,7 +36,7 @@ export class QueueController {
public async getJobs( public async getJobs(
@Query('status') filterByStatus?: string @Query('status') filterByStatus?: string
): Promise<AdminJobs> { ): Promise<AdminJobs> {
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined; const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
return this.queueService.getJobs({ status }); return this.queueService.getJobs({ status });
} }

2
apps/api/src/app/admin/queue/queue.service.ts

@ -71,7 +71,7 @@ export class QueueService {
.slice(0, limit) .slice(0, limit)
.map(async (job) => { .map(async (job) => {
return { return {
attemptsMade: job.attemptsMade + 1, attemptsMade: job.attemptsMade,
data: job.data, data: job.data,
finishedOn: job.finishedOn, finishedOn: job.finishedOn,
id: job.id, id: job.id,

19
apps/api/src/app/admin/update-asset-profile.dto.ts

@ -1,8 +1,9 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client'; import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { import {
IsArray, IsArray,
IsBoolean,
IsEnum, IsEnum,
IsObject, IsObject,
IsOptional, IsOptional,
@ -19,8 +20,8 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
@IsString()
@IsOptional() @IsOptional()
@IsString()
comment?: string; comment?: string;
@IsArray() @IsArray()
@ -31,8 +32,16 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
currency?: string; currency?: string;
@IsString() @IsEnum(DataSource)
@IsOptional() @IsOptional()
dataSource?: DataSource;
@IsBoolean()
@IsOptional()
isActive?: boolean;
@IsOptional()
@IsString()
name?: string; name?: string;
@IsObject() @IsObject()
@ -43,6 +52,10 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
sectors?: Prisma.InputJsonArray; sectors?: Prisma.InputJsonArray;
@IsOptional()
@IsString()
symbol?: string;
@IsObject() @IsObject()
@IsOptional() @IsOptional()
symbolMapping?: { symbolMapping?: {

54
apps/api/src/app/app.module.ts

@ -1,26 +1,27 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { EventsModule } from '@ghostfolio/api/events/events.module';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
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 { CronModule } from '@ghostfolio/api/services/cron/cron.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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
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 { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.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 { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter'; 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 { join } from 'node: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';
@ -29,9 +30,17 @@ import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module'; 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 { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { AssetsModule } from './endpoints/assets/assets.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PublicModule } from './endpoints/public/public.module'; import { PublicModule } from './endpoints/public/public.module';
import { SitemapModule } from './endpoints/sitemap/sitemap.module';
import { TagsModule } from './endpoints/tags/tags.module';
import { WatchlistModule } from './endpoints/watchlist/watchlist.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
@ -42,10 +51,8 @@ import { OrderModule } from './order/order.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module'; import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@Module({ @Module({
@ -54,21 +61,26 @@ import { UserModule } from './user/user.module';
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
AiModule,
ApiKeysModule,
AssetModule, AssetModule,
AssetsModule,
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarkModule, BenchmarksModule,
BullModule.forRoot({ BullModule.forRoot({
redis: { redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10), db: parseInt(process.env.REDIS_DB ?? '0', 10),
family: 0,
host: process.env.REDIS_HOST, host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10), password: process.env.REDIS_PASSWORD,
password: process.env.REDIS_PASSWORD port: parseInt(process.env.REDIS_PORT ?? '6379', 10)
} }
}), }),
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
ConfigurationModule, ConfigurationModule,
CronModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
@ -76,10 +88,12 @@ import { UserModule } from './user/user.module';
ExchangeRateModule, ExchangeRateModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ExportModule, ExportModule,
GhostfolioModule,
HealthModule, HealthModule,
ImportModule, ImportModule,
InfoModule, InfoModule,
LogoModule, LogoModule,
MarketDataModule,
OrderModule, OrderModule,
PlatformModule, PlatformModule,
PortfolioModule, PortfolioModule,
@ -90,7 +104,7 @@ import { UserModule } from './user/user.module';
RedisCacheModule, RedisCacheModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
exclude: ['/api*', '/sitemap.xml'], exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'],
rootPath: join(__dirname, '..', 'client'), rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: { serveStaticOptions: {
setHeaders: (res) => { setHeaders: (res) => {
@ -113,13 +127,21 @@ import { UserModule } from './user/user.module';
} }
} }
}), }),
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'client', '.well-known'),
serveRoot: '/.well-known'
}),
SitemapModule, SitemapModule,
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TagModule, TagsModule,
TwitterBotModule, UserModule,
UserModule WatchlistModule
], ],
providers: [CronService] providers: [I18nService]
}) })
export class AppModule {} export class AppModule implements NestModule {
public configure(consumer: MiddlewareConsumer) {
consumer.apply(HtmlTemplateMiddleware).forRoutes('*wildcard');
}
}

70
apps/api/src/app/auth/api-key.strategy.ts

@ -0,0 +1,70 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
@Injectable()
export class ApiKeyStrategy extends PassportStrategy(
HeaderAPIKeyStrategy,
'api-key'
) {
public constructor(
private readonly apiKeyService: ApiKeyService,
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly userService: UserService
) {
super({ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, false);
}
public async validate(apiKey: string) {
const user = await this.validateApiKey(apiKey);
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
await this.prismaService.analytics.upsert({
create: { user: { connect: { id: user.id } } },
update: {
activityCount: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId: user.id }
});
}
return user;
}
private async validateApiKey(apiKey: string) {
if (!apiKey) {
throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED
);
}
try {
const { id } = await this.apiKeyService.getUserByApiKey(apiKey);
return this.userService.user({ id });
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED
);
}
}
}

16
apps/api/src/app/auth/auth.controller.ts

@ -85,7 +85,7 @@ export class AuthController {
@Res() response: Response @Res() response: Response
) { ) {
// Handles the Google OAuth2 callback // Handles the Google OAuth2 callback
const jwt: string = (<any>request.user).jwt; const jwt: string = (request.user as any).jwt;
if (jwt) { if (jwt) {
response.redirect( response.redirect(
@ -133,17 +133,19 @@ export class AuthController {
return this.webAuthService.verifyAttestation(body.credential); return this.webAuthService.verifyAttestation(body.credential);
} }
@Post('webauthn/generate-assertion-options') @Post('webauthn/generate-authentication-options')
public async generateAssertionOptions(@Body() body: { deviceId: string }) { public async generateAuthenticationOptions(
return this.webAuthService.generateAssertionOptions(body.deviceId); @Body() body: { deviceId: string }
) {
return this.webAuthService.generateAuthenticationOptions(body.deviceId);
} }
@Post('webauthn/verify-assertion') @Post('webauthn/verify-authentication')
public async verifyAssertion( public async verifyAuthentication(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON } @Body() body: { deviceId: string; credential: AssertionCredentialJSON }
) { ) {
try { try {
const authToken = await this.webAuthService.verifyAssertion( const authToken = await this.webAuthService.verifyAuthentication(
body.deviceId, body.deviceId,
body.credential body.credential
); );

4
apps/api/src/app/auth/auth.module.ts

@ -2,6 +2,7 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
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';
@ -9,6 +10,7 @@ import { PropertyModule } from '@ghostfolio/api/services/property/property.modul
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { ApiKeyStrategy } from './api-key.strategy';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy'; import { GoogleStrategy } from './google.strategy';
@ -28,6 +30,8 @@ import { JwtStrategy } from './jwt.strategy';
UserModule UserModule
], ],
providers: [ providers: [
ApiKeyService,
ApiKeyStrategy,
AuthDeviceService, AuthDeviceService,
AuthService, AuthService,
GoogleStrategy, GoogleStrategy,

8
apps/api/src/app/auth/auth.service.ts

@ -20,10 +20,10 @@ export class AuthService {
public async validateAnonymousLogin(accessToken: string): Promise<string> { public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
try { try {
const hashedAccessToken = this.userService.createAccessToken( const hashedAccessToken = this.userService.createAccessToken({
accessToken, password: accessToken,
this.configurationService.get('ACCESS_TOKEN_SALT') salt: this.configurationService.get('ACCESS_TOKEN_SALT')
); });
const [user] = await this.userService.users({ const [user] = await this.userService.users({
where: { accessToken: hashedAccessToken } where: { accessToken: hashedAccessToken }

4
apps/api/src/app/auth/interfaces/simplewebauthn.ts

@ -198,12 +198,12 @@ export interface AuthenticatorAssertionResponseJSON
/** /**
* A WebAuthn-compatible device and the information needed to verify assertions by it * A WebAuthn-compatible device and the information needed to verify assertions by it
*/ */
export declare type AuthenticatorDevice = { export declare interface AuthenticatorDevice {
credentialPublicKey: Buffer; credentialPublicKey: Buffer;
credentialID: Buffer; credentialID: Buffer;
counter: number; counter: number;
transports?: AuthenticatorTransport[]; transports?: AuthenticatorTransport[];
}; }
/** /**
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string * An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
*/ */

20
apps/api/src/app/auth/jwt.strategy.ts

@ -1,7 +1,11 @@
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 { 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 {
DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE,
HEADER_KEY_TIMEZONE
} from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions'; import { hasRole } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
@ -42,16 +46,24 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
countriesAndTimezones.getCountryForTimezone(timezone)?.id; countriesAndTimezones.getCountryForTimezone(timezone)?.id;
await this.prismaService.analytics.upsert({ await this.prismaService.analytics.upsert({
create: { country, User: { connect: { id: user.id } } }, create: { country, user: { connect: { id: user.id } } },
update: { update: {
country, country,
activityCount: { increment: 1 }, activityCount: { increment: 1 },
updatedAt: new Date() lastRequestAt: new Date()
}, },
where: { userId: user.id } where: { userId: user.id }
}); });
} }
if (!user.settings.settings.baseCurrency) {
user.settings.settings.baseCurrency = DEFAULT_CURRENCY;
}
if (!user.settings.settings.language) {
user.settings.settings.language = DEFAULT_LANGUAGE_CODE;
}
return user; return user;
} else { } else {
throw new HttpException( throw new HttpException(
@ -60,7 +72,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
); );
} }
} catch (error) { } catch (error) {
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) { if (error?.getStatus?.() === StatusCodes.TOO_MANY_REQUESTS) {
throw error; throw error;
} else { } else {
throw new HttpException( throw new HttpException(

88
apps/api/src/app/auth/web-auth.service.ts

@ -24,6 +24,8 @@ import {
verifyRegistrationResponse, verifyRegistrationResponse,
VerifyRegistrationResponseOpts VerifyRegistrationResponseOpts
} from '@simplewebauthn/server'; } from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import ms from 'ms';
import { import {
AssertionCredentialJSON, AssertionCredentialJSON,
@ -40,43 +42,42 @@ export class WebAuthService {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
get rpID() { private get expectedOrigin() {
return new URL(this.configurationService.get('ROOT_URL')).hostname; return this.configurationService.get('ROOT_URL');
} }
get expectedOrigin() { private get rpID() {
return this.configurationService.get('ROOT_URL'); return new URL(this.configurationService.get('ROOT_URL')).hostname;
} }
public async generateRegistrationOptions() { public async generateRegistrationOptions() {
const user = this.request.user; const user = this.request.user;
const opts: GenerateRegistrationOptionsOpts = { const opts: GenerateRegistrationOptionsOpts = {
rpName: 'Ghostfolio',
rpID: this.rpID,
userID: user.id,
userName: '',
timeout: 60000,
attestationType: 'indirect',
authenticatorSelection: { authenticatorSelection: {
authenticatorAttachment: 'platform', authenticatorAttachment: 'platform',
requireResidentKey: false, residentKey: 'required',
userVerification: 'required' userVerification: 'preferred'
} },
rpID: this.rpID,
rpName: 'Ghostfolio',
timeout: ms('60 seconds'),
userID: isoUint8Array.fromUTF8String(user.id),
userName: ''
}; };
const options = await generateRegistrationOptions(opts); const registrationOptions = await generateRegistrationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
authChallenge: options.challenge authChallenge: registrationOptions.challenge
}, },
where: { where: {
id: user.id id: user.id
} }
}); });
return options; return registrationOptions;
} }
public async verifyAttestation( public async verifyAttestation(
@ -84,13 +85,14 @@ export class WebAuthService {
): Promise<AuthDeviceDto> { ): Promise<AuthDeviceDto> {
const user = this.request.user; const user = this.request.user;
const expectedChallenge = user.authChallenge; const expectedChallenge = user.authChallenge;
let verification: VerifiedRegistrationResponse; let verification: VerifiedRegistrationResponse;
try { try {
const opts: VerifyRegistrationResponseOpts = { const opts: VerifyRegistrationResponseOpts = {
expectedChallenge, expectedChallenge,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID, expectedRPID: this.rpID,
requireUserVerification: false,
response: { response: {
clientExtensionResults: credential.clientExtensionResults, clientExtensionResults: credential.clientExtensionResults,
id: credential.id, id: credential.id,
@ -99,6 +101,7 @@ export class WebAuthService {
type: 'public-key' type: 'public-key'
} }
}; };
verification = await verifyRegistrationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); Logger.error(error, 'WebAuthService');
@ -111,11 +114,17 @@ export class WebAuthService {
where: { userId: user.id } where: { userId: user.id }
}); });
if (registrationInfo && verified) { if (registrationInfo && verified) {
const { counter, credentialID, credentialPublicKey } = registrationInfo; const {
credential: {
counter,
id: credentialId,
publicKey: credentialPublicKey
}
} = registrationInfo;
let existingDevice = devices.find( let existingDevice = devices.find((device) => {
(device) => device.credentialId === credentialID return isoBase64URL.fromBuffer(device.credentialId) === credentialId;
); });
if (!existingDevice) { if (!existingDevice) {
/** /**
@ -123,9 +132,9 @@ export class WebAuthService {
*/ */
existingDevice = await this.deviceService.createAuthDevice({ existingDevice = await this.deviceService.createAuthDevice({
counter, counter,
credentialId: Buffer.from(credentialID), credentialId: Buffer.from(credentialId),
credentialPublicKey: Buffer.from(credentialPublicKey), credentialPublicKey: Buffer.from(credentialPublicKey),
User: { connect: { id: user.id } } user: { connect: { id: user.id } }
}); });
} }
@ -138,7 +147,7 @@ export class WebAuthService {
throw new InternalServerErrorException('An unknown error occurred'); throw new InternalServerErrorException('An unknown error occurred');
} }
public async generateAssertionOptions(deviceId: string) { public async generateAuthenticationOptions(deviceId: string) {
const device = await this.deviceService.authDevice({ id: deviceId }); const device = await this.deviceService.authDevice({ id: deviceId });
if (!device) { if (!device) {
@ -146,33 +155,27 @@ export class WebAuthService {
} }
const opts: GenerateAuthenticationOptionsOpts = { const opts: GenerateAuthenticationOptionsOpts = {
allowCredentials: [ allowCredentials: [],
{
id: device.credentialId,
transports: ['internal'],
type: 'public-key'
}
],
rpID: this.rpID, rpID: this.rpID,
timeout: 60000, timeout: ms('60 seconds'),
userVerification: 'preferred' userVerification: 'preferred'
}; };
const options = await generateAuthenticationOptions(opts); const authenticationOptions = await generateAuthenticationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
authChallenge: options.challenge authChallenge: authenticationOptions.challenge
}, },
where: { where: {
id: device.userId id: device.userId
} }
}); });
return options; return authenticationOptions;
} }
public async verifyAssertion( public async verifyAuthentication(
deviceId: string, deviceId: string,
credential: AssertionCredentialJSON credential: AssertionCredentialJSON
) { ) {
@ -185,16 +188,18 @@ export class WebAuthService {
const user = await this.userService.user({ id: device.userId }); const user = await this.userService.user({ id: device.userId });
let verification: VerifiedAuthenticationResponse; let verification: VerifiedAuthenticationResponse;
try { try {
const opts: VerifyAuthenticationResponseOpts = { const opts: VerifyAuthenticationResponseOpts = {
authenticator: { credential: {
credentialID: device.credentialId, counter: device.counter,
credentialPublicKey: device.credentialPublicKey, id: isoBase64URL.fromBuffer(device.credentialId),
counter: device.counter publicKey: device.credentialPublicKey
}, },
expectedChallenge: `${user.authChallenge}`, expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID, expectedRPID: this.rpID,
requireUserVerification: false,
response: { response: {
clientExtensionResults: credential.clientExtensionResults, clientExtensionResults: credential.clientExtensionResults,
id: credential.id, id: credential.id,
@ -203,13 +208,14 @@ export class WebAuthService {
type: 'public-key' type: 'public-key'
} }
}; };
verification = await verifyAuthenticationResponse(opts); verification = await verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });
} }
const { verified, authenticationInfo } = verification; const { authenticationInfo, verified } = verification;
if (verified) { if (verified) {
device.counter = authenticationInfo.newCounter; device.counter = authenticationInfo.newCounter;

59
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -0,0 +1,59 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
Inject,
Param,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AiService } from './ai.service';
@Controller('ai')
export class AiController {
public constructor(
private readonly aiService: AiService,
private readonly apiService: ApiService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('prompt/:mode')
@HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPrompt(
@Param('mode') mode: AiPromptMode,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<AiPromptResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
const prompt = await this.aiService.getPrompt({
filters,
mode,
impersonationId: undefined,
languageCode: this.request.user.settings.settings.language,
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id
});
return { prompt };
}
}

59
apps/api/src/app/endpoints/ai/ai.module.ts

@ -0,0 +1,59 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
@Module({
controllers: [AiController],
imports: [
ApiModule,
BenchmarkModule,
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule,
UserModule
],
providers: [
AccountBalanceService,
AccountService,
AiService,
CurrentRateService,
MarketDataService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]
})
export class AiModule {}

117
apps/api/src/app/endpoints/ai/ai.service.ts

@ -0,0 +1,117 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL
} from '@ghostfolio/common/config';
import { Filter } from '@ghostfolio/common/interfaces';
import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText } from 'ai';
import tablemark, { ColumnDescriptor } from 'tablemark';
@Injectable()
export class AiService {
public constructor(
private readonly portfolioService: PortfolioService,
private readonly propertyService: PropertyService
) {}
public async generateText({ prompt }: { prompt: string }) {
const openRouterApiKey = await this.propertyService.getByKey<string>(
PROPERTY_API_KEY_OPENROUTER
);
const openRouterModel = await this.propertyService.getByKey<string>(
PROPERTY_OPENROUTER_MODEL
);
const openRouterService = createOpenRouter({
apiKey: openRouterApiKey
});
return generateText({
prompt,
model: openRouterService.chat(openRouterModel)
});
}
public async getPrompt({
filters,
impersonationId,
languageCode,
mode,
userCurrency,
userId
}: {
filters?: Filter[];
impersonationId: string;
languageCode: string;
mode: AiPromptMode;
userCurrency: string;
userId: string;
}) {
const { holdings } = await this.portfolioService.getDetails({
filters,
impersonationId,
userId
});
const holdingsTableColumns: ColumnDescriptor[] = [
{ name: 'Name' },
{ name: 'Symbol' },
{ name: 'Currency' },
{ name: 'Asset Class' },
{ name: 'Asset Sub Class' },
{ align: 'right', name: 'Allocation in Percentage' }
];
const holdingsTableRows = Object.values(holdings)
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
})
.map(
({
allocationInPercentage,
assetClass,
assetSubClass,
currency,
name,
symbol
}) => {
return {
Name: name,
Symbol: symbol,
Currency: currency,
'Asset Class': assetClass ?? '',
'Asset Sub Class': assetSubClass ?? '',
'Allocation in Percentage': `${(allocationInPercentage * 100).toFixed(3)}%`
};
}
);
const holdingsTableString = tablemark(holdingsTableRows, {
columns: holdingsTableColumns
});
if (mode === 'portfolio') {
return holdingsTableString;
}
return [
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`,
holdingsTableString,
'Structure your answer with these sections:',
'Overview: Briefly summarize the portfolio’s composition and allocation rationale.',
'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.',
'Advantages: Highlight strengths, focusing on growth potential, diversification, or other benefits.',
'Disadvantages: Point out weaknesses, such as overexposure or lack of defensive assets.',
'Target Group: Discuss who this portfolio might suit (e.g., risk tolerance, investment goals, life stages, and experience levels).',
'Optimization Ideas: Offer ideas to complement the portfolio, ensuring they are constructive and neutral in tone.',
'Conclusion: Provide a concise summary highlighting key insights.',
`Provide your answer in the following language: ${languageCode}.`
].join('\n');
}
}

25
apps/api/src/app/endpoints/api-keys/api-keys.controller.ts

@ -0,0 +1,25 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ApiKeyResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@Controller('api-keys')
export class ApiKeysController {
public constructor(
private readonly apiKeyService: ApiKeyService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@HasPermission(permissions.createApiKey)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createApiKey(): Promise<ApiKeyResponse> {
return this.apiKeyService.create({ userId: this.request.user.id });
}
}

11
apps/api/src/app/endpoints/api-keys/api-keys.module.ts

@ -0,0 +1,11 @@
import { ApiKeyModule } from '@ghostfolio/api/services/api-key/api-key.module';
import { Module } from '@nestjs/common';
import { ApiKeysController } from './api-keys.controller';
@Module({
controllers: [ApiKeysController],
imports: [ApiKeyModule]
})
export class ApiKeysModule {}

46
apps/api/src/app/endpoints/assets/assets.controller.ts

@ -0,0 +1,46 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { interpolate } from '@ghostfolio/common/helper';
import {
Controller,
Get,
Param,
Res,
Version,
VERSION_NEUTRAL
} from '@nestjs/common';
import { Response } from 'express';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
@Controller('assets')
export class AssetsController {
private webManifest = '';
public constructor(
public readonly configurationService: ConfigurationService
) {
try {
this.webManifest = readFileSync(
join(__dirname, 'assets', 'site.webmanifest'),
'utf8'
);
} catch {}
}
@Get('/:languageCode/site.webmanifest')
@Version(VERSION_NEUTRAL)
public getWebManifest(
@Param('languageCode') languageCode: string,
@Res() response: Response
): void {
const rootUrl = this.configurationService.get('ROOT_URL');
const webManifest = interpolate(this.webManifest, {
languageCode,
rootUrl
});
response.setHeader('Content-Type', 'application/json');
response.send(webManifest);
}
}

11
apps/api/src/app/endpoints/assets/assets.module.ts

@ -0,0 +1,11 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Module } from '@nestjs/common';
import { AssetsController } from './assets.controller';
@Module({
controllers: [AssetsController],
providers: [ConfigurationService]
})
export class AssetsModule {}

44
apps/api/src/app/benchmark/benchmark.controller.ts → apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts

@ -2,10 +2,13 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { 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 { 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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type { import type {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetailsResponse,
BenchmarkResponse BenchmarkResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -16,6 +19,7 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -29,12 +33,14 @@ import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarkService } from './benchmark.service'; import { BenchmarksService } from './benchmarks.service';
@Controller('benchmark') @Controller('benchmarks')
export class BenchmarkController { export class BenchmarksController {
public constructor( public constructor(
private readonly apiService: ApiService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly benchmarksService: BenchmarksService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -108,23 +114,43 @@ export class BenchmarkController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataForUser( public async getBenchmarkMarketDataForUser(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@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' @Query('range') dateRange: DateRange = 'max',
): Promise<BenchmarkMarketDataDetails> { @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange( const { endDate, startDate } = getIntervalFromDateRange(
dateRange, dateRange,
new Date(startDateString) new Date(startDateString)
); );
const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataForUser({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
const withExcludedAccounts = withExcludedAccountsParam === 'true';
return this.benchmarksService.getMarketDataForUser({
dataSource, dataSource,
dateRange,
endDate, endDate,
filters,
impersonationId,
startDate, startDate,
symbol, symbol,
userCurrency withExcludedAccounts,
user: this.request.user
}); });
} }
} }

65
apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts

@ -0,0 +1,65 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarksController } from './benchmarks.controller';
import { BenchmarksService } from './benchmarks.service';
@Module({
controllers: [BenchmarksController],
imports: [
ApiModule,
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule,
UserModule
],
providers: [
AccountBalanceService,
AccountService,
BenchmarkService,
BenchmarksService,
CurrentRateService,
MarketDataService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]
})
export class BenchmarksModule {}

163
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -0,0 +1,163 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
BenchmarkMarketDataDetailsResponse,
Filter
} from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { format, isSameDay } from 'date-fns';
import { isNumber } from 'lodash';
@Injectable()
export class BenchmarksService {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly portfolioService: PortfolioService,
private readonly symbolService: SymbolService
) {}
public async getMarketDataForUser({
dataSource,
dateRange,
endDate = new Date(),
filters,
impersonationId,
startDate,
symbol,
user,
withExcludedAccounts
}: {
dateRange: DateRange;
endDate?: Date;
filters?: Filter[];
impersonationId: string;
startDate: Date;
user: UserWithSettings;
withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetailsResponse> {
const marketData: { date: string; value: number }[] = [];
const userCurrency = user.settings.settings.baseCurrency;
const userId = user.id;
const { chart } = await this.portfolioService.getPerformance({
dateRange,
filters,
impersonationId,
userId,
withExcludedAccounts
});
const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({
dataGatheringItem: {
dataSource,
symbol
}
}),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol,
date: {
in: chart.map(({ date }) => {
return resetHours(parseDate(date));
})
}
}
})
]);
const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
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 (const 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.benchmarkService.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice * exchangeRateFactor
) * 100
});
}
const includesEndDate = isSameDay(
parseDate(marketData.at(-1).date),
endDate
);
if (currentSymbolItem?.marketPrice && !includesEndDate) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(endDate, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(endDate, DATE_FORMAT),
value:
this.benchmarkService.calculateChangeInPercentage(
marketPriceAtStartDate,
currentSymbolItem.marketPrice * exchangeRateFactor
) * 100
});
}
return {
marketData
};
}
}

15
apps/api/src/app/endpoints/data-providers/ghostfolio/get-dividends.dto.ts

@ -0,0 +1,15 @@
import { Granularity } from '@ghostfolio/common/types';
import { IsIn, IsISO8601, IsOptional } from 'class-validator';
export class GetDividendsDto {
@IsISO8601()
from: string;
@IsIn(['day', 'month'] as Granularity[])
@IsOptional()
granularity: Granularity;
@IsISO8601()
to: string;
}

15
apps/api/src/app/endpoints/data-providers/ghostfolio/get-historical.dto.ts

@ -0,0 +1,15 @@
import { Granularity } from '@ghostfolio/common/types';
import { IsIn, IsISO8601, IsOptional } from 'class-validator';
export class GetHistoricalDto {
@IsISO8601()
from: string;
@IsIn(['day', 'month'] as Granularity[])
@IsOptional()
granularity: Granularity;
@IsISO8601()
to: string;
}

10
apps/api/src/app/endpoints/data-providers/ghostfolio/get-quotes.dto.ts

@ -0,0 +1,10 @@
import { Transform } from 'class-transformer';
import { IsString } from 'class-validator';
export class GetQuotesDto {
@IsString({ each: true })
@Transform(({ value }) =>
typeof value === 'string' ? value.split(',') : value
)
symbols: string[];
}

249
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -0,0 +1,249 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { AssetProfileInvalidError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-invalid.error';
import { parseDate } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderGhostfolioStatusResponse,
DividendsResponse,
HistoricalResponse,
LookupResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
HttpException,
Inject,
Param,
Query,
UseGuards,
Version
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { isISIN } from 'class-validator';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { GetDividendsDto } from './get-dividends.dto';
import { GetHistoricalDto } from './get-historical.dto';
import { GetQuotesDto } from './get-quotes.dto';
import { GhostfolioService } from './ghostfolio.service';
@Controller('data-providers/ghostfolio')
export class GhostfolioController {
public constructor(
private readonly ghostfolioService: GhostfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('asset-profile/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
public async getAssetProfile(
@Param('symbol') symbol: string
): Promise<DataProviderGhostfolioAssetProfileResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const assetProfile = await this.ghostfolioService.getAssetProfile({
symbol
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return assetProfile;
} catch (error) {
if (error instanceof AssetProfileInvalidError) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('dividends/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getDividends(
@Param('symbol') symbol: string,
@Query() query: GetDividendsDto
): Promise<DividendsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const dividends = await this.ghostfolioService.getDividends({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return dividends;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getHistorical(
@Param('symbol') symbol: string,
@Query() query: GetHistoricalDto
): Promise<HistoricalResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const historicalData = await this.ghostfolioService.getHistorical({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return historicalData;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async lookupSymbol(
@Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = ''
): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const result = await this.ghostfolioService.lookup({
includeIndices,
query: isISIN(query.toUpperCase())
? query.toUpperCase()
: query.toLowerCase()
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return result;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getQuotes(
@Query() query: GetQuotesDto
): Promise<QuotesResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const quotes = await this.ghostfolioService.getQuotes({
symbols: query.symbols
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return quotes;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user });
}
}

83
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts

@ -0,0 +1,83 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { GhostfolioController } from './ghostfolio.controller';
import { GhostfolioService } from './ghostfolio.service';
@Module({
controllers: [GhostfolioController],
imports: [
CryptocurrencyModule,
DataProviderModule,
MarketDataModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
],
providers: [
AlphaVantageService,
CoinGeckoService,
ConfigurationService,
DataProviderService,
EodHistoricalDataService,
FinancialModelingPrepService,
GhostfolioService,
GoogleSheetsService,
ManualService,
RapidApiService,
YahooFinanceService,
YahooFinanceDataEnhancerService,
{
inject: [
AlphaVantageService,
CoinGeckoService,
EodHistoricalDataService,
FinancialModelingPrepService,
GoogleSheetsService,
ManualService,
RapidApiService,
YahooFinanceService
],
provide: 'DataProviderInterfaces',
useFactory: (
alphaVantageService,
coinGeckoService,
eodHistoricalDataService,
financialModelingPrepService,
googleSheetsService,
manualService,
rapidApiService,
yahooFinanceService
) => [
alphaVantageService,
coinGeckoService,
eodHistoricalDataService,
financialModelingPrepService,
googleSheetsService,
manualService,
rapidApiService,
yahooFinanceService
]
}
]
})
export class GhostfolioModule {}

375
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -0,0 +1,375 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
import {
GetAssetProfileParams,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_CURRENCY,
DERIVED_CURRENCIES
} from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderInfo,
DividendsResponse,
HistoricalResponse,
LookupItem,
LookupResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
@Injectable()
export class GhostfolioService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) {}
public async getAssetProfile({ symbol }: GetAssetProfileParams) {
let result: DataProviderGhostfolioAssetProfileResponse = {};
try {
const promises: Promise<Partial<SymbolProfile>>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
this.dataProviderService
.getAssetProfiles([
{
symbol,
dataSource: dataProviderService.getName()
}
])
.then(async (assetProfiles) => {
const assetProfile = assetProfiles[symbol];
const dataSourceOrigin = DataSource.GHOSTFOLIO;
if (assetProfile) {
await this.prismaService.assetProfileResolution.upsert({
create: {
dataSourceOrigin,
currency: assetProfile.currency,
dataSourceTarget: assetProfile.dataSource,
symbolOrigin: symbol,
symbolTarget: assetProfile.symbol
},
update: {
requestCount: {
increment: 1
}
},
where: {
dataSourceOrigin_symbolOrigin: {
dataSourceOrigin,
symbolOrigin: symbol
}
}
});
}
result = {
...result,
...assetProfile,
dataSource: dataSourceOrigin
};
return assetProfile;
})
);
}
await Promise.all(promises);
return result;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getDividends({
from,
granularity,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol,
to
}: GetDividendsParams) {
const result: DividendsResponse = { dividends: {} };
try {
const promises: Promise<{
[date: string]: IDataProviderHistoricalResponse;
}>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService
.getDividends({
from,
granularity,
requestTimeout,
symbol,
to
})
.then((dividends) => {
result.dividends = dividends;
return dividends;
})
);
}
await Promise.all(promises);
return result;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getHistorical({
from,
granularity,
requestTimeout,
to,
symbol
}: GetHistoricalParams) {
const result: HistoricalResponse = { historicalData: {} };
try {
const promises: Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService
.getHistorical({
from,
granularity,
requestTimeout,
symbol,
to
})
.then((historicalData) => {
result.historicalData = historicalData[symbol];
return historicalData;
})
);
}
await Promise.all(promises);
return result;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getMaxDailyRequests() {
return parseInt(
(await this.propertyService.getByKey<string>(
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS
)) || '0',
10
);
}
public async getQuotes({ requestTimeout, symbols }: GetQuotesParams) {
const results: QuotesResponse = { quotes: {} };
try {
const promises: Promise<any>[] = [];
for (const dataProvider of this.getDataProviderServices()) {
const maximumNumberOfSymbolsPerRequest =
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
Number.MAX_SAFE_INTEGER;
for (
let i = 0;
i < symbols.length;
i += maximumNumberOfSymbolsPerRequest
) {
const symbolsChunk = symbols.slice(
i,
i + maximumNumberOfSymbolsPerRequest
);
const promise = Promise.resolve(
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk })
);
promises.push(
promise.then(async (result) => {
for (const [symbol, dataProviderResponse] of Object.entries(
result
)) {
dataProviderResponse.dataSource = 'GHOSTFOLIO';
if (
[
...DERIVED_CURRENCIES.map(({ currency }) => {
return `${DEFAULT_CURRENCY}${currency}`;
}),
`${DEFAULT_CURRENCY}USX`
].includes(symbol)
) {
continue;
}
results.quotes[symbol] = dataProviderResponse;
for (const {
currency,
factor,
rootCurrency
} of DERIVED_CURRENCIES) {
if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) {
results.quotes[`${DEFAULT_CURRENCY}${currency}`] = {
...dataProviderResponse,
currency,
marketPrice: new Big(
result[`${DEFAULT_CURRENCY}${rootCurrency}`].marketPrice
)
.mul(factor)
.toNumber(),
marketState: 'open'
};
}
}
}
})
);
}
await Promise.all(promises);
}
return results;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getStatus({ user }: { user: UserWithSettings }) {
return {
dailyRequests: user.dataProviderGhostfolioDailyRequests,
dailyRequestsMax: await this.getMaxDailyRequests(),
subscription: user.subscription
};
}
public async incrementDailyRequests({ userId }: { userId: string }) {
await this.prismaService.analytics.update({
data: {
dataProviderGhostfolioDailyRequests: { increment: 1 }
},
where: { userId }
});
}
public async lookup({
includeIndices = false,
query
}: GetSearchParams): Promise<LookupResponse> {
const results: LookupResponse = { items: [] };
if (!query) {
return results;
}
try {
let lookupItems: LookupItem[] = [];
const promises: Promise<{ items: LookupItem[] }>[] = [];
if (query?.length < 2) {
return { items: lookupItems };
}
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService.search({
includeIndices,
query
})
);
}
const searchResults = await Promise.all(promises);
for (const { items } of searchResults) {
if (items?.length > 0) {
lookupItems = lookupItems.concat(items);
}
}
const filteredItems = lookupItems
.filter(({ currency }) => {
// Only allow symbols with supported currency
return currency ? true : false;
})
.sort(({ name: name1 }, { name: name2 }) => {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
})
.map((lookupItem) => {
lookupItem.dataProviderInfo = this.getDataProviderInfo();
lookupItem.dataSource = 'GHOSTFOLIO';
return lookupItem;
});
results.items = filteredItems;
return results;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
private getDataProviderInfo(): DataProviderInfo {
const ghostfolioDataProviderService = new GhostfolioDataProviderService(
this.configurationService,
this.propertyService
);
return {
...ghostfolioDataProviderService.getDataProviderInfo(),
isPremium: false,
name: 'Ghostfolio Premium'
};
}
private getDataProviderServices() {
return this.configurationService
.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
.map((dataSource) => {
return this.dataProviderService.getDataProvider(DataSource[dataSource]);
});
}
}

189
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -0,0 +1,189 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
ghostfolioFearAndGreedIndexDataSourceStocks,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import {
MarketDataDetailsResponse,
MarketDataOfMarketsResponse
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
HttpException,
Inject,
Param,
Post,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
@Controller('market-data')
export class MarketDataController {
public constructor(
private readonly adminService: AdminService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService,
private readonly symbolService: SymbolService
) {}
@Get('markets')
@HasPermission(permissions.readMarketDataOfMarkets)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataOfMarkets(
@Query('includeHistoricalData') includeHistoricalData = 0
): Promise<MarketDataOfMarketsResponse> {
const [
marketDataFearAndGreedIndexCryptocurrencies,
marketDataFearAndGreedIndexStocks
] = await Promise.all([
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies
}
}),
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceStocks,
symbol: ghostfolioFearAndGreedIndexSymbolStocks
}
})
]);
return {
fearAndGreedIndex: {
CRYPTOCURRENCIES: {
...marketDataFearAndGreedIndexCryptocurrencies
},
STOCKS: {
...marketDataFearAndGreedIndexStocks
}
}
};
}
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<MarketDataDetailsResponse> {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const canReadAllAssetProfiles = hasPermission(
this.request.user.permissions,
permissions.readMarketData
);
const canReadOwnAssetProfile =
assetProfile?.userId === this.request.user.id &&
hasPermission(
this.request.user.permissions,
permissions.readMarketDataOfOwnAssetProfile
);
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) {
throw new HttpException(
assetProfile.userId
? getReasonPhrase(StatusCodes.NOT_FOUND)
: getReasonPhrase(StatusCodes.FORBIDDEN),
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
}
@Post(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const canUpsertAllAssetProfiles =
hasPermission(
this.request.user.permissions,
permissions.createMarketData
) &&
hasPermission(
this.request.user.permissions,
permissions.updateMarketData
);
const canUpsertOwnAssetProfile =
assetProfile?.userId === this.request.user.id &&
hasPermission(
this.request.user.permissions,
permissions.createMarketDataOfOwnAssetProfile
) &&
hasPermission(
this.request.user.permissions,
permissions.updateMarketDataOfOwnAssetProfile
);
if (!canUpsertAllAssetProfiles && !canUpsertOwnAssetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({
dataSource,
marketPrice,
symbol,
date: parseISO(date),
state: 'CLOSE'
})
);
return this.marketDataService.updateMany({
data: dataBulkUpdate
});
}
}

19
apps/api/src/app/endpoints/market-data/market-data.module.ts

@ -0,0 +1,19 @@
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { MarketDataController } from './market-data.controller';
@Module({
controllers: [MarketDataController],
imports: [
AdminModule,
MarketDataServiceModule,
SymbolModule,
SymbolProfileModule
]
})
export class MarketDataModule {}

24
apps/api/src/app/endpoints/market-data/update-bulk-market-data.dto.ts

@ -0,0 +1,24 @@
import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsISO8601,
IsNumber,
IsOptional
} from 'class-validator';
export class UpdateBulkMarketDataDto {
@ArrayNotEmpty()
@IsArray()
@Type(() => UpdateMarketDataDto)
marketData: UpdateMarketDataDto[];
}
class UpdateMarketDataDto {
@IsISO8601()
@IsOptional()
date?: string;
@IsNumber()
marketPrice: number;
}

55
apps/api/src/app/endpoints/public/public.controller.ts

@ -1,6 +1,8 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
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/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 { 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';
@ -18,6 +20,7 @@ import {
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { Type as ActivityType } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -27,15 +30,17 @@ export class PublicController {
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@Get(':accessId/portfolio') @Get(':accessId/portfolio')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublicPortfolio( public async getPublicPortfolio(
@Param('accessId') accessId @Param('accessId') accessId: string
): Promise<PublicPortfolioResponse> { ): Promise<PublicPortfolioResponse> {
const access = await this.accessService.access({ id: accessId }); const access = await this.accessService.access({ id: accessId });
@ -57,7 +62,7 @@ export class PublicController {
} }
const [ const [
{ holdings, markets }, { createdAt, holdings, markets },
{ performance: performance1d }, { performance: performance1d },
{ performance: performanceMax }, { performance: performanceMax },
{ performance: performanceYtd } { performance: performanceYtd }
@ -76,12 +81,56 @@ export class PublicController {
}) })
]); ]);
const { activities } = await this.orderService.getOrders({
includeDrafts: false,
sortColumn: 'date',
sortDirection: 'desc',
take: 10,
types: [ActivityType.BUY, ActivityType.SELL],
userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY,
userId: user.id,
withExcludedAccountsAndActivities: false
});
// Experimental
const latestActivities = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? []
: activities.map(
({
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice,
value,
valueInBaseCurrency
}) => {
return {
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice,
value,
valueInBaseCurrency
};
}
);
Object.values(markets ?? {}).forEach((market) => { Object.values(markets ?? {}).forEach((market) => {
delete market.valueInBaseCurrency; delete market.valueInBaseCurrency;
}); });
const publicPortfolioResponse: PublicPortfolioResponse = { const publicPortfolioResponse: PublicPortfolioResponse = {
createdAt,
hasDetails, hasDetails,
latestActivities,
markets, markets,
alias: access.alias, alias: access.alias,
holdings: {}, holdings: {},
@ -107,7 +156,7 @@ export class PublicController {
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
quantity * marketPrice, quantity * marketPrice,
currency, currency,
this.request.user?.Settings?.settings.baseCurrency ?? this.request.user?.settings?.settings.baseCurrency ??
DEFAULT_CURRENCY DEFAULT_CURRENCY
) )
); );

4
apps/api/src/app/endpoints/public/public.module.ts

@ -9,8 +9,10 @@ import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
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 { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.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';
@ -25,8 +27,10 @@ import { PublicController } from './public.controller';
controllers: [PublicController], controllers: [PublicController],
imports: [ imports: [
AccessModule, AccessModule,
BenchmarkModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
I18nModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,

52
apps/api/src/app/endpoints/sitemap/sitemap.controller.ts

@ -0,0 +1,52 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DATE_FORMAT,
getYesterday,
interpolate
} from '@ghostfolio/common/helper';
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
import { Response } from 'express';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { SitemapService } from './sitemap.service';
@Controller('sitemap.xml')
export class SitemapController {
public sitemapXml = '';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly sitemapService: SitemapService
) {
try {
this.sitemapXml = readFileSync(
join(__dirname, 'assets', 'sitemap.xml'),
'utf8'
);
} catch {}
}
@Get()
@Version(VERSION_NEUTRAL)
public getSitemapXml(@Res() response: Response) {
const currentDate = format(getYesterday(), DATE_FORMAT);
response.setHeader('content-type', 'application/xml');
response.send(
interpolate(this.sitemapXml, {
blogPosts: this.sitemapService.getBlogPosts({ currentDate }),
personalFinanceTools: this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.sitemapService.getPersonalFinanceTools({ currentDate })
: '',
publicRoutes: this.sitemapService.getPublicRoutes({
currentDate
})
})
);
}
}

5
apps/api/src/app/sitemap/sitemap.module.ts → apps/api/src/app/endpoints/sitemap/sitemap.module.ts

@ -1,11 +1,14 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SitemapController } from './sitemap.controller'; import { SitemapController } from './sitemap.controller';
import { SitemapService } from './sitemap.service';
@Module({ @Module({
controllers: [SitemapController], controllers: [SitemapController],
imports: [ConfigurationModule] imports: [ConfigurationModule, I18nModule],
providers: [SitemapService]
}) })
export class SitemapModule {} export class SitemapModule {}

252
apps/api/src/app/endpoints/sitemap/sitemap.service.ts

@ -0,0 +1,252 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { PublicRoute } from '@ghostfolio/common/routes/interfaces/public-route.interface';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Injectable } from '@nestjs/common';
@Injectable()
export class SitemapService {
private static readonly TRANSLATION_TAGGED_MESSAGE_REGEX =
/:.*@@(?<id>[a-zA-Z0-9.]+):(?<message>.+)/;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly i18nService: I18nService
) {}
public getBlogPosts({ currentDate }: { currentDate: string }) {
const rootUrl = this.configurationService.get('ROOT_URL');
return [
{
languageCode: 'de',
routerLink: ['2021', '07', 'hallo-ghostfolio']
},
{
languageCode: 'en',
routerLink: ['2021', '07', 'hello-ghostfolio']
},
{
languageCode: 'en',
routerLink: ['2022', '01', 'ghostfolio-first-months-in-open-source']
},
{
languageCode: 'en',
routerLink: ['2022', '07', 'ghostfolio-meets-internet-identity']
},
{
languageCode: 'en',
routerLink: ['2022', '07', 'how-do-i-get-my-finances-in-order']
},
{
languageCode: 'en',
routerLink: ['2022', '08', '500-stars-on-github']
},
{
languageCode: 'en',
routerLink: ['2022', '10', 'hacktoberfest-2022']
},
{
languageCode: 'en',
routerLink: ['2022', '11', 'black-friday-2022']
},
{
languageCode: 'en',
routerLink: [
'2022',
'12',
'the-importance-of-tracking-your-personal-finances'
]
},
{
languageCode: 'de',
routerLink: ['2023', '01', 'ghostfolio-auf-sackgeld-vorgestellt']
},
{
languageCode: 'en',
routerLink: ['2023', '02', 'ghostfolio-meets-umbrel']
},
{
languageCode: 'en',
routerLink: ['2023', '03', 'ghostfolio-reaches-1000-stars-on-github']
},
{
languageCode: 'en',
routerLink: [
'2023',
'05',
'unlock-your-financial-potential-with-ghostfolio'
]
},
{
languageCode: 'en',
routerLink: ['2023', '07', 'exploring-the-path-to-fire']
},
{
languageCode: 'en',
routerLink: ['2023', '08', 'ghostfolio-joins-oss-friends']
},
{
languageCode: 'en',
routerLink: ['2023', '09', 'ghostfolio-2']
},
{
languageCode: 'en',
routerLink: ['2023', '09', 'hacktoberfest-2023']
},
{
languageCode: 'en',
routerLink: ['2023', '11', 'black-week-2023']
},
{
languageCode: 'en',
routerLink: ['2023', '11', 'hacktoberfest-2023-debriefing']
},
{
languageCode: 'en',
routerLink: ['2024', '09', 'hacktoberfest-2024']
},
{
languageCode: 'en',
routerLink: ['2024', '11', 'black-weeks-2024']
},
{
languageCode: 'en',
routerLink: ['2025', '09', 'hacktoberfest-2025']
}
]
.map(({ languageCode, routerLink }) => {
return this.createRouteSitemapUrl({
currentDate,
languageCode,
rootUrl,
route: {
routerLink: [publicRoutes.blog.path, ...routerLink],
path: undefined
}
});
})
.join('\n');
}
public getPersonalFinanceTools({ currentDate }: { currentDate: string }) {
const rootUrl = this.configurationService.get('ROOT_URL');
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => {
const resourcesPath = this.i18nService.getTranslation({
languageCode,
id: publicRoutes.resources.path.match(
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX
).groups.id
});
const personalFinanceToolsPath = this.i18nService.getTranslation({
languageCode,
id: publicRoutes.resources.subRoutes.personalFinanceTools.path.match(
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX
).groups.id
});
const productPath = this.i18nService.getTranslation({
languageCode,
id: publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes.product.path.match(
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX
).groups.id
});
return personalFinanceTools.map(({ alias, key }) => {
const routerLink = [
resourcesPath,
personalFinanceToolsPath,
`${productPath}-${alias ?? key}`
];
return this.createRouteSitemapUrl({
currentDate,
languageCode,
rootUrl,
route: {
routerLink,
path: undefined
}
});
});
}).join('\n');
}
public getPublicRoutes({ currentDate }: { currentDate: string }) {
const rootUrl = this.configurationService.get('ROOT_URL');
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => {
const params = {
currentDate,
languageCode,
rootUrl
};
return [
this.createRouteSitemapUrl(params),
...this.createSitemapUrls(params, publicRoutes)
];
}).join('\n');
}
private createRouteSitemapUrl({
currentDate,
languageCode,
rootUrl,
route
}: {
currentDate: string;
languageCode: string;
rootUrl: string;
route?: PublicRoute;
}): string {
const segments =
route?.routerLink.map((link) => {
const match = link.match(
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX
);
const segment = match
? (this.i18nService.getTranslation({
languageCode,
id: match.groups.id
}) ?? match.groups.message)
: link;
return segment.replace(/^\/+|\/+$/, '');
}) ?? [];
const location = [rootUrl, languageCode, ...segments].join('/');
return [
' <url>',
` <loc>${location}</loc>`,
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
' </url>'
].join('\n');
}
private createSitemapUrls(
params: { currentDate: string; languageCode: string; rootUrl: string },
routes: Record<string, PublicRoute>
): string[] {
return Object.values(routes).flatMap((route) => {
if (route.excludeFromSitemap) {
return [];
}
const urls = [this.createRouteSitemapUrl({ ...params, route })];
if (route.subRoutes) {
urls.push(...this.createSitemapUrls(params, route.subRoutes));
}
return urls;
});
}
}

14
apps/api/src/app/endpoints/tags/create-tag.dto.ts

@ -0,0 +1,14 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateTagDto {
@IsOptional()
@IsString()
id?: string;
@IsString()
name: string;
@IsOptional()
@IsString()
userId?: string;
}

89
apps/api/src/app/tag/tag.controller.ts → apps/api/src/app/endpoints/tags/tags.controller.ts

@ -1,6 +1,8 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -8,41 +10,63 @@ import {
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 { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto'; import { CreateTagDto } from './create-tag.dto';
import { TagService } from './tag.service';
import { UpdateTagDto } from './update-tag.dto'; import { UpdateTagDto } from './update-tag.dto';
@Controller('tag') @Controller('tags')
export class TagController { export class TagsController {
public constructor(private readonly tagService: TagService) {} public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
@Get() private readonly tagService: TagService
@HasPermission(permissions.readTags) ) {}
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Post() @Post()
@HasPermission(permissions.createTag) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createTag(@Body() data: CreateTagDto): Promise<Tag> { public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
const canCreateOwnTag = hasPermission(
this.request.user.permissions,
permissions.createOwnTag
);
const canCreateTag = hasPermission(
this.request.user.permissions,
permissions.createTag
);
if (!canCreateOwnTag && !canCreateTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (canCreateOwnTag && !canCreateTag) {
if (data.userId !== this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
return this.tagService.createTag(data); return this.tagService.createTag(data);
} }
@HasPermission(permissions.updateTag) @Delete(':id')
@Put(':id') @HasPermission(permissions.deleteTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) { public async deleteTag(@Param('id') id: string) {
const originalTag = await this.tagService.getTag({ const originalTag = await this.tagService.getTag({
id id
}); });
@ -54,20 +78,20 @@ export class TagController {
); );
} }
return this.tagService.updateTag({ return this.tagService.deleteTag({ id });
data: {
...data
},
where: {
id
}
});
} }
@Delete(':id') @Get()
@HasPermission(permissions.deleteTag) @HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteTag(@Param('id') id: string) { public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@HasPermission(permissions.updateTag)
@Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
const originalTag = await this.tagService.getTag({ const originalTag = await this.tagService.getTag({
id id
}); });
@ -79,6 +103,13 @@ export class TagController {
); );
} }
return this.tagService.deleteTag({ id }); return this.tagService.updateTag({
data: {
...data
},
where: {
id
}
});
} }
} }

12
apps/api/src/app/endpoints/tags/tags.module.ts

@ -0,0 +1,12 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
import { TagsController } from './tags.controller';
@Module({
controllers: [TagsController],
imports: [PrismaModule, TagModule]
})
export class TagsModule {}

13
apps/api/src/app/endpoints/tags/update-tag.dto.ts

@ -0,0 +1,13 @@
import { IsOptional, IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
@IsOptional()
@IsString()
userId?: string;
}

10
apps/api/src/app/endpoints/watchlist/create-watchlist-item.dto.ts

@ -0,0 +1,10 @@
import { DataSource } from '@prisma/client';
import { IsEnum, IsString } from 'class-validator';
export class CreateWatchlistItemDto {
@IsEnum(DataSource)
dataSource: DataSource;
@IsString()
symbol: string;
}

100
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts

@ -0,0 +1,100 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpException,
Inject,
Param,
Post,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateWatchlistItemDto } from './create-watchlist-item.dto';
import { WatchlistService } from './watchlist.service';
@Controller('watchlist')
export class WatchlistController {
public constructor(
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly watchlistService: WatchlistService
) {}
@Post()
@HasPermission(permissions.createWatchlistItem)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) {
return this.watchlistService.createWatchlistItem({
dataSource: data.dataSource,
symbol: data.symbol,
userId: this.request.user.id
});
}
@Delete(':dataSource/:symbol')
@HasPermission(permissions.deleteWatchlistItem)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteWatchlistItem(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
const watchlistItems = await this.watchlistService.getWatchlistItems(
this.request.user.id
);
const watchlistItem = watchlistItems.find((item) => {
return item.dataSource === dataSource && item.symbol === symbol;
});
if (!watchlistItem) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return this.watchlistService.deleteWatchlistItem({
dataSource,
symbol,
userId: this.request.user.id
});
}
@Get()
@HasPermission(permissions.readWatchlist)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getWatchlistItems(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<WatchlistResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const watchlist = await this.watchlistService.getWatchlistItems(
impersonationUserId || this.request.user.id
);
return {
watchlist
};
}
}

27
apps/api/src/app/benchmark/benchmark.module.ts → apps/api/src/app/endpoints/watchlist/watchlist.module.ts

@ -1,36 +1,31 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { 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 { 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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.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 { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.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 { WatchlistController } from './watchlist.controller';
import { BenchmarkService } from './benchmark.service'; import { WatchlistService } from './watchlist.service';
@Module({ @Module({
controllers: [BenchmarkController], controllers: [WatchlistController],
exports: [BenchmarkService],
imports: [ imports: [
ConfigurationModule, BenchmarkModule,
DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ImpersonationModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule, SymbolProfileModule,
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule TransformDataSourceInResponseModule
], ],
providers: [BenchmarkService] providers: [WatchlistService]
}) })
export class BenchmarkModule {} export class WatchlistModule {}

155
apps/api/src/app/endpoints/watchlist/watchlist.service.ts

@ -0,0 +1,155 @@
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { BadRequestException, Injectable } from '@nestjs/common';
import { DataSource, Prisma } from '@prisma/client';
@Injectable()
export class WatchlistService {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async createWatchlistItem({
dataSource,
symbol,
userId
}: {
dataSource: DataSource;
symbol: string;
userId: string;
}): Promise<void> {
const symbolProfile = await this.prismaService.symbolProfile.findUnique({
where: {
dataSource_symbol: { dataSource, symbol }
}
});
if (!symbolProfile) {
const assetProfiles = await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
]);
if (!assetProfiles[symbol]?.currency) {
throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})`
);
}
await this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
);
}
await this.dataGatheringService.gatherSymbol({
dataSource,
symbol
});
await this.prismaService.user.update({
data: {
watchlist: {
connect: {
dataSource_symbol: { dataSource, symbol }
}
}
},
where: { id: userId }
});
}
public async deleteWatchlistItem({
dataSource,
symbol,
userId
}: {
dataSource: DataSource;
symbol: string;
userId: string;
}) {
await this.prismaService.user.update({
data: {
watchlist: {
disconnect: {
dataSource_symbol: { dataSource, symbol }
}
}
},
where: { id: userId }
});
}
public async getWatchlistItems(
userId: string
): Promise<WatchlistResponse['watchlist']> {
const user = await this.prismaService.user.findUnique({
select: {
watchlist: {
select: { dataSource: true, symbol: true }
}
},
where: { id: userId }
});
const [assetProfiles, quotes] = await Promise.all([
this.symbolProfileService.getSymbolProfiles(user.watchlist),
this.dataProviderService.getQuotes({
items: user.watchlist.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
})
]);
const watchlist = await Promise.all(
user.watchlist.map(async ({ dataSource, symbol }) => {
const assetProfile = assetProfiles.find((profile) => {
return profile.dataSource === dataSource && profile.symbol === symbol;
});
const [allTimeHigh, trends] = await Promise.all([
this.marketDataService.getMax({
dataSource,
symbol
}),
this.benchmarkService.getBenchmarkTrends({ dataSource, symbol })
]);
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
allTimeHigh?.marketPrice,
quotes[symbol]?.marketPrice
);
return {
dataSource,
symbol,
marketCondition:
this.benchmarkService.getMarketCondition(performancePercent),
name: assetProfile?.name,
performances: {
allTimeHigh: {
performancePercent,
date: allTimeHigh?.date
}
},
trend50d: trends.trend50d,
trend200d: trends.trend200d
};
})
);
return watchlist.sort((a, b) => {
return a.name.localeCompare(b.name);
});
}
}

20
apps/api/src/app/export/export.controller.ts

@ -1,9 +1,17 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { 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,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -19,22 +27,28 @@ export class ExportController {
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityIds') activityIds?: string[], @Query('activityIds') filterByActivityIds?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<Export> { ): Promise<Export> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });
return this.exportService.export({ return this.exportService.export({
activityIds, activityIds,
filters, 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
}); });
} }

12
apps/api/src/app/export/export.module.ts

@ -1,6 +1,9 @@
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 { 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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -8,8 +11,15 @@ import { ExportController } from './export.controller';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
@Module({ @Module({
imports: [AccountModule, ApiModule, OrderModule],
controllers: [ExportController], controllers: [ExportController],
imports: [
AccountModule,
ApiModule,
MarketDataModule,
OrderModule,
TagModule,
TransformDataSourceInRequestModule
],
providers: [ExportService] providers: [ExportService]
}) })
export class ExportModule {} export class ExportModule {}

203
apps/api/src/app/export/export.service.ts

@ -1,15 +1,21 @@
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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { Filter, Export } from '@ghostfolio/common/interfaces'; import { Filter, Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Platform, Prisma } from '@prisma/client';
import { groupBy, uniqBy } from 'lodash';
@Injectable() @Injectable()
export class ExportService { export class ExportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly orderService: OrderService private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
private readonly tagService: TagService
) {} ) {}
public async export({ public async export({
@ -23,55 +29,199 @@ export class ExportService {
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<Export> { }): Promise<Export> {
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => {
return type;
});
const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.orderService.getOrders({
filters,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
withExcludedAccountsAndActivities: true
});
if (activityIds?.length > 0) {
activities = activities.filter(({ id }) => {
return activityIds.includes(id);
});
}
const where: Prisma.AccountWhereInput = { userId };
if (filtersByAccount?.length > 0) {
where.id = {
in: filtersByAccount.map(({ id }) => {
return id;
})
};
}
const accounts = ( const accounts = (
await this.accountService.accounts({ await this.accountService.accounts({
where,
include: {
balances: true,
platform: true
},
orderBy: { orderBy: {
name: 'asc' name: 'asc'
}, }
where: { userId }
}) })
).map( )
({ balance, comment, currency, id, isExcluded, name, platformId }) => { .filter(({ id }) => {
return { return activityIds?.length > 0
? activities.some(({ accountId }) => {
return accountId === id;
})
: true;
})
.map(
({
balance, balance,
balances,
comment, comment,
currency, currency,
id, id,
isExcluded, isExcluded,
name, name,
platform,
platformId platformId
}; }) => {
if (platformId) {
platformsMap[platformId] = platform;
}
return {
balance,
balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value };
}),
comment,
currency,
id,
isExcluded,
name,
platformId
};
}
);
const customAssetProfiles = uniqBy(
activities
.map(({ SymbolProfile }) => {
return SymbolProfile;
})
.filter(({ userId: assetProfileUserId }) => {
return assetProfileUserId === userId;
}),
({ id }) => {
return id;
} }
); );
let { activities } = await this.orderService.getOrders({ const marketDataByAssetProfile = Object.fromEntries(
filters, await Promise.all(
userCurrency, customAssetProfiles.map(async ({ dataSource, id, symbol }) => {
userId, const marketData = (
includeDrafts: true, await this.marketDataService.marketDataItems({
sortColumn: 'date', where: { dataSource, symbol }
sortDirection: 'asc', })
withExcludedAccounts: true ).map(({ date, marketPrice }) => ({
}); date: date.toISOString(),
marketPrice
}));
if (activityIds) { return [id, marketData] as const;
activities = activities.filter((activity) => { })
return activityIds.includes(activity.id); )
);
const tags = (await this.tagService.getTagsForUser(userId))
.filter(({ id, isUsed }) => {
return (
isUsed &&
activities.some((activity) => {
return activity.tags.some(({ id: tagId }) => {
return tagId === id;
});
})
);
})
.map(({ id, name }) => {
return {
id,
name
};
}); });
}
return { return {
meta: { date: new Date().toISOString(), version: environment.version }, meta: { date: new Date().toISOString(), version: environment.version },
accounts, accounts,
assetProfiles: customAssetProfiles.map(
({
assetClass,
assetSubClass,
comment,
countries,
currency,
cusip,
dataSource,
figi,
figiComposite,
figiShareClass,
holdings,
id,
isActive,
isin,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url
}) => {
return {
assetClass,
assetSubClass,
comment,
countries: countries as unknown as Prisma.JsonArray,
currency,
cusip,
dataSource,
figi,
figiComposite,
figiShareClass,
holdings: holdings as unknown as Prisma.JsonArray,
isActive,
isin,
marketData: marketDataByAssetProfile[id],
name,
scraperConfiguration:
scraperConfiguration as unknown as Prisma.JsonArray,
sectors: sectors as unknown as Prisma.JsonArray,
symbol,
symbolMapping,
url
};
}
),
platforms: Object.values(platformsMap),
tags,
activities: activities.map( activities: activities.map(
({ ({
accountId, accountId,
comment, comment,
currency,
date, date,
fee, fee,
id, id,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags: currentTags,
type, type,
unitPrice unitPrice
}) => { }) => {
@ -83,16 +233,13 @@ export class ExportService {
quantity, quantity,
type, type,
unitPrice, unitPrice,
currency: SymbolProfile.currency, currency: currency ?? SymbolProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toISOString(), date: date.toISOString(),
symbol: symbol: SymbolProfile.symbol,
type === 'FEE' || tags: currentTags.map(({ id: tagId }) => {
type === 'INTEREST' || return tagId;
type === 'ITEM' || })
type === 'LIABILITY'
? SymbolProfile.name
: SymbolProfile.symbol
}; };
} }
), ),

59
apps/api/src/app/health/health.controller.ts

@ -1,13 +1,20 @@
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/transform-data-source-in-request.interceptor';
import {
DataEnhancerHealthResponse,
DataProviderHealthResponse
} from '@ghostfolio/common/interfaces';
import { import {
Controller, Controller,
Get, Get,
HttpException, HttpException,
HttpStatus,
Param, Param,
Res,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { HealthService } from './health.service'; import { HealthService } from './health.service';
@ -17,26 +24,47 @@ export class HealthController {
public constructor(private readonly healthService: HealthService) {} public constructor(private readonly healthService: HealthService) {}
@Get() @Get()
public async getHealth() {} public async getHealth(@Res() response: Response) {
const databaseServiceHealthy = await this.healthService.isDatabaseHealthy();
const redisCacheServiceHealthy =
await this.healthService.isRedisCacheHealthy();
if (databaseServiceHealthy && redisCacheServiceHealthy) {
return response
.status(HttpStatus.OK)
.json({ status: getReasonPhrase(StatusCodes.OK) });
} else {
return response
.status(HttpStatus.SERVICE_UNAVAILABLE)
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
}
}
@Get('data-enhancer/:name') @Get('data-enhancer/:name')
public async getHealthOfDataEnhancer(@Param('name') name: string) { public async getHealthOfDataEnhancer(
@Param('name') name: string,
@Res() response: Response
): Promise<Response<DataEnhancerHealthResponse>> {
const hasResponse = const hasResponse =
await this.healthService.hasResponseFromDataEnhancer(name); await this.healthService.hasResponseFromDataEnhancer(name);
if (hasResponse !== true) { if (hasResponse) {
throw new HttpException( return response.status(HttpStatus.OK).json({
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), status: getReasonPhrase(StatusCodes.OK)
StatusCodes.SERVICE_UNAVAILABLE });
); } else {
return response
.status(HttpStatus.SERVICE_UNAVAILABLE)
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
} }
} }
@Get('data-provider/:dataSource') @Get('data-provider/:dataSource')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getHealthOfDataProvider( public async getHealthOfDataProvider(
@Param('dataSource') dataSource: DataSource @Param('dataSource') dataSource: DataSource,
) { @Res() response: Response
): Promise<Response<DataProviderHealthResponse>> {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
@ -47,11 +75,14 @@ export class HealthController {
const hasResponse = const hasResponse =
await this.healthService.hasResponseFromDataProvider(dataSource); await this.healthService.hasResponseFromDataProvider(dataSource);
if (hasResponse !== true) { if (hasResponse) {
throw new HttpException( return response
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), .status(HttpStatus.OK)
StatusCodes.SERVICE_UNAVAILABLE .json({ status: getReasonPhrase(StatusCodes.OK) });
); } else {
return response
.status(HttpStatus.SERVICE_UNAVAILABLE)
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
} }
} }
} }

4
apps/api/src/app/health/health.module.ts

@ -1,6 +1,8 @@
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 { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.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 { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -12,6 +14,8 @@ import { HealthService } from './health.service';
imports: [ imports: [
DataEnhancerModule, DataEnhancerModule,
DataProviderModule, DataProviderModule,
PropertyModule,
RedisCacheModule,
TransformDataSourceInRequestModule TransformDataSourceInRequestModule
], ],
providers: [HealthService] providers: [HealthService]

27
apps/api/src/app/health/health.service.ts

@ -1,5 +1,8 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -8,7 +11,9 @@ import { DataSource } from '@prisma/client';
export class HealthService { export class HealthService {
public constructor( public constructor(
private readonly dataEnhancerService: DataEnhancerService, private readonly dataEnhancerService: DataEnhancerService,
private readonly dataProviderService: DataProviderService private readonly dataProviderService: DataProviderService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService
) {} ) {}
public async hasResponseFromDataEnhancer(aName: string) { public async hasResponseFromDataEnhancer(aName: string) {
@ -18,4 +23,24 @@ export class HealthService {
public async hasResponseFromDataProvider(aDataSource: DataSource) { public async hasResponseFromDataProvider(aDataSource: DataSource) {
return this.dataProviderService.checkQuote(aDataSource); return this.dataProviderService.checkQuote(aDataSource);
} }
public async isDatabaseHealthy() {
try {
await this.propertyService.getByKey(PROPERTY_CURRENCIES);
return true;
} catch {
return false;
}
}
public async isRedisCacheHealthy() {
try {
const isHealthy = await this.redisCacheService.isHealthy();
return isHealthy;
} catch {
return false;
}
}
} }

10
apps/api/src/app/import/create-account-with-balances.dto.ts

@ -0,0 +1,10 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { AccountBalance } from '@ghostfolio/common/interfaces';
import { IsArray, IsOptional } from 'class-validator';
export class CreateAccountWithBalancesDto extends CreateAccountDto {
@IsArray()
@IsOptional()
balances?: AccountBalance[];
}

17
apps/api/src/app/import/create-asset-profile-with-market-data.dto.ts

@ -0,0 +1,17 @@
import { MarketData } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
import { IsArray, IsEnum, IsOptional } from 'class-validator';
import { CreateAssetProfileDto } from '../admin/create-asset-profile.dto';
export class CreateAssetProfileWithMarketDataDto extends CreateAssetProfileDto {
@IsEnum([DataSource.MANUAL], {
message: `dataSource must be '${DataSource.MANUAL}'`
})
dataSource: DataSource;
@IsArray()
@IsOptional()
marketData?: MarketData[];
}

23
apps/api/src/app/import/import-data.dto.ts

@ -1,18 +1,33 @@
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';
import { CreateTagDto } from '../endpoints/tags/create-tag.dto';
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto';
export class ImportDataDto { export class ImportDataDto {
@IsOptional()
@IsArray() @IsArray()
@Type(() => CreateAccountDto) @IsOptional()
@Type(() => CreateAccountWithBalancesDto)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
accounts: CreateAccountDto[]; accounts?: CreateAccountWithBalancesDto[];
@IsArray() @IsArray()
@Type(() => CreateOrderDto) @Type(() => CreateOrderDto)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
activities: CreateOrderDto[]; activities: CreateOrderDto[];
@IsArray()
@IsOptional()
@Type(() => CreateAssetProfileWithMarketDataDto)
@ValidateNested({ each: true })
assetProfiles?: CreateAssetProfileWithMarketDataDto[];
@IsArray()
@IsOptional()
@Type(() => CreateTagDto)
@ValidateNested({ each: true })
tags?: CreateTagDto[];
} }

8
apps/api/src/app/import/import.controller.ts

@ -71,8 +71,10 @@ export class ImportController {
const activities = await this.importService.import({ const activities = await this.importService.import({
isDryRun, isDryRun,
maxActivitiesToImport, maxActivitiesToImport,
accountsDto: importData.accounts ?? [], accountsWithBalancesDto: importData.accounts ?? [],
activitiesDto: importData.activities, activitiesDto: importData.activities,
assetProfilesWithMarketDataDto: importData.assetProfiles ?? [],
tagsDto: importData.tags ?? [],
user: this.request.user user: this.request.user
}); });
@ -98,12 +100,10 @@ export class ImportController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<ImportResponse> { ): Promise<ImportResponse> {
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const activities = await this.importService.getDividends({ const activities = await this.importService.getDividends({
dataSource, dataSource,
symbol, symbol,
userCurrency userId: this.request.user.id
}); });
return { activities }; return { activities };

4
apps/api/src/app/import/import.module.ts

@ -9,9 +9,11 @@ import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptor
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 { 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 { 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 { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.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 { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -27,12 +29,14 @@ import { ImportService } from './import.service';
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule,
OrderModule, OrderModule,
PlatformModule, PlatformModule,
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule TransformDataSourceInResponseModule
], ],

404
apps/api/src/app/import/import.service.ts

@ -10,16 +10,17 @@ import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT,
getAssetProfileIdentifier, getAssetProfileIdentifier,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import {
AccountWithPlatform, AccountWithPlatform,
OrderWithAccount, OrderWithAccount,
@ -29,10 +30,13 @@ import {
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, isAfter, isSameSecond, parseISO } from 'date-fns';
import { uniqBy } from 'lodash'; import { omit, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { CreateAssetProfileDto } from '../admin/create-asset-profile.dto';
import { ImportDataDto } from './import-data.dto';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
public constructor( public constructor(
@ -40,21 +44,27 @@ export class ImportService {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
private readonly platformService: PlatformService, private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService,
private readonly tagService: TagService
) {} ) {}
public async getDividends({ public async getDividends({
dataSource, dataSource,
symbol, symbol,
userCurrency userId
}: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> { }: AssetProfileIdentifier & { userId: string }): Promise<Activity[]> {
try { try {
const { firstBuyDate, historicalData, orders } = const { activities, firstBuyDate, historicalData } =
await this.portfolioService.getPosition(dataSource, undefined, symbol); await this.portfolioService.getHolding({
dataSource,
symbol,
userId,
impersonationId: undefined
});
const [[assetProfile], dividends] = await Promise.all([ const [[assetProfile], dividends] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([ this.symbolProfileService.getSymbolProfiles([
@ -72,15 +82,15 @@ export class ImportService {
}) })
]); ]);
const accounts = orders const accounts = activities
.filter(({ Account }) => { .filter(({ account }) => {
return !!Account; return !!account;
}) })
.map(({ Account }) => { .map(({ account }) => {
return Account; return account;
}); });
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
return await Promise.all( return await Promise.all(
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => { Object.entries(dividends).map(async ([dateString, { marketPrice }]) => {
@ -92,9 +102,9 @@ export class ImportService {
const value = new Big(quantity).mul(marketPrice).toNumber(); const value = new Big(quantity).mul(marketPrice).toNumber();
const date = parseDate(dateString); const date = parseDate(dateString);
const isDuplicate = orders.some((activity) => { const isDuplicate = activities.some((activity) => {
return ( return (
activity.accountId === Account?.id && activity.accountId === account?.id &&
activity.SymbolProfile.currency === assetProfile.currency && activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource && activity.SymbolProfile.dataSource === assetProfile.dataSource &&
isSameSecond(activity.date, date) && isSameSecond(activity.date, date) &&
@ -110,17 +120,18 @@ export class ImportService {
: undefined; : undefined;
return { return {
Account, account,
date, date,
error, error,
quantity, quantity,
value, value,
accountId: Account?.id, accountId: account?.id,
accountUserId: undefined, accountUserId: undefined,
comment: undefined, comment: undefined,
currency: undefined, currency: undefined,
createdAt: undefined, createdAt: undefined,
fee: 0, fee: 0,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0, feeInBaseCurrency: 0,
id: assetProfile.id, id: assetProfile.id,
isDraft: false, isDraft: false,
@ -128,15 +139,10 @@ export class ImportService {
symbolProfileId: assetProfile.id, symbolProfileId: assetProfile.id,
type: 'DIVIDEND', type: 'DIVIDEND',
unitPrice: marketPrice, unitPrice: marketPrice,
unitPriceInAssetProfileCurrency: marketPrice,
updatedAt: undefined, updatedAt: undefined,
userId: Account?.userId, userId: account?.userId,
valueInBaseCurrency: valueInBaseCurrency: value
await this.exchangeRateDataService.toCurrencyAtDate(
value,
assetProfile.currency,
userCurrency,
date
)
}; };
}) })
); );
@ -146,27 +152,33 @@ export class ImportService {
} }
public async import({ public async import({
accountsDto, accountsWithBalancesDto,
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto,
isDryRun = false, isDryRun = false,
maxActivitiesToImport, maxActivitiesToImport,
tagsDto,
user user
}: { }: {
accountsDto: Partial<CreateAccountDto>[]; accountsWithBalancesDto: ImportDataDto['accounts'];
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: ImportDataDto['activities'];
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
isDryRun?: boolean; isDryRun?: boolean;
maxActivitiesToImport: number; maxActivitiesToImport: number;
tagsDto: ImportDataDto['tags'];
user: UserWithSettings; user: UserWithSettings;
}): Promise<Activity[]> { }): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {}; const accountIdMapping: { [oldAccountId: string]: string } = {};
const userCurrency = user.Settings.settings.baseCurrency; const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {};
const tagIdMapping: { [oldTagId: string]: string } = {};
const userCurrency = user.settings.settings.baseCurrency;
if (!isDryRun && accountsDto?.length) { if (!isDryRun && accountsWithBalancesDto?.length) {
const [existingAccounts, existingPlatforms] = await Promise.all([ const [existingAccounts, existingPlatforms] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
where: { where: {
id: { id: {
in: accountsDto.map(({ id }) => { in: accountsWithBalancesDto.map(({ id }) => {
return id; return id;
}) })
} }
@ -175,14 +187,19 @@ export class ImportService {
this.platformService.getPlatforms() this.platformService.getPlatforms()
]); ]);
for (const account of accountsDto) { for (const accountWithBalances of accountsWithBalancesDto) {
// Check if there is any existing account with the same ID // Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find( const accountWithSameId = existingAccounts.find((existingAccount) => {
(existingAccount) => existingAccount.id === account.id return existingAccount.id === accountWithBalances.id;
); });
// 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 !== user.id) {
const account: CreateAccountDto = omit(
accountWithBalances,
'balances'
);
let oldAccountId: string; let oldAccountId: string;
const platformId = account.platformId; const platformId = account.platformId;
@ -195,7 +212,10 @@ export class ImportService {
let accountObject: Prisma.AccountCreateInput = { let accountObject: Prisma.AccountCreateInput = {
...account, ...account,
User: { connect: { id: user.id } } balances: {
create: accountWithBalances.balances ?? []
},
user: { connect: { id: user.id } }
}; };
if ( if (
@ -205,7 +225,7 @@ export class ImportService {
) { ) {
accountObject = { accountObject = {
...accountObject, ...accountObject,
Platform: { connect: { id: platformId } } platform: { connect: { id: platformId } }
}; };
} }
@ -222,9 +242,110 @@ export class ImportService {
} }
} }
if (!isDryRun && assetProfilesWithMarketDataDto?.length) {
const existingAssetProfiles =
await this.symbolProfileService.getSymbolProfiles(
assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
);
for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) {
// Check if there is any existing asset profile
const existingAssetProfile = existingAssetProfiles.find(
({ dataSource, symbol }) => {
return (
dataSource === assetProfileWithMarketData.dataSource &&
symbol === assetProfileWithMarketData.symbol
);
}
);
// If there is no asset profile or if the asset profile belongs to a different user, then create a new asset profile
if (!existingAssetProfile || existingAssetProfile.userId !== user.id) {
const assetProfile: CreateAssetProfileDto = omit(
assetProfileWithMarketData,
'marketData'
);
// Asset profile belongs to a different user
if (existingAssetProfile) {
const symbol = uuidv4();
assetProfileSymbolMapping[assetProfile.symbol] = symbol;
assetProfile.symbol = symbol;
}
// Create a new asset profile
const assetProfileObject: Prisma.SymbolProfileCreateInput = {
...assetProfile,
user: { connect: { id: user.id } }
};
await this.symbolProfileService.add(assetProfileObject);
}
// Insert or update market data
const marketDataObjects = assetProfileWithMarketData.marketData.map(
(marketData) => {
return {
...marketData,
dataSource: assetProfileWithMarketData.dataSource,
symbol: assetProfileWithMarketData.symbol
} as Prisma.MarketDataUpdateInput;
}
);
await this.marketDataService.updateMany({ data: marketDataObjects });
}
}
if (tagsDto?.length) {
const existingTagsOfUser = await this.tagService.getTagsForUser(user.id);
const canCreateOwnTag = hasPermission(
user.permissions,
permissions.createOwnTag
);
for (const tag of tagsDto) {
const existingTagOfUser = existingTagsOfUser.find(({ id }) => {
return id === tag.id;
});
if (!existingTagOfUser || existingTagOfUser.userId !== null) {
if (!canCreateOwnTag) {
throw new Error(
`Insufficient permissions to create custom tag ("${tag.name}")`
);
}
if (!isDryRun) {
const existingTag = await this.tagService.getTag({ id: tag.id });
let oldTagId: string;
if (existingTag) {
oldTagId = tag.id;
delete tag.id;
}
const tagObject: Prisma.TagCreateInput = {
...tag,
user: { connect: { id: user.id } }
};
const newTag = await this.tagService.createTag(tagObject);
if (existingTag && oldTagId) {
tagIdMapping[oldTagId] = newTag.id;
}
}
}
}
}
for (const activity of activitiesDto) { for (const activity of activitiesDto) {
if (!activity.dataSource) { if (!activity.dataSource) {
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') { if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) {
activity.dataSource = DataSource.MANUAL; activity.dataSource = DataSource.MANUAL;
} else { } else {
activity.dataSource = activity.dataSource =
@ -232,16 +353,27 @@ export class ImportService {
} }
} }
// If a new account is created, then update the accountId in all activities
if (!isDryRun) { if (!isDryRun) {
if (Object.keys(accountIdMapping).includes(activity.accountId)) { // If a new account is created, then update the accountId in all activities
if (accountIdMapping[activity.accountId]) {
activity.accountId = accountIdMapping[activity.accountId]; activity.accountId = accountIdMapping[activity.accountId];
} }
// If a new asset profile is created, then update the symbol in all activities
if (assetProfileSymbolMapping[activity.symbol]) {
activity.symbol = assetProfileSymbolMapping[activity.symbol];
}
// If a new tag is created, then update the tag ID in all activities
activity.tags = (activity.tags ?? []).map((tagId) => {
return tagIdMapping[tagId] ?? tagId;
});
} }
} }
const assetProfiles = await this.validateActivities({ const assetProfiles = await this.validateActivities({
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport, maxActivitiesToImport,
user user
}); });
@ -259,24 +391,43 @@ export class ImportService {
); );
if (isDryRun) { if (isDryRun) {
accountsDto.forEach(({ id, name }) => { accountsWithBalancesDto.forEach(({ id, name }) => {
accounts.push({ id, name }); accounts.push({ id, name });
}); });
} }
const tags = (await this.tagService.getTagsForUser(user.id)).map(
({ id, name }) => {
return { id, name };
}
);
if (isDryRun) {
tagsDto
.filter(({ id }) => {
return !tags.some(({ id: tagId }) => {
return tagId === id;
});
})
.forEach(({ id, name }) => {
tags.push({ id, name });
});
}
const activities: Activity[] = []; const activities: Activity[] = [];
for (const [index, activity] of activitiesExtendedWithErrors.entries()) { for (const activity of activitiesExtendedWithErrors) {
const accountId = activity.accountId; const accountId = activity.accountId;
const comment = activity.comment; const comment = activity.comment;
const currency = activity.currency; const currency = activity.currency;
const date = activity.date; const date = activity.date;
const error = activity.error; const error = activity.error;
let fee = activity.fee; const fee = activity.fee;
const quantity = activity.quantity; const quantity = activity.quantity;
const SymbolProfile = activity.SymbolProfile; const SymbolProfile = activity.SymbolProfile;
const tagIds = activity.tagIds ?? [];
const type = activity.type; const type = activity.type;
let unitPrice = activity.unitPrice; const unitPrice = activity.unitPrice;
const assetProfile = assetProfiles[ const assetProfile = assetProfiles[
getAssetProfileIdentifier({ getAssetProfileIdentifier({
@ -284,7 +435,6 @@ export class ImportService {
symbol: SymbolProfile.symbol symbol: SymbolProfile.symbol
}) })
] ?? { ] ?? {
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol symbol: SymbolProfile.symbol
}; };
@ -293,12 +443,14 @@ export class ImportService {
assetSubClass, assetSubClass,
countries, countries,
createdAt, createdAt,
cusip,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings, holdings,
id, id,
isActive,
isin, isin,
name, name,
scraperConfiguration, scraperConfiguration,
@ -311,42 +463,19 @@ export class ImportService {
const validatedAccount = accounts.find(({ id }) => { const validatedAccount = accounts.find(({ id }) => {
return id === accountId; return id === accountId;
}); });
const validatedTags = tags.filter(({ id: tagId }) => {
return tagIds.some((activityTagId) => {
return activityTagId === tagId;
});
});
let order: let order:
| OrderWithAccount | OrderWithAccount
| (Omit<OrderWithAccount, 'Account'> & { | (Omit<OrderWithAccount, 'account' | 'tags'> & {
Account?: { id: string; name: string }; account?: { id: string; name: string };
tags?: { id: string; name: string }[];
}); });
if (SymbolProfile.currency !== assetProfile.currency) {
// Convert the unit price and fee to the asset currency if the imported
// activity is in a different currency
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate(
unitPrice,
SymbolProfile.currency,
assetProfile.currency,
date
);
if (!unitPrice) {
throw new Error(
`activities.${index} historical exchange rate at ${format(
date,
DATE_FORMAT
)} is not available from "${SymbolProfile.currency}" to "${
assetProfile.currency
}"`
);
}
fee = await this.exchangeRateDataService.toCurrencyAtDate(
fee,
SymbolProfile.currency,
assetProfile.currency,
date
);
}
if (isDryRun) { if (isDryRun) {
order = { order = {
comment, comment,
@ -356,6 +485,7 @@ export class ImportService {
quantity, quantity,
type, type,
unitPrice, unitPrice,
account: validatedAccount,
accountId: validatedAccount?.id, accountId: validatedAccount?.id,
accountUserId: undefined, accountUserId: undefined,
createdAt: new Date(), createdAt: new Date(),
@ -366,12 +496,14 @@ export class ImportService {
assetSubClass, assetSubClass,
countries, countries,
createdAt, createdAt,
cusip,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,
holdings, holdings,
id, id,
isActive,
isin, isin,
name, name,
scraperConfiguration, scraperConfiguration,
@ -380,11 +512,12 @@ export class ImportService {
symbolMapping, symbolMapping,
updatedAt, updatedAt,
url, url,
comment: assetProfile.comment,
currency: assetProfile.currency, currency: assetProfile.currency,
comment: assetProfile.comment userId: dataSource === 'MANUAL' ? user.id : undefined
}, },
Account: validatedAccount,
symbolProfileId: undefined, symbolProfileId: undefined,
tags: validatedTags,
updatedAt: new Date(), updatedAt: new Date(),
userId: user.id userId: user.id
}; };
@ -395,6 +528,7 @@ export class ImportService {
order = await this.orderService.createOrder({ order = await this.orderService.createOrder({
comment, comment,
currency,
date, date,
fee, fee,
quantity, quantity,
@ -406,7 +540,8 @@ export class ImportService {
create: { create: {
dataSource, dataSource,
symbol, symbol,
currency: assetProfile.currency currency: assetProfile.currency,
userId: dataSource === 'MANUAL' ? user.id : undefined
}, },
where: { where: {
dataSource_symbol: { dataSource_symbol: {
@ -416,8 +551,11 @@ export class ImportService {
} }
} }
}, },
tags: validatedTags.map(({ id }) => {
return { id };
}),
updateAccountBalance: false, updateAccountBalance: false,
User: { connect: { id: user.id } }, user: { connect: { id: user.id } },
userId: user.id userId: user.id
}); });
@ -433,21 +571,8 @@ export class ImportService {
...order, ...order,
error, error,
value, value,
feeInBaseCurrency: await this.exchangeRateDataService.toCurrencyAtDate(
fee,
assetProfile.currency,
userCurrency,
date
),
// @ts-ignore // @ts-ignore
SymbolProfile: assetProfile, SymbolProfile: assetProfile
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
assetProfile.currency,
userCurrency,
date
)
}); });
} }
@ -493,7 +618,7 @@ export class ImportService {
userCurrency, userCurrency,
userId, userId,
includeDrafts: true, includeDrafts: true,
withExcludedAccounts: true withExcludedAccountsAndActivities: true
}); });
return activitiesDto.map( return activitiesDto.map(
@ -506,6 +631,7 @@ export class ImportService {
fee, fee,
quantity, quantity,
symbol, symbol,
tags,
type, type,
unitPrice unitPrice
}) => { }) => {
@ -513,7 +639,9 @@ export class ImportService {
const isDuplicate = existingActivities.some((activity) => { const isDuplicate = existingActivities.some((activity) => {
return ( return (
activity.accountId === accountId && activity.accountId === accountId &&
activity.SymbolProfile.currency === currency && activity.comment === comment &&
(activity.currency === currency ||
activity.SymbolProfile.currency === currency) &&
activity.SymbolProfile.dataSource === dataSource && activity.SymbolProfile.dataSource === dataSource &&
isSameSecond(activity.date, date) && isSameSecond(activity.date, date) &&
activity.fee === fee && activity.fee === fee &&
@ -531,6 +659,7 @@ export class ImportService {
return { return {
accountId, accountId,
comment, comment,
currency,
date, date,
error, error,
fee, fee,
@ -538,7 +667,6 @@ export class ImportService {
type, type,
unitPrice, unitPrice,
SymbolProfile: { SymbolProfile: {
currency,
dataSource, dataSource,
symbol, symbol,
activitiesCount: undefined, activitiesCount: undefined,
@ -546,11 +674,14 @@ export class ImportService {
assetSubClass: undefined, assetSubClass: undefined,
countries: undefined, countries: undefined,
createdAt: undefined, createdAt: undefined,
currency: undefined,
holdings: undefined, holdings: undefined,
id: undefined, id: undefined,
isActive: true,
sectors: undefined, sectors: undefined,
updatedAt: undefined updatedAt: undefined
} },
tagIds: tags
}; };
} }
); );
@ -568,10 +699,12 @@ export class ImportService {
private async validateActivities({ private async validateActivities({
activitiesDto, activitiesDto,
assetProfilesWithMarketDataDto,
maxActivitiesToImport, maxActivitiesToImport,
user user
}: { }: {
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
maxActivitiesToImport: number; maxActivitiesToImport: number;
user: UserWithSettings; user: UserWithSettings;
}) { }) {
@ -582,12 +715,13 @@ export class ImportService {
const assetProfiles: { const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>; [assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {}; } = {};
const dataSources = await this.dataProviderService.getDataSources();
for (const [ for (const [
index, index,
{ currency, dataSource, symbol, type } { currency, dataSource, symbol, type }
] of activitiesDto.entries()) { ] of activitiesDto.entries()) {
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) { if (!dataSources.includes(dataSource)) {
throw new Error( throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid` `activities.${index}.dataSource ("${dataSource}") is not valid`
); );
@ -609,25 +743,67 @@ export class ImportService {
} }
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) { if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
const assetProfile = { if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
currency, // Skip asset profile validation for FEE, INTEREST, and LIABILITY
...( // as these activity types don't require asset profiles
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = {
currency,
dataSource,
symbol
};
continue;
}
let assetProfile: Partial<SymbolProfile> = { currency };
try {
assetProfile = (
await this.dataProviderService.getAssetProfiles([ await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol } { dataSource, symbol }
]) ])
)?.[symbol] )?.[symbol];
}; } catch {}
if (!assetProfile?.name) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') { if (assetProfileInImport) {
if (!assetProfile?.name) { // Merge all fields of custom asset profiles into the validation object
throw new Error( Object.assign(assetProfile, {
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` assetClass: assetProfileInImport.assetClass,
); assetSubClass: assetProfileInImport.assetSubClass,
comment: assetProfileInImport.comment,
countries: assetProfileInImport.countries,
currency: assetProfileInImport.currency,
cusip: assetProfileInImport.cusip,
dataSource: assetProfileInImport.dataSource,
figi: assetProfileInImport.figi,
figiComposite: assetProfileInImport.figiComposite,
figiShareClass: assetProfileInImport.figiShareClass,
holdings: assetProfileInImport.holdings,
isActive: assetProfileInImport.isActive,
isin: assetProfileInImport.isin,
name: assetProfileInImport.name,
scraperConfiguration: assetProfileInImport.scraperConfiguration,
sectors: assetProfileInImport.sectors,
symbol: assetProfileInImport.symbol,
symbolMapping: assetProfileInImport.symbolMapping,
url: assetProfileInImport.url
});
} }
}
if (assetProfile.currency !== currency) { if (!['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
} }
} }

4
apps/api/src/app/info/info.module.ts

@ -1,8 +1,9 @@
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 { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { 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';
@ -31,6 +32,7 @@ import { InfoService } from './info.service';
PlatformModule, PlatformModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SubscriptionModule,
SymbolProfileModule, SymbolProfileModule,
TransformDataSourceInResponseModule, TransformDataSourceInResponseModule,
UserModule UserModule

136
apps/api/src/app/info/info.service.ts

@ -1,38 +1,33 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
HEADER_KEY_TOKEN,
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,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS, PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG, ghostfolioFearAndGreedIndexDataSourceStocks
ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
encodeDataSource, encodeDataSource,
extractNumberFromString extractNumberFromString
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
InfoItem,
Statistics,
Subscription
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOffer } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
@ -46,6 +41,7 @@ export class InfoService {
private readonly platformService: PlatformService, private readonly platformService: PlatformService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly subscriptionService: SubscriptionService,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -58,19 +54,20 @@ export class InfoService {
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(
ghostfolioFearAndGreedIndexDataSource ghostfolioFearAndGreedIndexDataSourceStocks
); );
} else { } else {
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource; info.fearAndGreedDataSource =
ghostfolioFearAndGreedIndexDataSourceStocks;
} }
globalPermissions.push(permissions.enableFearAndGreedIndex); globalPermissions.push(permissions.enableFearAndGreedIndex);
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
isReadOnlyMode = (await this.propertyService.getByKey( isReadOnlyMode = await this.propertyService.getByKey<boolean>(
PROPERTY_IS_READ_ONLY_MODE PROPERTY_IS_READ_ONLY_MODE
)) as boolean; );
} }
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) { if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
@ -85,9 +82,9 @@ export class InfoService {
globalPermissions.push(permissions.enableSubscription); globalPermissions.push(permissions.enableSubscription);
info.countriesOfSubscribers = info.countriesOfSubscribers =
((await this.propertyService.getByKey( (await this.propertyService.getByKey<string[]>(
PROPERTY_COUNTRIES_OF_SUBSCRIBERS PROPERTY_COUNTRIES_OF_SUBSCRIBERS
)) as string[]) ?? []; )) ?? [];
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY'); info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
} }
@ -101,7 +98,7 @@ export class InfoService {
isUserSignupEnabled, isUserSignupEnabled,
platforms, platforms,
statistics, statistics,
subscriptions subscriptionOffer
] = await Promise.all([ ] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(), this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(), this.getDemoAuthToken(),
@ -110,7 +107,7 @@ export class InfoService {
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getStatistics(), this.getStatistics(),
this.getSubscriptions() this.subscriptionService.getSubscriptionOffer({ key: 'default' })
]); ]);
if (isUserSignupEnabled) { if (isUserSignupEnabled) {
@ -125,7 +122,7 @@ export class InfoService {
isReadOnlyMode, isReadOnlyMode,
platforms, platforms,
statistics, statistics,
subscriptions, subscriptionOffer,
baseCurrency: DEFAULT_CURRENCY, baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies() currencies: this.exchangeRateDataService.getCurrencies()
}; };
@ -137,12 +134,12 @@ export class InfoService {
AND: [ AND: [
{ {
NOT: { NOT: {
Analytics: null analytics: null
} }
}, },
{ {
Analytics: { analytics: {
updatedAt: { lastRequestAt: {
gt: subDays(new Date(), aDays) gt: subDays(new Date(), aDays)
} }
} }
@ -154,20 +151,15 @@ export class InfoService {
private async countDockerHubPulls(): Promise<number> { private async countDockerHubPulls(): Promise<number> {
try { try {
const abortController = new AbortController(); const { pull_count } = (await fetch(
'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio',
setTimeout(() => {
abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { pull_count } = await got(
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
{ {
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
// @ts-ignore signal: AbortSignal.timeout(
signal: abortController.signal this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).json<any>(); ).then((res) => res.json())) as { pull_count: number };
return pull_count; return pull_count;
} catch (error) { } catch (error) {
@ -179,22 +171,17 @@ export class InfoService {
private async countGitHubContributors(): Promise<number> { private async countGitHubContributors(): Promise<number> {
try { try {
const abortController = new AbortController(); const body = await fetch('https://github.com/ghostfolio/ghostfolio', {
signal: AbortSignal.timeout(
setTimeout(() => { this.configurationService.get('REQUEST_TIMEOUT')
abortController.abort(); )
}, this.configurationService.get('REQUEST_TIMEOUT')); }).then((res) => res.text());
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
// @ts-ignore
signal: abortController.signal
});
const $ = cheerio.load(body); const $ = cheerio.load(body);
return extractNumberFromString({ return extractNumberFromString({
value: $( value: $(
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter` 'a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter'
).text() ).text()
}); });
} catch (error) { } catch (error) {
@ -206,20 +193,15 @@ export class InfoService {
private async countGitHubStargazers(): Promise<number> { private async countGitHubStargazers(): Promise<number> {
try { try {
const abortController = new AbortController(); const { stargazers_count } = (await fetch(
'https://api.github.com/repos/ghostfolio/ghostfolio',
setTimeout(() => {
abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { stargazers_count } = await got(
`https://api.github.com/repos/ghostfolio/ghostfolio`,
{ {
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
// @ts-ignore signal: AbortSignal.timeout(
signal: abortController.signal this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).json<any>(); ).then((res) => res.json())) as { stargazers_count: number };
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
@ -235,7 +217,7 @@ export class InfoService {
AND: [ AND: [
{ {
NOT: { NOT: {
Analytics: null analytics: null
} }
}, },
{ {
@ -249,15 +231,15 @@ export class InfoService {
} }
private async countSlackCommunityUsers() { private async countSlackCommunityUsers() {
return (await this.propertyService.getByKey( return await this.propertyService.getByKey<string>(
PROPERTY_SLACK_COMMUNITY_USERS PROPERTY_SLACK_COMMUNITY_USERS
)) as string; );
} }
private async getDemoAuthToken() { private async getDemoAuthToken() {
const demoUserId = (await this.propertyService.getByKey( const demoUserId = await this.propertyService.getByKey<string>(
PROPERTY_DEMO_USER_ID PROPERTY_DEMO_USER_ID
)) as string; );
if (demoUserId) { if (demoUserId) {
return this.jwtService.sign({ return this.jwtService.sign({
@ -314,47 +296,29 @@ export class InfoService {
return statistics; return statistics;
} }
private async getSubscriptions(): Promise<{
[offer in SubscriptionOffer]: Subscription;
}> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
return (
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
{}
);
}
private async getUptime(): Promise<number> { private async getUptime(): Promise<number> {
{ {
try { try {
const monitorId = (await this.propertyService.getByKey( const monitorId = await this.propertyService.getByKey<string>(
PROPERTY_BETTER_UPTIME_MONITOR_ID PROPERTY_BETTER_UPTIME_MONITOR_ID
)) as string; );
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { data } = await got( const { data } = await fetch(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90), subDays(new Date(), 90),
DATE_FORMAT DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`, )}&to${format(new Date(), DATE_FORMAT)}`,
{ {
headers: { headers: {
Authorization: `Bearer ${this.configurationService.get( [HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
'API_KEY_BETTER_UPTIME' 'API_KEY_BETTER_UPTIME'
)}` )}`
}, },
// @ts-ignore signal: AbortSignal.timeout(
signal: abortController.signal this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).json<any>(); ).then((res) => res.json());
return data.attributes.availability / 100; return data.attributes.availability / 100;
} catch (error) { } catch (error) {

15
apps/api/src/app/logo/logo.controller.ts

@ -26,12 +26,13 @@ export class LogoController {
@Res() response: Response @Res() response: Response
) { ) {
try { try {
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({ const { buffer, type } =
dataSource, await this.logoService.getLogoByDataSourceAndSymbol({
symbol dataSource,
}); symbol
});
response.contentType('image/png'); response.contentType(type);
response.send(buffer); response.send(buffer);
} catch { } catch {
response.status(HttpStatus.NOT_FOUND).send(); response.status(HttpStatus.NOT_FOUND).send();
@ -44,9 +45,9 @@ export class LogoController {
@Res() response: Response @Res() response: Response
) { ) {
try { try {
const buffer = await this.logoService.getLogoByUrl(url); const { buffer, type } = await this.logoService.getLogoByUrl(url);
response.contentType('image/png'); response.contentType(type);
response.send(buffer); response.send(buffer);
} catch { } catch {
response.status(HttpStatus.NOT_FOUND).send(); response.status(HttpStatus.NOT_FOUND).send();

29
apps/api/src/app/logo/logo.service.ts

@ -4,7 +4,6 @@ import { AssetProfileIdentifier } 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 { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable() @Injectable()
@ -29,7 +28,7 @@ export class LogoService {
{ dataSource, symbol } { dataSource, symbol }
]); ]);
if (!assetProfile) { if (!assetProfile?.url) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND StatusCodes.NOT_FOUND
@ -39,24 +38,26 @@ export class LogoService {
return this.getBuffer(assetProfile.url); return this.getBuffer(assetProfile.url);
} }
public async getLogoByUrl(aUrl: string) { public getLogoByUrl(aUrl: string) {
return this.getBuffer(aUrl); return this.getBuffer(aUrl);
} }
private getBuffer(aUrl: string) { private async getBuffer(aUrl: string) {
const abortController = new AbortController(); const blob = await fetch(
setTimeout(() => {
abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT'));
return got(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
{ {
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
// @ts-ignore signal: AbortSignal.timeout(
signal: abortController.signal this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).buffer(); ).then((res) => res.blob());
return {
buffer: await blob.arrayBuffer().then((arrayBuffer) => {
return Buffer.from(arrayBuffer);
}),
type: blob.type
};
} }
} }

19
apps/api/src/app/order/create-order.dto.ts

@ -1,13 +1,7 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970'; import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import { import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsArray, IsArray,
@ -27,12 +21,12 @@ export class CreateOrderDto {
@IsString() @IsString()
accountId?: string; accountId?: string;
@IsOptional()
@IsEnum(AssetClass, { each: true }) @IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass; assetClass?: AssetClass;
@IsOptional()
@IsEnum(AssetSubClass, { each: true }) @IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
@IsOptional() @IsOptional()
@ -49,9 +43,8 @@ export class CreateOrderDto {
@IsOptional() @IsOptional()
customCurrency?: string; customCurrency?: string;
@IsOptional() @IsEnum(DataSource)
@IsEnum(DataSource, { each: true }) dataSource: DataSource;
dataSource?: DataSource;
@IsISO8601() @IsISO8601()
@Validate(IsAfter1970Constraint) @Validate(IsAfter1970Constraint)
@ -70,7 +63,7 @@ export class CreateOrderDto {
@IsArray() @IsArray()
@IsOptional() @IsOptional()
tags?: Tag[]; tags?: string[];
@IsEnum(Type, { each: true }) @IsEnum(Type, { each: true })
type: Type; type: Type;

5
apps/api/src/app/order/interfaces/activities.interface.ts

@ -9,11 +9,14 @@ export interface Activities {
} }
export interface Activity extends Order { export interface Activity extends Order {
Account?: AccountWithPlatform; account?: AccountWithPlatform;
error?: ActivityError; error?: ActivityError;
feeInAssetProfileCurrency: number;
feeInBaseCurrency: number; feeInBaseCurrency: number;
SymbolProfile?: EnhancedSymbolProfile; SymbolProfile?: EnhancedSymbolProfile;
tagIds?: string[];
tags?: Tag[]; tags?: Tag[];
unitPriceInAssetProfileCurrency: number;
updateAccountBalance?: boolean; updateAccountBalance?: boolean;
value: number; value: number;
valueInBaseCurrency: number; valueInBaseCurrency: number;

45
apps/api/src/app/order/order.controller.ts

@ -53,14 +53,19 @@ export class OrderController {
@Delete() @Delete()
@HasPermission(permissions.deleteOrder) @HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteOrders( public async deleteOrders(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<number> { ): Promise<number> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });
@ -97,7 +102,7 @@ export class OrderController {
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders( public async getAllOrders(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@ -126,7 +131,7 @@ export class OrderController {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
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, endDate,
@ -139,7 +144,7 @@ export class OrderController {
skip: isNaN(skip) ? undefined : skip, skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take, take: isNaN(take) ? undefined : take,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true withExcludedAccountsAndActivities: true
}); });
return { activities, count }; return { activities, count };
@ -150,17 +155,17 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getOrderById( public async getOrderById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string @Param('id') id: string
): Promise<Activity> { ): Promise<Activity> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
userCurrency, userCurrency,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true withExcludedAccountsAndActivities: true
}); });
const activity = activities.find((activity) => { const activity = activities.find((activity) => {
@ -184,6 +189,7 @@ export class OrderController {
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
const currency = data.currency; const currency = data.currency;
const customCurrency = data.customCurrency; const customCurrency = data.customCurrency;
const dataSource = data.dataSource;
if (customCurrency) { if (customCurrency) {
data.currency = customCurrency; data.currency = customCurrency;
@ -191,6 +197,8 @@ export class OrderController {
delete data.customCurrency; delete data.customCurrency;
} }
delete data.dataSource;
const order = await this.orderService.createOrder({ const order = await this.orderService.createOrder({
...data, ...data,
date: parseISO(data.date), date: parseISO(data.date),
@ -198,28 +206,31 @@ export class OrderController {
connectOrCreate: { connectOrCreate: {
create: { create: {
currency, currency,
dataSource: data.dataSource, dataSource,
symbol: data.symbol symbol: data.symbol
}, },
where: { where: {
dataSource_symbol: { dataSource_symbol: {
dataSource: data.dataSource, dataSource,
symbol: data.symbol symbol: data.symbol
} }
} }
} }
}, },
User: { connect: { id: this.request.user.id } }, tags: data.tags?.map((id) => {
return { id };
}),
user: { connect: { id: this.request.user.id } },
userId: this.request.user.id userId: this.request.user.id
}); });
if (data.dataSource && !order.isDraft) { if (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: [ dataGatheringItems: [
{ {
dataSource: data.dataSource, dataSource,
date: order.date, date: order.date,
symbol: data.symbol symbol: data.symbol
} }
@ -251,6 +262,7 @@ export class OrderController {
const accountId = data.accountId; const accountId = data.accountId;
const customCurrency = data.customCurrency; const customCurrency = data.customCurrency;
const dataSource = data.dataSource;
delete data.accountId; delete data.accountId;
@ -260,11 +272,13 @@ export class OrderController {
delete data.customCurrency; delete data.customCurrency;
} }
delete data.dataSource;
return this.orderService.updateOrder({ return this.orderService.updateOrder({
data: { data: {
...data, ...data,
date, date,
Account: { account: {
connect: { connect: {
id_userId: { id: accountId, userId: this.request.user.id } id_userId: { id: accountId, userId: this.request.user.id }
} }
@ -272,7 +286,7 @@ export class OrderController {
SymbolProfile: { SymbolProfile: {
connect: { connect: {
dataSource_symbol: { dataSource_symbol: {
dataSource: data.dataSource, dataSource,
symbol: data.symbol symbol: data.symbol
} }
}, },
@ -282,7 +296,10 @@ export class OrderController {
name: data.symbol name: data.symbol
} }
}, },
User: { connect: { id: this.request.user.id } } tags: data.tags?.map((id) => {
return { id };
}),
user: { connect: { id: this.request.user.id } }
}, },
where: { where: {
id id

203
apps/api/src/app/order/order.service.ts

@ -7,8 +7,10 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
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, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
ghostfolioPrefix,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
@ -30,6 +32,7 @@ import {
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -63,14 +66,14 @@ export class OrderService {
} }
}); });
return Promise.all( await Promise.all(
orders.map(({ id }) => orders.map(({ id }) =>
this.prismaService.order.update({ this.prismaService.order.update({
data: { data: {
tags: { tags: {
// The set operation replaces all existing connections with the provided ones // The set operation replaces all existing connections with the provided ones
set: tags.map(({ id }) => { set: tags.map((tag) => {
return { id }; return { id: tag.id };
}) })
} }
}, },
@ -78,6 +81,13 @@ export class OrderService {
}) })
) )
); );
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
} }
public async createOrder( public async createOrder(
@ -86,17 +96,16 @@ export class OrderService {
assetClass?: AssetClass; assetClass?: AssetClass;
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
currency?: string; currency?: string;
dataSource?: DataSource;
symbol?: string; symbol?: string;
tags?: Tag[]; tags?: { id: string }[];
updateAccountBalance?: boolean; updateAccountBalance?: boolean;
userId: string; userId: string;
} }
): Promise<Order> { ): Promise<Order> {
let Account; let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput;
if (data.accountId) { if (data.accountId) {
Account = { account = {
connect: { connect: {
id_userId: { id_userId: {
userId: data.userId, userId: data.userId,
@ -111,22 +120,41 @@ export class OrderService {
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 (
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) ||
(data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' &&
data.type === 'BUY')
) {
const assetClass = data.assetClass; const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
const dataSource: DataSource = 'MANUAL'; const dataSource: DataSource = 'MANUAL';
const id = uuidv4();
const name = data.SymbolProfile.connectOrCreate.create.symbol;
data.id = id; let name: string;
let symbol: string;
if (
data.SymbolProfile.connectOrCreate.create.symbol.startsWith(
`${ghostfolioPrefix}_`
) ||
isUUID(data.SymbolProfile.connectOrCreate.create.symbol)
) {
// Connect custom asset profile (clone)
symbol = data.SymbolProfile.connectOrCreate.create.symbol;
} else {
// Create custom asset profile
name = data.SymbolProfile.connectOrCreate.create.symbol;
symbol = uuidv4();
}
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.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 = symbol;
data.SymbolProfile.connectOrCreate.create.userId = userId;
data.SymbolProfile.connectOrCreate.where.dataSource_symbol = { data.SymbolProfile.connectOrCreate.where.dataSource_symbol = {
dataSource, dataSource,
symbol: id symbol
}; };
} }
@ -136,9 +164,9 @@ export class OrderService {
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
}, },
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
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
@ -156,7 +184,6 @@ export class OrderService {
delete data.comment; delete data.comment;
} }
delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
delete data.updateAccountBalance; delete data.updateAccountBalance;
@ -164,19 +191,17 @@ export class OrderService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type) const isDraft = ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type)
? false ? false
: isAfter(data.date as Date, endOfToday()); : isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({ const order = await this.prismaService.order.create({
data: { data: {
...orderData, ...orderData,
Account, account,
isDraft, isDraft,
tags: { tags: {
connect: tags.map(({ id }) => { connect: tags
return { id };
})
} }
}, },
include: { SymbolProfile: true } include: { SymbolProfile: true }
@ -223,10 +248,7 @@ export class OrderService {
order.symbolProfileId order.symbolProfileId
]); ]);
if ( if (symbolProfile.activitiesCount === 0) {
['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type) ||
symbolProfile.activitiesCount === 0
) {
await this.symbolProfileService.deleteById(order.symbolProfileId); await this.symbolProfileService.deleteById(order.symbolProfileId);
} }
@ -252,7 +274,7 @@ export class OrderService {
userId, userId,
includeDrafts: true, includeDrafts: true,
userCurrency: undefined, userCurrency: undefined,
withExcludedAccounts: true withExcludedAccountsAndActivities: true
}); });
const { count } = await this.prismaService.order.deleteMany({ const { count } = await this.prismaService.order.deleteMany({
@ -303,13 +325,13 @@ export class OrderService {
includeDrafts = false, includeDrafts = false,
skip, skip,
sortColumn, sortColumn,
sortDirection, sortDirection = 'asc',
startDate, startDate,
take = Number.MAX_SAFE_INTEGER, take = Number.MAX_SAFE_INTEGER,
types, types,
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts = false withExcludedAccountsAndActivities = false
}: { }: {
endDate?: Date; endDate?: Date;
filters?: Filter[]; filters?: Filter[];
@ -322,12 +344,12 @@ export class OrderService {
types?: ActivityType[]; types?: ActivityType[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccountsAndActivities?: 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) { if (endDate || startDate) {
@ -410,7 +432,7 @@ export class OrderService {
where.SymbolProfile, where.SymbolProfile,
{ {
AND: [ AND: [
{ dataSource: <DataSource>filterByDataSource }, { dataSource: filterByDataSource as DataSource },
{ symbol: filterBySymbol } { symbol: filterBySymbol }
] ]
} }
@ -419,7 +441,7 @@ export class OrderService {
} else { } else {
where.SymbolProfile = { where.SymbolProfile = {
AND: [ AND: [
{ dataSource: <DataSource>filterByDataSource }, { dataSource: filterByDataSource as DataSource },
{ symbol: filterBySymbol } { symbol: filterBySymbol }
] ]
}; };
@ -461,37 +483,43 @@ export class OrderService {
} }
if (sortColumn) { if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
} }
if (types) { if (types) {
where.type = { in: types }; where.type = { in: types };
} }
if (withExcludedAccounts === false) { if (withExcludedAccountsAndActivities === false) {
where.OR = [ where.OR = [
{ Account: null }, { account: null },
{ Account: { NOT: { isExcluded: true } } } { account: { NOT: { isExcluded: true } } }
]; ];
where.tags = {
...where.tags,
none: {
id: TAG_ID_EXCLUDE_FROM_ANALYSIS
}
};
} }
const [orders, count] = await Promise.all([ const [orders, count] = await Promise.all([
this.orders({ this.orders({
orderBy,
skip, skip,
take, take,
where, where,
include: { include: {
// eslint-disable-next-line @typescript-eslint/naming-convention account: {
Account: {
include: { include: {
Platform: true platform: true
} }
}, },
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true, SymbolProfile: true,
tags: true tags: true
} },
orderBy: [...orderBy, { id: sortDirection }]
}), }),
this.prismaService.order.count({ where }) this.prismaService.order.count({ where })
]); ]);
@ -526,24 +554,46 @@ export class OrderService {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
const [
feeInAssetProfileCurrency,
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,
valueInBaseCurrency
] = await Promise.all([
this.exchangeRateDataService.toCurrencyAtDate(
order.fee,
order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency,
order.date
),
this.exchangeRateDataService.toCurrencyAtDate(
order.fee,
order.currency ?? order.SymbolProfile.currency,
userCurrency,
order.date
),
this.exchangeRateDataService.toCurrencyAtDate(
order.unitPrice,
order.currency ?? order.SymbolProfile.currency,
order.SymbolProfile.currency,
order.date
),
this.exchangeRateDataService.toCurrencyAtDate(
value,
order.currency ?? order.SymbolProfile.currency,
userCurrency,
order.date
)
]);
return { return {
...order, ...order,
feeInAssetProfileCurrency,
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,
value, value,
feeInBaseCurrency: valueInBaseCurrency,
await this.exchangeRateDataService.toCurrencyAtDate( SymbolProfile: assetProfile
order.fee,
order.SymbolProfile.currency,
userCurrency,
order.date
),
SymbolProfile: assetProfile,
valueInBaseCurrency:
await this.exchangeRateDataService.toCurrencyAtDate(
value,
order.SymbolProfile.currency,
userCurrency,
order.date
)
}; };
}) })
); );
@ -565,7 +615,7 @@ export class OrderService {
filters, filters,
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts: false // TODO withExcludedAccountsAndActivities: false // TODO
}); });
} }
@ -605,9 +655,8 @@ export class OrderService {
assetClass?: AssetClass; assetClass?: AssetClass;
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
currency?: string; currency?: string;
dataSource?: DataSource;
symbol?: string; symbol?: string;
tags?: Tag[]; tags?: { id: string }[];
type?: ActivityType; type?: ActivityType;
}; };
where: Prisma.OrderWhereUniqueInput; where: Prisma.OrderWhereUniqueInput;
@ -620,12 +669,17 @@ export class OrderService {
let isDraft = false; let isDraft = false;
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) { if (
delete data.SymbolProfile.connect; ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) ||
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' &&
if (data.Account?.connect?.id_userId?.id === null) { data.type === 'BUY')
data.Account = { disconnect: true }; ) {
if (data.account?.connect?.id_userId?.id === null) {
data.account = { disconnect: true };
} }
delete data.SymbolProfile.connect;
delete data.SymbolProfile.update.name;
} else { } else {
delete data.SymbolProfile.update; delete data.SymbolProfile.update;
@ -638,7 +692,7 @@ export class OrderService {
{ {
dataSource: dataSource:
data.SymbolProfile.connect.dataSource_symbol.dataSource, data.SymbolProfile.connect.dataSource_symbol.dataSource,
date: <Date>data.date, date: data.date as Date,
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
} }
], ],
@ -649,27 +703,24 @@ export class OrderService {
delete data.assetClass; delete data.assetClass;
delete data.assetSubClass; delete data.assetSubClass;
delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
// Remove existing tags // Remove existing tags
await this.prismaService.order.update({ await this.prismaService.order.update({
data: { tags: { set: [] } }, where,
where data: { tags: { set: [] } }
}); });
const order = await this.prismaService.order.update({ const order = await this.prismaService.order.update({
where,
data: { data: {
...data, ...data,
isDraft, isDraft,
tags: { tags: {
connect: tags.map(({ id }) => { connect: tags
return { id };
})
} }
}, }
where
}); });
this.eventEmitter.emit( this.eventEmitter.emit(

10
apps/api/src/app/order/update-order.dto.ts

@ -1,13 +1,7 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970'; import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import { import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsArray, IsArray,
@ -71,7 +65,7 @@ export class UpdateOrderDto {
@IsArray() @IsArray()
@IsOptional() @IsOptional()
tags?: Tag[]; tags?: string[];
@IsString() @IsString()
type: Type; type: Type;

4
apps/api/src/app/platform/platform.service.ts

@ -54,7 +54,7 @@ export class PlatformService {
await this.prismaService.platform.findMany({ await this.prismaService.platform.findMany({
include: { include: {
_count: { _count: {
select: { Account: true } select: { accounts: true }
} }
} }
}); });
@ -64,7 +64,7 @@ export class PlatformService {
id, id,
name, name,
url, url,
accountCount: _count.Account accountCount: _count.accounts
}; };
}); });
} }

7
apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts

@ -4,12 +4,17 @@ import {
SymbolMetrics SymbolMetrics
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
export class MWRPortfolioCalculator extends PortfolioCalculator { export class MwrPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot { protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }
protected getPerformanceCalculationType() {
return PerformanceCalculationType.MWR;
}
protected getSymbolMetrics({}: { protected getSymbolMetrics({}: {
end: Date; end: Date;
exchangeRates: { [dateString: string]: number }; exchangeRates: { [dateString: string]: number };

13
apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts

@ -1,4 +1,6 @@
import { readFileSync } from 'fs'; import { Export } from '@ghostfolio/common/interfaces';
import { readFileSync } from 'node:fs';
export const activityDummyData = { export const activityDummyData = {
accountId: undefined, accountId: undefined,
@ -6,10 +8,14 @@ export const activityDummyData = {
comment: undefined, comment: undefined,
createdAt: new Date(), createdAt: new Date(),
currency: undefined, currency: undefined,
fee: undefined,
feeInAssetProfileCurrency: undefined,
feeInBaseCurrency: undefined, feeInBaseCurrency: undefined,
id: undefined, id: undefined,
isDraft: false, isDraft: false,
symbolProfileId: undefined, symbolProfileId: undefined,
unitPrice: undefined,
unitPriceInAssetProfileCurrency: undefined,
updatedAt: new Date(), updatedAt: new Date(),
userId: undefined, userId: undefined,
value: undefined, value: undefined,
@ -24,6 +30,7 @@ export const symbolProfileDummyData = {
createdAt: undefined, createdAt: undefined,
holdings: [], holdings: [],
id: undefined, id: undefined,
isActive: true,
sectors: [], sectors: [],
updatedAt: undefined updatedAt: undefined
}; };
@ -32,6 +39,6 @@ export const userDummyData = {
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
}; };
export function loadActivityExportFile(filePath: string) { export function loadExportFile(filePath: string): Export {
return JSON.parse(readFileSync(filePath, 'utf8')).activities; return JSON.parse(readFileSync(filePath, 'utf8'));
} }

48
apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts

@ -5,17 +5,15 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator'; import { MwrPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator';
import { TWRPortfolioCalculator } from './twr/portfolio-calculator'; import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
import { RoiPortfolioCalculator } from './roi/portfolio-calculator';
export enum PerformanceCalculationType { import { TwrPortfolioCalculator } from './twr/portfolio-calculator';
MWR = 'MWR', // Money-Weighted Rate of Return
TWR = 'TWR' // Time-Weighted Rate of Return
}
@Injectable() @Injectable()
export class PortfolioCalculatorFactory { export class PortfolioCalculatorFactory {
@ -44,7 +42,7 @@ export class PortfolioCalculatorFactory {
}): PortfolioCalculator { }): PortfolioCalculator {
switch (calculationType) { switch (calculationType) {
case PerformanceCalculationType.MWR: case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({ return new MwrPortfolioCalculator({
accountBalanceItems, accountBalanceItems,
activities, activities,
currency, currency,
@ -56,19 +54,49 @@ export class PortfolioCalculatorFactory {
portfolioSnapshotService: this.portfolioSnapshotService, portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService redisCacheService: this.redisCacheService
}); });
case PerformanceCalculationType.TWR:
return new TWRPortfolioCalculator({ case PerformanceCalculationType.ROAI:
return new RoaiPortfolioCalculator({
accountBalanceItems,
activities,
currency,
filters,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.ROI:
return new RoiPortfolioCalculator({
accountBalanceItems, accountBalanceItems,
activities, activities,
currency, currency,
filters,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.TWR:
return new TwrPortfolioCalculator({
accountBalanceItems,
activities,
currency,
filters, filters,
userId, userId,
configurationService: this.configurationService, configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService, exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService, portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService redisCacheService: this.redisCacheService
}); });
default: default:
throw new Error('Invalid calculation type'); throw new Error('Invalid calculation type');
} }

143
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -13,6 +13,7 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
INVESTMENT_ACTIVITY_TYPES,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH, PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH,
@ -35,6 +36,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { GroupBy } from '@ghostfolio/common/types'; import { GroupBy } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Big } from 'big.js'; import { Big } from 'big.js';
@ -49,7 +51,7 @@ import {
min, min,
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { first, isNumber, last, sortBy, sum, uniq, uniqBy } from 'lodash'; import { isNumber, sortBy, sum, uniqBy } from 'lodash';
export abstract class PortfolioCalculator { export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false; protected static readonly ENABLE_LOGGING = false;
@ -112,12 +114,12 @@ export abstract class PortfolioCalculator {
.map( .map(
({ ({
date, date,
fee, feeInAssetProfileCurrency,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags = [], tags = [],
type, type,
unitPrice unitPriceInAssetProfileCurrency
}) => { }) => {
if (isBefore(date, dateOfFirstActivity)) { if (isBefore(date, dateOfFirstActivity)) {
dateOfFirstActivity = date; dateOfFirstActivity = date;
@ -134,9 +136,9 @@ export abstract class PortfolioCalculator {
tags, tags,
type, type,
date: format(date, DATE_FORMAT), date: format(date, DATE_FORMAT),
fee: new Big(fee), fee: new Big(feeInAssetProfileCurrency),
quantity: new Big(quantity), quantity: new Big(quantity),
unitPrice: new Big(unitPrice) unitPrice: new Big(unitPriceInAssetProfileCurrency)
}; };
} }
) )
@ -167,7 +169,7 @@ export abstract class PortfolioCalculator {
@LogPerformance @LogPerformance
public async computeSnapshot(): Promise<PortfolioSnapshot> { public async computeSnapshot(): Promise<PortfolioSnapshot> {
const lastTransactionPoint = last(this.transactionPoints); const lastTransactionPoint = this.transactionPoints.at(-1);
const transactionPoints = this.transactionPoints?.filter(({ date }) => { const transactionPoints = this.transactionPoints?.filter(({ date }) => {
return isBefore(parseDate(date), this.endDate); return isBefore(parseDate(date), this.endDate);
@ -175,7 +177,10 @@ export abstract class PortfolioCalculator {
if (!transactionPoints.length) { if (!transactionPoints.length) {
return { return {
activitiesCount: 0,
createdAt: new Date(),
currentValueInBaseCurrency: new Big(0), currentValueInBaseCurrency: new Big(0),
errors: [],
hasErrors: false, hasErrors: false,
historicalData: [], historicalData: [],
positions: [], positions: [],
@ -183,8 +188,7 @@ export abstract class PortfolioCalculator {
totalInterestWithCurrencyEffect: new Big(0), totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(0), totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0), totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big(0), totalLiabilitiesWithCurrencyEffect: new Big(0)
totalValuablesWithCurrencyEffect: new Big(0)
}; };
} }
@ -194,7 +198,6 @@ export abstract class PortfolioCalculator {
let firstTransactionPoint: TransactionPoint = null; let firstTransactionPoint: TransactionPoint = null;
let totalInterestWithCurrencyEffect = new Big(0); let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0); let totalLiabilitiesWithCurrencyEffect = new Big(0);
let totalValuablesWithCurrencyEffect = new Big(0);
for (const { currency, dataSource, symbol } of transactionPoints[ for (const { currency, dataSource, symbol } of transactionPoints[
firstIndex - 1 firstIndex - 1
@ -219,7 +222,7 @@ export abstract class PortfolioCalculator {
const exchangeRatesByCurrency = const exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({ await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: uniq(Object.values(currencies)), currencies: Array.from(new Set(Object.values(currencies))),
endDate: endOfDay(this.endDate), endDate: endOfDay(this.endDate),
startDate: this.startDate, startDate: this.startDate,
targetCurrency: this.currency targetCurrency: this.currency
@ -285,10 +288,12 @@ export abstract class PortfolioCalculator {
firstIndex--; firstIndex--;
} }
const positions: TimelinePosition[] = []; const errors: ResponseError['errors'] = [];
let hasAnySymbolMetricsErrors = false; let hasAnySymbolMetricsErrors = false;
const errors: ResponseError['errors'] = []; const positions: (TimelinePosition & {
includeInHoldings: boolean;
})[] = [];
const accumulatedValuesByDate: { const accumulatedValuesByDate: {
[date: string]: { [date: string]: {
@ -360,8 +365,7 @@ export abstract class PortfolioCalculator {
totalInterestInBaseCurrency, totalInterestInBaseCurrency,
totalInvestment, totalInvestment,
totalInvestmentWithCurrencyEffect, totalInvestmentWithCurrencyEffect,
totalLiabilitiesInBaseCurrency, totalLiabilitiesInBaseCurrency
totalValuablesInBaseCurrency
} = this.getSymbolMetrics({ } = this.getSymbolMetrics({
chartDateMap, chartDateMap,
marketSymbolMap, marketSymbolMap,
@ -408,6 +412,7 @@ export abstract class PortfolioCalculator {
grossPerformanceWithCurrencyEffect: !hasErrors grossPerformanceWithCurrencyEffect: !hasErrors
? (grossPerformanceWithCurrencyEffect ?? null) ? (grossPerformanceWithCurrencyEffect ?? null)
: null, : null,
includeInHoldings: item.includeInHoldings,
investment: totalInvestment, investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
marketPrice: marketPrice:
@ -440,16 +445,13 @@ export abstract class PortfolioCalculator {
totalLiabilitiesWithCurrencyEffect = totalLiabilitiesWithCurrencyEffect =
totalLiabilitiesWithCurrencyEffect.plus(totalLiabilitiesInBaseCurrency); totalLiabilitiesWithCurrencyEffect.plus(totalLiabilitiesInBaseCurrency);
totalValuablesWithCurrencyEffect = totalValuablesWithCurrencyEffect.plus(
totalValuablesInBaseCurrency
);
if ( if (
(hasErrors || (hasErrors ||
currentRateErrors.find(({ dataSource, symbol }) => { currentRateErrors.find(({ dataSource, symbol }) => {
return dataSource === item.dataSource && symbol === item.symbol; return dataSource === item.dataSource && symbol === item.symbol;
})) && })) &&
item.investment.gt(0) item.investment.gt(0) &&
item.skipErrors === false
) { ) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol }); errors.push({ dataSource: item.dataSource, symbol: item.symbol });
} }
@ -593,7 +595,6 @@ export abstract class PortfolioCalculator {
netPerformance: totalNetPerformanceValue.toNumber(), netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(), totalNetPerformanceValueWithCurrencyEffect.toNumber(),
// TODO: Add valuables
netWorth: totalCurrentValueWithCurrencyEffect netWorth: totalCurrentValueWithCurrencyEffect
.plus(totalAccountBalanceWithCurrencyEffect) .plus(totalAccountBalanceWithCurrencyEffect)
.toNumber(), .toNumber(),
@ -608,18 +609,28 @@ export abstract class PortfolioCalculator {
const overall = this.calculateOverallPerformance(positions); const overall = this.calculateOverallPerformance(positions);
const positionsIncludedInHoldings = positions
.filter(({ includeInHoldings }) => {
return includeInHoldings;
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(({ includeInHoldings, ...rest }) => {
return rest;
});
return { return {
...overall, ...overall,
errors, errors,
historicalData, historicalData,
positions,
totalInterestWithCurrencyEffect, totalInterestWithCurrencyEffect,
totalLiabilitiesWithCurrencyEffect, totalLiabilitiesWithCurrencyEffect,
totalValuablesWithCurrencyEffect, hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors,
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors positions: positionsIncludedInHoldings
}; };
} }
protected abstract getPerformanceCalculationType(): PerformanceCalculationType;
public getDataProviderInfos() { public getDataProviderInfos() {
return this.dataProviderInfos; return this.dataProviderInfos;
} }
@ -748,7 +759,7 @@ export abstract class PortfolioCalculator {
? 0 ? 0
: netPerformanceWithCurrencyEffectSinceStartDate / : netPerformanceWithCurrencyEffectSinceStartDate /
timeWeightedInvestmentValue timeWeightedInvestmentValue
// TODO: Add net worth with valuables // TODO: Add net worth
// netWorth: totalCurrentValueWithCurrencyEffect // netWorth: totalCurrentValueWithCurrencyEffect
// .plus(totalAccountBalanceWithCurrencyEffect) // .plus(totalAccountBalanceWithCurrencyEffect)
// .toNumber() // .toNumber()
@ -771,9 +782,7 @@ export abstract class PortfolioCalculator {
let firstActivityDate: Date; let firstActivityDate: Date;
try { try {
const firstAccountBalanceDateString = first( const firstAccountBalanceDateString = this.accountBalanceItems[0]?.date;
this.accountBalanceItems
)?.date;
firstAccountBalanceDate = firstAccountBalanceDateString firstAccountBalanceDate = firstAccountBalanceDateString
? parseDate(firstAccountBalanceDateString) ? parseDate(firstAccountBalanceDateString)
: new Date(); : new Date();
@ -815,12 +824,6 @@ export abstract class PortfolioCalculator {
return this.transactionPoints; return this.transactionPoints;
} }
public async getValuablesInBaseCurrency() {
await this.snapshotPromise;
return this.snapshot.totalValuablesWithCurrencyEffect;
}
private getChartDateMap({ private getChartDateMap({
endDate, endDate,
startDate, startDate,
@ -898,8 +901,8 @@ export abstract class PortfolioCalculator {
let lastTransactionPoint: TransactionPoint = null; let lastTransactionPoint: TransactionPoint = null;
for (const { for (const {
fee,
date, date,
fee,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags, tags,
@ -907,54 +910,81 @@ export abstract class PortfolioCalculator {
unitPrice unitPrice
} of this.activities) { } of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol; let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[SymbolProfile.symbol];
const currency = SymbolProfile.currency;
const dataSource = SymbolProfile.dataSource;
const factor = getFactor(type); const factor = getFactor(type);
const skipErrors = !!SymbolProfile.userId; // Skip errors for custom asset profiles
const symbol = SymbolProfile.symbol;
const oldAccumulatedSymbol = symbols[symbol];
if (oldAccumulatedSymbol) { if (oldAccumulatedSymbol) {
let investment = oldAccumulatedSymbol.investment; let investment = oldAccumulatedSymbol.investment;
const newQuantity = quantity let newQuantity = quantity
.mul(factor) .mul(factor)
.plus(oldAccumulatedSymbol.quantity); .plus(oldAccumulatedSymbol.quantity);
if (type === 'BUY') { if (type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus( if (oldAccumulatedSymbol.investment.gte(0)) {
quantity.mul(unitPrice) investment = oldAccumulatedSymbol.investment.plus(
); quantity.mul(unitPrice)
);
} else {
investment = oldAccumulatedSymbol.investment.plus(
quantity.mul(oldAccumulatedSymbol.averagePrice)
);
}
} else if (type === 'SELL') { } else if (type === 'SELL') {
investment = oldAccumulatedSymbol.investment.minus( if (oldAccumulatedSymbol.investment.gt(0)) {
quantity.mul(oldAccumulatedSymbol.averagePrice) investment = oldAccumulatedSymbol.investment.minus(
); quantity.mul(oldAccumulatedSymbol.averagePrice)
);
} else {
investment = oldAccumulatedSymbol.investment.minus(
quantity.mul(unitPrice)
);
}
}
if (newQuantity.abs().lt(Number.EPSILON)) {
// Reset to zero if quantity is (almost) zero to avoid rounding issues
investment = new Big(0);
newQuantity = new Big(0);
} }
currentTransactionPointItem = { currentTransactionPointItem = {
currency,
dataSource,
investment, investment,
averagePrice: newQuantity.gt(0) skipErrors,
? investment.div(newQuantity) symbol,
: new Big(0), averagePrice: newQuantity.eq(0)
currency: SymbolProfile.currency, ? new Big(0)
dataSource: SymbolProfile.dataSource, : investment.div(newQuantity).abs(),
dividend: new Big(0), dividend: new Big(0),
fee: oldAccumulatedSymbol.fee.plus(fee), fee: oldAccumulatedSymbol.fee.plus(fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
includeInHoldings: oldAccumulatedSymbol.includeInHoldings,
quantity: newQuantity, quantity: newQuantity,
symbol: SymbolProfile.symbol,
tags: oldAccumulatedSymbol.tags.concat(tags), tags: oldAccumulatedSymbol.tags.concat(tags),
transactionCount: oldAccumulatedSymbol.transactionCount + 1 transactionCount: oldAccumulatedSymbol.transactionCount + 1
}; };
} else { } else {
currentTransactionPointItem = { currentTransactionPointItem = {
currency,
dataSource,
fee, fee,
skipErrors,
symbol,
tags, tags,
averagePrice: unitPrice, averagePrice: unitPrice,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
dividend: new Big(0), dividend: new Big(0),
firstBuyDate: date, firstBuyDate: date,
includeInHoldings: INVESTMENT_ACTIVITY_TYPES.includes(type),
investment: unitPrice.mul(quantity).mul(factor), investment: unitPrice.mul(quantity).mul(factor),
quantity: quantity.mul(factor), quantity: quantity.mul(factor),
symbol: SymbolProfile.symbol,
transactionCount: 1 transactionCount: 1
}; };
} }
@ -996,19 +1026,12 @@ export abstract class PortfolioCalculator {
liabilities = quantity.mul(unitPrice); liabilities = quantity.mul(unitPrice);
} }
let valuables = new Big(0);
if (type === 'ITEM') {
valuables = quantity.mul(unitPrice);
}
if (lastDate !== date || lastTransactionPoint === null) { if (lastDate !== date || lastTransactionPoint === null) {
lastTransactionPoint = { lastTransactionPoint = {
date, date,
fees, fees,
interest, interest,
liabilities, liabilities,
valuables,
items: newItems items: newItems
}; };
@ -1020,8 +1043,6 @@ export abstract class PortfolioCalculator {
lastTransactionPoint.items = newItems; lastTransactionPoint.items = newItems;
lastTransactionPoint.liabilities = lastTransactionPoint.liabilities =
lastTransactionPoint.liabilities.plus(liabilities); lastTransactionPoint.liabilities.plus(liabilities);
lastTransactionPoint.valuables =
lastTransactionPoint.valuables.plus(valuables);
} }
lastDate = date; lastDate = date;
@ -1072,6 +1093,7 @@ export abstract class PortfolioCalculator {
// Compute in the background // Compute in the background
this.portfolioSnapshotService.addJobToQueue({ this.portfolioSnapshotService.addJobToQueue({
data: { data: {
calculationType: this.getPerformanceCalculationType(),
filters: this.filters, filters: this.filters,
userCurrency: this.currency, userCurrency: this.currency,
userId: this.userId userId: this.userId
@ -1088,6 +1110,7 @@ export abstract class PortfolioCalculator {
// Wait for computation // Wait for computation
await this.portfolioSnapshotService.addJobToQueue({ await this.portfolioSnapshotService.addJobToQueue({
data: { data: {
calculationType: this.getPerformanceCalculationType(),
filters: this.filters, filters: this.filters,
userCurrency: this.currency, userCurrency: this.currency,
userId: this.userId userId: this.userId

208
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts

@ -0,0 +1,208 @@
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 } 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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
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/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
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 portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and buy', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 136.6
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('595.6'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('139.75'),
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('36.6'),
grossPerformancePercentage: new Big('0.07706261539956593567'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.07706261539956593567'
),
grossPerformanceWithCurrencyEffect: new Big('36.6'),
investment: new Big('559'),
investmentWithCurrencyEffect: new Big('559'),
netPerformance: new Big('33.4'),
netPerformancePercentage: new Big('0.07032490039195361342'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.06986689805847808234')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('33.4')
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('4'),
symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('474.93846153846153846154'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'474.93846153846153846154'
),
transactionCount: 2,
valueInBaseCurrency: new Big('595.6')
}
],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('559'),
totalInvestmentWithCurrencyEffect: new Big('559'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 33.4,
netPerformanceInPercentage: 0.07032490039195362,
netPerformanceInPercentageWithCurrencyEffect: 0.07032490039195362,
netPerformanceWithCurrencyEffect: 33.4,
totalInvestmentValueWithCurrencyEffect: 559
})
);
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('559') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 559 },
{ date: '2021-12-01', investment: 0 }
]);
});
});
});

26
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -4,10 +4,7 @@ import {
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
PortfolioCalculatorFactory,
PerformanceCalculationType
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -17,9 +14,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -92,7 +89,7 @@ describe('PortfolioCalculator', () => {
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-22'), date: new Date('2021-11-22'),
fee: 1.55, feeInAssetProfileCurrency: 1.55,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -102,12 +99,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
type: 'BUY', type: 'BUY',
unitPrice: 142.9 unitPriceInAssetProfileCurrency: 142.9
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), date: new Date('2021-11-30'),
fee: 1.65, feeInAssetProfileCurrency: 1.65,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -117,12 +114,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
type: 'SELL', type: 'SELL',
unitPrice: 136.6 unitPriceInAssetProfileCurrency: 136.6
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), date: new Date('2021-11-30'),
fee: 0, feeInAssetProfileCurrency: 0,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -132,13 +129,13 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
type: 'SELL', type: 'SELL',
unitPrice: 136.6 unitPriceInAssetProfileCurrency: 136.6
} }
]; ];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -197,11 +194,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
netPerformance: -15.8, netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550734703, netPerformanceInPercentage: -0.05528341497550734703,

22
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -4,10 +4,7 @@ import {
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
@ -17,9 +14,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -92,7 +89,7 @@ describe('PortfolioCalculator', () => {
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-22'), date: new Date('2021-11-22'),
fee: 1.55, feeInAssetProfileCurrency: 1.55,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -102,12 +99,12 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
type: 'BUY', type: 'BUY',
unitPrice: 142.9 unitPriceInAssetProfileCurrency: 142.9
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), date: new Date('2021-11-30'),
fee: 1.65, feeInAssetProfileCurrency: 1.65,
quantity: 2, quantity: 2,
SymbolProfile: { SymbolProfile: {
...symbolProfileDummyData, ...symbolProfileDummyData,
@ -117,13 +114,13 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
type: 'SELL', type: 'SELL',
unitPrice: 136.6 unitPriceInAssetProfileCurrency: 136.6
} }
]; ];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -182,11 +179,10 @@ describe('PortfolioCalculator', () => {
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'), totalLiabilitiesWithCurrencyEffect: new Big('0')
totalValuablesWithCurrencyEffect: new Big('0')
}); });
expect(last(portfolioSnapshot.historicalData)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
netPerformance: -15.8, netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550734703, netPerformanceInPercentage: -0.05528341497550734703,

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

Loading…
Cancel
Save