Compare commits
458 Commits
Author | SHA1 | Date | |
---|---|---|---|
6e4660295a | |||
d4c3a9d1e8 | |||
263f6b32f2 | |||
637f31ae3b | |||
547e27c7a1 | |||
f10dc176f2 | |||
0a966e46cd | |||
4f281d25e1 | |||
aaba8c35c2 | |||
7d27cb3398 | |||
91678028b5 | |||
5e3cac8ac9 | |||
33f20b6b48 | |||
e4fd255dd7 | |||
e320aa91f7 | |||
0fcfa6c1bd | |||
42d32ed652 | |||
21b4b0ef24 | |||
4f8fe83a16 | |||
980ad1028c | |||
0d5bc3f51b | |||
aece76d98f | |||
fc4bb71184 | |||
20bc7ef99c | |||
7a733ae49b | |||
376ce88492 | |||
c4d83aabe7 | |||
d4e2cec77e | |||
75db7bf79a | |||
3ad99c9991 | |||
00e402d286 | |||
4ac0484025 | |||
75d61bff6d | |||
0de28d733e | |||
3b2f13850c | |||
0cc42ffd7c | |||
3ccb812ac3 | |||
0a8549db3e | |||
c95e90ff31 | |||
b59af0d864 | |||
408bdbd187 | |||
a3bfa46fb0 | |||
8cb1b3f925 | |||
15c650f951 | |||
c198bd78da | |||
35963580bc | |||
cf2c5bad02 | |||
f332aea9b4 | |||
7a9fd18407 | |||
ca08d3154a | |||
01d4ae8757 | |||
43ce2786c1 | |||
de2092c4d2 | |||
435a180e54 | |||
0ad30ffabe | |||
0cc5e558f1 | |||
63b183cc6f | |||
10bae24c5c | |||
0e29278e96 | |||
2db46e5bbf | |||
e757e90e5a | |||
184ddc6209 | |||
e3662a143c | |||
25afd7e07b | |||
7fceaa1350 | |||
7c8530483c | |||
539d3ff754 | |||
9d28b63da6 | |||
24abbd85e6 | |||
b6f395fd3b | |||
04d894cf88 | |||
b4d2c4109e | |||
823093f4d7 | |||
56bf422407 | |||
df0e9ad03b | |||
0e3702c2be | |||
11136ae4f8 | |||
2e6a7d5a91 | |||
83845c256a | |||
34c9703716 | |||
48903238c5 | |||
57a14bd945 | |||
4fd0622114 | |||
52f0fb5ab8 | |||
20195b2b1a | |||
7fa4e6ebd2 | |||
d8531ddfcb | |||
70d670b711 | |||
27b0663a80 | |||
874dfb0235 | |||
072db0d558 | |||
12e692429a | |||
e22b8b78b8 | |||
dc5052f7dc | |||
335553e891 | |||
d480ad1023 | |||
7320751056 | |||
108c0c13c4 | |||
053a5cc5b5 | |||
c456a8bcfe | |||
6fcecb5bc6 | |||
e4e0a7d9f0 | |||
c7173761a3 | |||
185e130d9f | |||
81245635af | |||
55182ac1af | |||
0b446a30ae | |||
c5e6602102 | |||
573038f407 | |||
dbc38e705e | |||
f127e7c61a | |||
4ccabde251 | |||
86ae88f90f | |||
69bc1d67e1 | |||
03942aecda | |||
7ec9170c0d | |||
51431a7fb2 | |||
4adda6783d | |||
d5cd4c0dea | |||
34be10d755 | |||
51f586e160 | |||
ff64a00196 | |||
148f6f8762 | |||
bf2c4d1e9e | |||
eee1f1c722 | |||
9f2a49a1c7 | |||
44058b2d7a | |||
23634f3404 | |||
f93dab6086 | |||
207859cc22 | |||
77181aaaff | |||
412039badf | |||
7619442895 | |||
61ecd66e0f | |||
81217b35ef | |||
678f1f0051 | |||
71c7e37b5a | |||
80459371f3 | |||
35f1f348a8 | |||
0bb0b12991 | |||
d887de50d2 | |||
2571e5b8c0 | |||
e444d717e5 | |||
1866e26c1d | |||
9923074e04 | |||
c367e61b85 | |||
364f1ad9b9 | |||
2394cbd6fe | |||
a74d5cce20 | |||
95bcc3f32d | |||
e9dbd4a55d | |||
d440b09dc9 | |||
cc16ba5dc8 | |||
d10227bc39 | |||
4e214c32e8 | |||
49e2862e03 | |||
34e33a2400 | |||
ec9bc984af | |||
2388c494df | |||
d71ab10eed | |||
0e0592180f | |||
60e2aff488 | |||
7b5454e7de | |||
30835ced88 | |||
8897f32bc5 | |||
abaa6b5f27 | |||
2060fcaf0b | |||
fd2408dd62 | |||
31cca024f1 | |||
b535122945 | |||
5113e4e3ad | |||
35e039748f | |||
c6b9e0aa5b | |||
b250491ca5 | |||
61e501c659 | |||
c0f19d56ec | |||
8e2b235b1f | |||
c3407e9b34 | |||
74193e4ee2 | |||
3fe8f9c882 | |||
d130efad47 | |||
109f0ebd70 | |||
069ddcc6b2 | |||
f7bf6e652b | |||
eb059a024a | |||
ad88acff1c | |||
1ff736537c | |||
1fa65e1efd | |||
df6bb489c2 | |||
928a13310d | |||
2384861953 | |||
fe90bda6fb | |||
d4b29ff11c | |||
a0a26cfa58 | |||
1610150427 | |||
cff8acd7b1 | |||
0f36d6cbdb | |||
046e28b521 | |||
aba562cb35 | |||
03f2f33344 | |||
a996dd7ed5 | |||
002b883668 | |||
0b06823893 | |||
2dfd779444 | |||
1824413379 | |||
3332ade3d3 | |||
8d2e110e3d | |||
a8fcf09380 | |||
1071f446a8 | |||
03b050d1ac | |||
58eeff7001 | |||
76fb8825e4 | |||
0f9d142afe | |||
bd33855a27 | |||
5329e45e2c | |||
e990ecd12c | |||
a4fcf64f13 | |||
557e3a0676 | |||
2abe399ebd | |||
74fe90906a | |||
4cb9a3b142 | |||
0da9368e0c | |||
d2f8e3d645 | |||
5263fba64e | |||
e3689c48f8 | |||
787efdb33b | |||
e63578d8ce | |||
7cf0cdc4ce | |||
14a0eeab29 | |||
6774c48dff | |||
565947e752 | |||
2cc7c6fa1c | |||
023a7147e2 | |||
a96e89a86e | |||
b9c9443899 | |||
f1e06347d3 | |||
697e92f818 | |||
b678998801 | |||
de53cf1884 | |||
bbe30218bd | |||
15dda886a0 | |||
34d4212f55 | |||
f7060230b7 | |||
0fdafcb7e4 | |||
e79be9f2d6 | |||
69088b93a6 | |||
c3768a882d | |||
3498ed8549 | |||
c07c300fef | |||
c62a5af9eb | |||
0c04f10e19 | |||
2c4c16ec99 | |||
4711b0d1ed | |||
a8521e0ecf | |||
424748ae90 | |||
9c4d8bdf4b | |||
332203b9e2 | |||
f48832c671 | |||
ae8a203526 | |||
d0c1506ded | |||
af0863d193 | |||
f5819cc399 | |||
977c5a9544 | |||
b9cd42cd53 | |||
379977008d | |||
38f9d54705 | |||
5cb6e5dec6 | |||
4a123c38f2 | |||
160335302a | |||
f1483569a2 | |||
5391b88c42 | |||
2b63f7e707 | |||
d5c96d1cb7 | |||
1a4dc51825 | |||
d094bae7de | |||
57bf10e7e7 | |||
c1d460cead | |||
dfa67b275c | |||
80862e5c2a | |||
904d4db219 | |||
10f13eec48 | |||
ea3a9d3b79 | |||
e55b05fe3d | |||
32dd76be5f | |||
ff9b6bb4df | |||
5be95b7b63 | |||
b3e07c8446 | |||
eb9cece4e4 | |||
b331f5f04d | |||
34cbdd7c2a | |||
57314d62ee | |||
40380346e6 | |||
5622c4cf7e | |||
21173bed21 | |||
16dd8f7652 | |||
ce6b5fb7cb | |||
f6f62db830 | |||
01103f3db4 | |||
e9e9f1a124 | |||
751256f158 | |||
c2a1cbd20f | |||
04044f8720 | |||
4dc76817ce | |||
1f0bd5a7db | |||
b6cd007ad4 | |||
b4bc72c6f9 | |||
899fa0370e | |||
da27504aa1 | |||
b7bbc029ac | |||
c61a415fb2 | |||
8ff811ed28 | |||
9a2ea0a4ed | |||
bad9d17c44 | |||
ea89ca5734 | |||
8f61f7c169 | |||
edca05f542 | |||
283f054ee2 | |||
e9a46cb224 | |||
4a75c6d483 | |||
bbe9183fb0 | |||
1b03ddc586 | |||
beb12637ce | |||
20358d9105 | |||
0e4c39d145 | |||
83ebacbb06 | |||
7c58c5fb7f | |||
f3271ab1ff | |||
9f597cbff1 | |||
90efc2ac51 | |||
056b318d86 | |||
82ede2fe32 | |||
8ae041faa0 | |||
bd4608e521 | |||
0d8362ca8f | |||
638ae3f7fa | |||
6e7cf0380b | |||
ec2ecab751 | |||
598fe41b8c | |||
ba7c98d325 | |||
65e062ad26 | |||
8526b5a027 | |||
f1feb04f29 | |||
500e09d95a | |||
aef91d3e30 | |||
70723f8d5f | |||
6cfd052781 | |||
23f2ac472e | |||
d5ba624403 | |||
9b49ed77f7 | |||
08405d14d5 | |||
56b169e1c4 | |||
67f2b326f3 | |||
3d3a6c1204 | |||
bfc8f87d88 | |||
957200854c | |||
6575440877 | |||
255af6a6e9 | |||
795a6a6799 | |||
2a854e2574 | |||
52d113e71f | |||
204c7360c3 | |||
fa41e25c8f | |||
ba765b9de6 | |||
fa79196278 | |||
d1230ca3ad | |||
69a1316cfe | |||
a256b783bc | |||
ebbdd47fa2 | |||
3d21e2eac6 | |||
bc117fe601 | |||
65f6bcb166 | |||
b8c43ecf89 | |||
1214127ec0 | |||
e986310302 | |||
6762572658 | |||
eb77652d6a | |||
a7b59f4ec6 | |||
dd71f2be45 | |||
d530cb38fa | |||
16b79a7e60 | |||
7f0c98cae6 | |||
57e4163848 | |||
14773bf1aa | |||
1a8fc5757a | |||
b4848be914 | |||
2b4319454d | |||
e2faaf6faa | |||
86a1589834 | |||
9f67993c03 | |||
32fb3551dc | |||
30411b1502 | |||
eb0444603b | |||
6e582fe505 | |||
402d73a12c | |||
4826a51199 | |||
5356bf568e | |||
d8da574ae4 | |||
e769fabbae | |||
5a369f29d4 | |||
122ba9046f | |||
f781eb207c | |||
7b6893b5ed | |||
07799573cb | |||
9cdef6a7cb | |||
0d897bc461 | |||
e4908b51aa | |||
718b0de0a7 | |||
99655604d9 | |||
b602e7690b | |||
7745dafe48 | |||
50184284e1 | |||
f46533107d | |||
c216ab1d76 | |||
86acbf06f4 | |||
3de7d3f60e | |||
63ed227f3f | |||
5bb20f6d5f | |||
b3e58d182a | |||
93d6746739 | |||
e3f8b0cf52 | |||
c02bcd9bd8 | |||
6a4f1c0188 | |||
745ba978a3 | |||
46b91d3c3b | |||
1dd670a7c3 | |||
68d07cc8d4 | |||
02809a529e | |||
fd60569716 | |||
fed771525e | |||
a5771f601d | |||
2a2a5f4da5 | |||
06d5ec9182 | |||
122107c8a1 | |||
ca46a9827a | |||
4ec351369b | |||
dced06ebb5 | |||
baa6a3d0f0 | |||
d3382f0809 | |||
1eb4041837 | |||
5a869a90da | |||
280030ae7f | |||
52e4504de9 | |||
20356f6931 | |||
e0bb2b1c78 | |||
ec806be45f | |||
809ee97f6f | |||
893ca83d3a | |||
23da1bd293 | |||
fa66cd5bce | |||
9344dcd26e | |||
90ad22cccf | |||
dcc7ef89fe | |||
e355847f40 | |||
76f70598e2 | |||
7af5cd244a | |||
86943a5f5b | |||
6eb4eae4a9 | |||
6ac693dd39 |
8
.env
8
.env
@ -3,14 +3,14 @@ COMPOSE_PROJECT_NAME=ghostfolio-development
|
|||||||
# CACHE
|
# CACHE
|
||||||
REDIS_HOST=localhost
|
REDIS_HOST=localhost
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||||
|
|
||||||
# POSTGRES
|
# POSTGRES
|
||||||
POSTGRES_DB=ghostfolio-db
|
POSTGRES_DB=ghostfolio-db
|
||||||
POSTGRES_USER=user
|
POSTGRES_USER=user
|
||||||
POSTGRES_PASSWORD=password
|
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||||
|
|
||||||
ACCESS_TOKEN_SALT=GHOSTFOLIO
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
ALPHA_VANTAGE_API_KEY=
|
ALPHA_VANTAGE_API_KEY=
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
||||||
JWT_SECRET_KEY=123456
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||||
PORT=3333
|
|
||||||
|
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
custom: ['https://www.buymeacoffee.com/ghostfolio']
|
36
.github/workflows/build-code.yml
vendored
Normal file
36
.github/workflows/build-code.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
name: Build code
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node_version:
|
||||||
|
- 16
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Use Node.js ${{ matrix.node_version }}
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node_version }}
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: yarn format:check
|
||||||
|
|
||||||
|
- name: Execute tests
|
||||||
|
run: yarn test
|
||||||
|
|
||||||
|
- name: Build application
|
||||||
|
run: yarn build:all
|
49
.github/workflows/docker-image.yml
vendored
Normal file
49
.github/workflows/docker-image.yml
vendored
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
name: Docker image CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*.*.*'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_and_push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4
|
||||||
|
with:
|
||||||
|
images: ghostfolio/ghostfolio
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v3
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.output.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -24,15 +24,16 @@
|
|||||||
|
|
||||||
# misc
|
# misc
|
||||||
/.angular/cache
|
/.angular/cache
|
||||||
|
.env.prod
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
/dist
|
/dist
|
||||||
/libpeerconnection.log
|
/libpeerconnection.log
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
|
||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
30
.travis.yml
30
.travis.yml
@ -1,30 +0,0 @@
|
|||||||
language: node_js
|
|
||||||
git:
|
|
||||||
depth: false
|
|
||||||
node_js:
|
|
||||||
- 14
|
|
||||||
|
|
||||||
services:
|
|
||||||
- docker
|
|
||||||
|
|
||||||
cache: yarn
|
|
||||||
|
|
||||||
if: (type = pull_request) OR (tag IS present)
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
include:
|
|
||||||
- stage: Install dependencies
|
|
||||||
if: type = pull_request
|
|
||||||
script: yarn --frozen-lockfile
|
|
||||||
- stage: Check formatting
|
|
||||||
if: type = pull_request
|
|
||||||
script: yarn format:check
|
|
||||||
- stage: Execute tests
|
|
||||||
if: type = pull_request
|
|
||||||
script: yarn test
|
|
||||||
- stage: Build application
|
|
||||||
if: type = pull_request
|
|
||||||
script: yarn build:all
|
|
||||||
- stage: Build and publish docker image
|
|
||||||
if: tag IS present
|
|
||||||
script: ./publish-docker-image.sh
|
|
1067
CHANGELOG.md
1067
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
22
Dockerfile
22
Dockerfile
@ -1,7 +1,6 @@
|
|||||||
FROM node:14-alpine as builder
|
FROM --platform=$BUILDPLATFORM node:16-slim as builder
|
||||||
|
|
||||||
# Build application and add additional files
|
# Build application and add additional files
|
||||||
|
|
||||||
WORKDIR /ghostfolio
|
WORKDIR /ghostfolio
|
||||||
|
|
||||||
# Only add basic files without the application itself to avoid rebuilding
|
# Only add basic files without the application itself to avoid rebuilding
|
||||||
@ -10,9 +9,16 @@ COPY ./CHANGELOG.md CHANGELOG.md
|
|||||||
COPY ./LICENSE LICENSE
|
COPY ./LICENSE LICENSE
|
||||||
COPY ./package.json package.json
|
COPY ./package.json package.json
|
||||||
COPY ./yarn.lock yarn.lock
|
COPY ./yarn.lock yarn.lock
|
||||||
|
COPY ./.yarnrc .yarnrc
|
||||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
RUN apk add --no-cache python3 g++ make openssl
|
RUN apt update && apt install -y \
|
||||||
|
git \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
openssl \
|
||||||
|
python3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
RUN yarn install
|
RUN yarn install
|
||||||
|
|
||||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
@ -23,7 +29,7 @@ COPY ./angular.json angular.json
|
|||||||
COPY ./nx.json nx.json
|
COPY ./nx.json nx.json
|
||||||
COPY ./replace.build.js replace.build.js
|
COPY ./replace.build.js replace.build.js
|
||||||
COPY ./jest.preset.js jest.preset.js
|
COPY ./jest.preset.js jest.preset.js
|
||||||
COPY ./jest.config.js jest.config.js
|
COPY ./jest.config.ts jest.config.ts
|
||||||
COPY ./tsconfig.base.json tsconfig.base.json
|
COPY ./tsconfig.base.json tsconfig.base.json
|
||||||
COPY ./libs libs
|
COPY ./libs libs
|
||||||
COPY ./apps apps
|
COPY ./apps apps
|
||||||
@ -45,8 +51,12 @@ COPY package.json /ghostfolio/dist/apps/api
|
|||||||
RUN yarn database:generate-typings
|
RUN yarn database:generate-typings
|
||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:14-alpine
|
FROM node:16-slim
|
||||||
|
RUN apt update && apt install -y \
|
||||||
|
openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
WORKDIR /ghostfolio/apps/api
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE 3333
|
EXPOSE 3333
|
||||||
CMD [ "node", "main" ]
|
CMD [ "yarn", "start:prod" ]
|
||||||
|
183
README.md
183
README.md
@ -9,53 +9,44 @@
|
|||||||
|
|
||||||
<h1>Ghostfolio</h1>
|
<h1>Ghostfolio</h1>
|
||||||
<p>
|
<p>
|
||||||
<strong>Open Source Wealth Management Software made for Humans</strong>
|
<strong>Open Source Wealth Management Software</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/en/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/en/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/en/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="#contributing">
|
<a href="#contributing">
|
||||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
|
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
|
||||||
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
|
|
||||||
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
|
|
||||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
||||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
|
||||||
|
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Ghostfolio Premium
|
## Ghostfolio Premium
|
||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
|
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
||||||
|
|
||||||
## Why Ghostfolio?
|
## Why Ghostfolio?
|
||||||
|
|
||||||
Ghostfolio is for you if you are...
|
Ghostfolio is for you if you are...
|
||||||
|
|
||||||
- 💼 trading stocks, ETFs or cryptocurrencies on multiple platforms
|
- 💼 trading stocks, ETFs or cryptocurrencies on multiple platforms
|
||||||
|
|
||||||
- 🏦 pursuing a buy & hold strategy
|
- 🏦 pursuing a buy & hold strategy
|
||||||
|
|
||||||
- 🎯 interested in getting insights of your portfolio composition
|
- 🎯 interested in getting insights of your portfolio composition
|
||||||
|
|
||||||
- 👻 valuing privacy and data ownership
|
- 👻 valuing privacy and data ownership
|
||||||
|
|
||||||
- 🧘 into minimalism
|
- 🧘 into minimalism
|
||||||
|
|
||||||
- 🧺 caring about diversifying your financial resources
|
- 🧺 caring about diversifying your financial resources
|
||||||
|
|
||||||
- 🆓 interested in financial independence
|
- 🆓 interested in financial independence
|
||||||
|
- 🙅 saying no to spreadsheets in 2022
|
||||||
- 🙅 saying no to spreadsheets in 2021
|
|
||||||
|
|
||||||
- 😎 still reading this list
|
- 😎 still reading this list
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@ -65,10 +56,15 @@ Ghostfolio is for you if you are...
|
|||||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||||
- ✅ Various charts
|
- ✅ Various charts
|
||||||
- ✅ Static analysis to identify potential risks in your portfolio
|
- ✅ Static analysis to identify potential risks in your portfolio
|
||||||
|
- ✅ Import and export transactions
|
||||||
- ✅ Dark Mode
|
- ✅ Dark Mode
|
||||||
- ✅ Zen Mode
|
- ✅ Zen Mode
|
||||||
- ✅ Mobile-first design
|
- ✅ Mobile-first design
|
||||||
|
|
||||||
|
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||||
|
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
||||||
|
</div>
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace.
|
Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace.
|
||||||
@ -81,46 +77,53 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
|||||||
|
|
||||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||||
|
|
||||||
## Run with Docker (self-hosting)
|
## Self-hosting
|
||||||
|
|
||||||
### Prerequisites
|
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
### Supported Environment Variables
|
||||||
|
|
||||||
### a. Run environment
|
| Name | Default Value | Description |
|
||||||
|
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||||
|
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. Caution: This cannot be changed later! |
|
||||||
|
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
|
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
|
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
|
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||||
|
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||||
|
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||||
|
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||||
|
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||||
|
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||||
|
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||||
|
|
||||||
|
### Run with Docker Compose
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
- Basic knowledge of Docker
|
||||||
|
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
|
- Local copy of this Git repository (clone)
|
||||||
|
|
||||||
|
#### a. Run environment
|
||||||
|
|
||||||
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 -f docker/docker-compose.yml up
|
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Setup Database
|
#### b. Build and run environment
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
### b. Build and run environment
|
|
||||||
|
|
||||||
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 -f docker/docker-compose.build.yml build
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||||
docker-compose -f docker/docker-compose.build.yml up
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Setup Database
|
#### Fetch Historical Data
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fetch Historical Data
|
|
||||||
|
|
||||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||||
|
|
||||||
@ -128,26 +131,30 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
|||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
### Migrate Database
|
#### Upgrade Version
|
||||||
|
|
||||||
With the following command you can keep your database schema in sync after a Ghostfolio version update:
|
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||||
|
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||||
|
At each start, the container will automatically apply the database schema migrations if needed.
|
||||||
|
|
||||||
```bash
|
### Run with _Unraid_ (Community)
|
||||||
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:migrate
|
|
||||||
```
|
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
- [Node.js](https://nodejs.org/en/download) (version 16+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
|
- A local copy of this Git repository (clone)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
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 `yarn build:dev` to build the source code including the assets
|
||||||
|
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
@ -169,16 +176,92 @@ Run `yarn start:client`
|
|||||||
|
|
||||||
Run `yarn start:storybook`
|
Run `yarn start:storybook`
|
||||||
|
|
||||||
|
### Migrate Database
|
||||||
|
|
||||||
|
With the following command you can keep your database schema in sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn database:push
|
||||||
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run `yarn test`
|
Run `yarn test`
|
||||||
|
|
||||||
|
## Public API
|
||||||
|
|
||||||
|
### Import Activities
|
||||||
|
|
||||||
|
#### Request
|
||||||
|
|
||||||
|
`POST http://localhost:3333/api/v1/import`
|
||||||
|
|
||||||
|
#### Authorization: Bearer Token
|
||||||
|
|
||||||
|
Set the header as follows:
|
||||||
|
|
||||||
|
```
|
||||||
|
"Authorization": "Bearer eyJh..."
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Body
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"activities": [
|
||||||
|
{
|
||||||
|
"currency": "USD",
|
||||||
|
"dataSource": "YAHOO",
|
||||||
|
"date": "2021-09-15T00:00:00.000Z",
|
||||||
|
"fee": 19,
|
||||||
|
"quantity": 5,
|
||||||
|
"symbol": "MSFT"
|
||||||
|
"type": "BUY",
|
||||||
|
"unitPrice": 298.58
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------- | ------------------- | -------------------------------------------------- |
|
||||||
|
| accountId | string (`optional`) | Id of the account |
|
||||||
|
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||||
|
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||||
|
| date | string | Date in the format `ISO-8601` |
|
||||||
|
| fee | number | Fee of the activity |
|
||||||
|
| quantity | number | Quantity of the activity |
|
||||||
|
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
||||||
|
| type | string | `BUY` \| `DIVIDEND` \| `ITEM` \| `SELL` |
|
||||||
|
| unitPrice | number | Price per unit of the activity |
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
##### Success
|
||||||
|
|
||||||
|
`201 Created`
|
||||||
|
|
||||||
|
##### Error
|
||||||
|
|
||||||
|
`400 Bad Request`
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"error": "Bad Request",
|
||||||
|
"message": [
|
||||||
|
"activities.1 is a duplicate activity"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||||
|
|
||||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
||||||
|
|
||||||
|
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
© 2022 [Ghostfolio](https://ghostfol.io)
|
||||||
|
101
angular.json
101
angular.json
@ -2,6 +2,7 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"projects": {
|
"projects": {
|
||||||
"api": {
|
"api": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "apps/api",
|
"root": "apps/api",
|
||||||
"sourceRoot": "apps/api/src",
|
"sourceRoot": "apps/api/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
@ -9,7 +10,7 @@
|
|||||||
"schematics": {},
|
"schematics": {},
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@nrwl/node:build",
|
"builder": "@nrwl/node:webpack",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": "dist/apps/api",
|
"outputPath": "dist/apps/api",
|
||||||
"main": "apps/api/src/main.ts",
|
"main": "apps/api/src/main.ts",
|
||||||
@ -33,7 +34,7 @@
|
|||||||
"outputs": ["{options.outputPath}"]
|
"outputs": ["{options.outputPath}"]
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@nrwl/node:execute",
|
"builder": "@nrwl/node:node",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "api:build"
|
"buildTarget": "api:build"
|
||||||
}
|
}
|
||||||
@ -47,7 +48,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"builder": "@nrwl/jest:jest",
|
"builder": "@nrwl/jest:jest",
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "apps/api/jest.config.js",
|
"jestConfig": "apps/api/jest.config.ts",
|
||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
},
|
},
|
||||||
"outputs": ["coverage/apps/api"]
|
"outputs": ["coverage/apps/api"]
|
||||||
@ -56,6 +57,7 @@
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@schematics/angular:component": {
|
"@schematics/angular:component": {
|
||||||
@ -75,45 +77,49 @@
|
|||||||
"polyfills": "apps/client/src/polyfills.ts",
|
"polyfills": "apps/client/src/polyfills.ts",
|
||||||
"tsConfig": "apps/client/tsconfig.app.json",
|
"tsConfig": "apps/client/tsconfig.app.json",
|
||||||
"assets": [
|
"assets": [
|
||||||
"apps/client/src/assets",
|
|
||||||
{
|
{
|
||||||
"glob": "assetlinks.json",
|
"glob": "assetlinks.json",
|
||||||
"input": "apps/client/src/assets",
|
"input": "apps/client/src/assets",
|
||||||
"output": "./.well-known"
|
"output": "./../.well-known"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "CHANGELOG.md",
|
"glob": "CHANGELOG.md",
|
||||||
"input": "",
|
"input": "",
|
||||||
"output": "./assets"
|
"output": "./../assets"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "LICENSE",
|
"glob": "LICENSE",
|
||||||
"input": "",
|
"input": "",
|
||||||
"output": "./assets"
|
"output": "./../assets"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "robots.txt",
|
"glob": "robots.txt",
|
||||||
"input": "apps/client/src/assets",
|
"input": "apps/client/src/assets",
|
||||||
"output": "./"
|
"output": "./../"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "sitemap.xml",
|
"glob": "sitemap.xml",
|
||||||
"input": "apps/client/src/assets",
|
"input": "apps/client/src/assets",
|
||||||
"output": "./"
|
"output": "./../"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "node_modules/ionicons/dist/ionicons",
|
"input": "node_modules/ionicons/dist/ionicons",
|
||||||
"output": "./ionicons"
|
"output": "./../ionicons"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "**/*.js",
|
"glob": "**/*.js",
|
||||||
"input": "node_modules/ionicons/dist/",
|
"input": "node_modules/ionicons/dist/",
|
||||||
"output": "./"
|
"output": "./../"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "apps/client/src/assets",
|
||||||
|
"output": "./../assets/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["apps/client/src/styles.scss"],
|
"styles": ["apps/client/src/styles.scss"],
|
||||||
"scripts": ["node_modules/marked/lib/marked.js"],
|
"scripts": ["node_modules/marked/marked.min.js"],
|
||||||
"vendorChunk": true,
|
"vendorChunk": true,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"buildOptimizer": false,
|
"buildOptimizer": false,
|
||||||
@ -122,6 +128,18 @@
|
|||||||
"namedChunks": true
|
"namedChunks": true
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
"development-de": {
|
||||||
|
"baseHref": "/de/",
|
||||||
|
"localize": ["de"]
|
||||||
|
},
|
||||||
|
"development-en": {
|
||||||
|
"baseHref": "/en/",
|
||||||
|
"localize": ["en"]
|
||||||
|
},
|
||||||
|
"development-it": {
|
||||||
|
"baseHref": "/it/",
|
||||||
|
"localize": ["it"]
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
{
|
{
|
||||||
@ -160,15 +178,27 @@
|
|||||||
"proxyConfig": "apps/client/proxy.conf.json"
|
"proxyConfig": "apps/client/proxy.conf.json"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
"development-de": {
|
||||||
|
"browserTarget": "client:build:development-de"
|
||||||
|
},
|
||||||
|
"development-en": {
|
||||||
|
"browserTarget": "client:build:development-en"
|
||||||
|
},
|
||||||
|
"development-it": {
|
||||||
|
"browserTarget": "client:build:development-it"
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"browserTarget": "client:build:production"
|
"browserTarget": "client:build:production"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
"builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
||||||
"options": {
|
"options": {
|
||||||
"browserTarget": "client:build"
|
"browserTarget": "client:build",
|
||||||
|
"includeContext": true,
|
||||||
|
"outputPath": "src/locales",
|
||||||
|
"targetFiles": ["messages.de.xlf", "messages.it.xlf"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
@ -180,15 +210,29 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"builder": "@nrwl/jest:jest",
|
"builder": "@nrwl/jest:jest",
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "apps/client/jest.config.js",
|
"jestConfig": "apps/client/jest.config.ts",
|
||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
},
|
},
|
||||||
"outputs": ["coverage/apps/client"]
|
"outputs": ["coverage/apps/client"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"i18n": {
|
||||||
|
"locales": {
|
||||||
|
"de": {
|
||||||
|
"baseHref": "/de/",
|
||||||
|
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"baseHref": "/it/",
|
||||||
|
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sourceLocale": "en"
|
||||||
|
},
|
||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"client-e2e": {
|
"client-e2e": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "apps/client-e2e",
|
"root": "apps/client-e2e",
|
||||||
"sourceRoot": "apps/client-e2e/src",
|
"sourceRoot": "apps/client-e2e/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
@ -211,6 +255,7 @@
|
|||||||
"implicitDependencies": ["client"]
|
"implicitDependencies": ["client"]
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "libs/common",
|
"root": "libs/common",
|
||||||
"sourceRoot": "libs/common/src",
|
"sourceRoot": "libs/common/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
@ -225,7 +270,7 @@
|
|||||||
"builder": "@nrwl/jest:jest",
|
"builder": "@nrwl/jest:jest",
|
||||||
"outputs": ["coverage/libs/common"],
|
"outputs": ["coverage/libs/common"],
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "libs/common/jest.config.js",
|
"jestConfig": "libs/common/jest.config.ts",
|
||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -233,6 +278,7 @@
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@schematics/angular:component": {
|
"@schematics/angular:component": {
|
||||||
@ -247,7 +293,7 @@
|
|||||||
"builder": "@nrwl/jest:jest",
|
"builder": "@nrwl/jest:jest",
|
||||||
"outputs": ["coverage/libs/ui"],
|
"outputs": ["coverage/libs/ui"],
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "libs/ui/jest.config.js",
|
"jestConfig": "libs/ui/jest.config.ts",
|
||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -258,13 +304,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storybook": {
|
"storybook": {
|
||||||
"builder": "@nrwl/storybook:storybook",
|
"builder": "@storybook/angular:start-storybook",
|
||||||
"options": {
|
"options": {
|
||||||
"uiFramework": "@storybook/angular",
|
|
||||||
"port": 4400,
|
"port": 4400,
|
||||||
"config": {
|
"configDir": "libs/ui/.storybook",
|
||||||
"configFolder": "libs/ui/.storybook"
|
"browserTarget": "ui:build-storybook",
|
||||||
}
|
"compodoc": false
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"ci": {
|
"ci": {
|
||||||
@ -273,14 +318,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"build-storybook": {
|
"build-storybook": {
|
||||||
"builder": "@nrwl/storybook:build",
|
"builder": "@storybook/angular:build-storybook",
|
||||||
"outputs": ["{options.outputPath}"],
|
"outputs": ["{options.outputPath}"],
|
||||||
"options": {
|
"options": {
|
||||||
"uiFramework": "@storybook/angular",
|
"outputDir": "dist/storybook/ui",
|
||||||
"outputPath": "dist/storybook/ui",
|
"configDir": "libs/ui/.storybook",
|
||||||
"config": {
|
"browserTarget": "ui:build-storybook",
|
||||||
"configFolder": "libs/ui/.storybook"
|
"compodoc": false
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"ci": {
|
"ci": {
|
||||||
@ -292,6 +336,7 @@
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"ui-e2e": {
|
"ui-e2e": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "apps/ui-e2e",
|
"root": "apps/ui-e2e",
|
||||||
"sourceRoot": "apps/ui-e2e/src",
|
"sourceRoot": "apps/ui-e2e/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
module.exports = {
|
/* eslint-disable */
|
||||||
|
export default {
|
||||||
displayName: 'api',
|
displayName: 'api',
|
||||||
preset: '../../jest.preset.js',
|
|
||||||
globals: {
|
globals: {
|
||||||
'ts-jest': {
|
'ts-jest': {
|
||||||
tsconfig: '<rootDir>/tsconfig.spec.json'
|
tsconfig: '<rootDir>/tsconfig.spec.json'
|
||||||
@ -12,5 +13,6 @@ module.exports = {
|
|||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
testTimeout: 10000,
|
testTimeout: 10000,
|
||||||
testEnvironment: 'node'
|
testEnvironment: 'node',
|
||||||
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
@ -42,14 +42,16 @@ export class AccessController {
|
|||||||
return accessesWithGranteeUser.map((access) => {
|
return accessesWithGranteeUser.map((access) => {
|
||||||
if (access.GranteeUser) {
|
if (access.GranteeUser) {
|
||||||
return {
|
return {
|
||||||
granteeAlias: access.GranteeUser?.alias,
|
alias: access.alias,
|
||||||
|
grantee: access.GranteeUser?.id,
|
||||||
id: access.id,
|
id: access.id,
|
||||||
type: 'RESTRICTED_VIEW'
|
type: 'RESTRICTED_VIEW'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
granteeAlias: 'Public',
|
alias: access.alias,
|
||||||
|
grantee: 'Public',
|
||||||
id: access.id,
|
id: access.id,
|
||||||
type: 'PUBLIC'
|
type: 'PUBLIC'
|
||||||
};
|
};
|
||||||
@ -71,6 +73,10 @@ export class AccessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.accessService.createAccess({
|
return this.accessService.createAccess({
|
||||||
|
alias: data.alias || undefined,
|
||||||
|
GranteeUser: data.granteeUserId
|
||||||
|
? { connect: { id: data.granteeUserId } }
|
||||||
|
: undefined,
|
||||||
User: { connect: { id: this.request.user.id } }
|
User: { connect: { id: this.request.user.id } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -78,8 +84,12 @@ export class AccessController {
|
|||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
||||||
|
const access = await this.accessService.access({ id });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteAccess)
|
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
|
||||||
|
!access ||
|
||||||
|
access.userId !== this.request.user.id
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -88,10 +98,7 @@ export class AccessController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.accessService.deleteAccess({
|
return this.accessService.deleteAccess({
|
||||||
id_userId: {
|
id
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AccessController } from './access.controller';
|
import { AccessController } from './access.controller';
|
||||||
@ -7,7 +7,7 @@ import { AccessService } from './access.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [AccessController],
|
controllers: [AccessController],
|
||||||
exports: [AccessService],
|
exports: [AccessService],
|
||||||
imports: [],
|
imports: [PrismaModule],
|
||||||
providers: [AccessService, PrismaService]
|
providers: [AccessService]
|
||||||
})
|
})
|
||||||
export class AccessModule {}
|
export class AccessModule {}
|
||||||
|
@ -1 +1,11 @@
|
|||||||
export class CreateAccessDto {}
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAccessDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
alias?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
granteeUserId?: string;
|
||||||
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy';
|
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 {
|
import {
|
||||||
nullifyValuesInObject,
|
nullifyValuesInObject,
|
||||||
@ -7,7 +7,10 @@ import {
|
|||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type {
|
||||||
|
AccountWithValue,
|
||||||
|
RequestWithUser
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -35,7 +38,7 @@ export class AccountController {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
@ -91,9 +94,10 @@ export class AccountController {
|
|||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let accountsWithAggregations = await this.portfolioServiceStrategy
|
let accountsWithAggregations =
|
||||||
.get()
|
await this.portfolioService.getAccountsWithAggregations(
|
||||||
.getAccountsWithAggregations(impersonationUserId || this.request.user.id);
|
impersonationUserId || this.request.user.id
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationUserId ||
|
impersonationUserId ||
|
||||||
@ -101,16 +105,18 @@ export class AccountController {
|
|||||||
) {
|
) {
|
||||||
accountsWithAggregations = {
|
accountsWithAggregations = {
|
||||||
...nullifyValuesInObject(accountsWithAggregations, [
|
...nullifyValuesInObject(accountsWithAggregations, [
|
||||||
'totalBalance',
|
'totalBalanceInBaseCurrency',
|
||||||
'totalValue'
|
'totalValueInBaseCurrency'
|
||||||
]),
|
]),
|
||||||
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
||||||
'balance',
|
'balance',
|
||||||
|
'balanceInBaseCurrency',
|
||||||
'convertedBalance',
|
'convertedBalance',
|
||||||
'fee',
|
'fee',
|
||||||
'quantity',
|
'quantity',
|
||||||
'unitPrice',
|
'unitPrice',
|
||||||
'value'
|
'value',
|
||||||
|
'valueInBaseCurrency'
|
||||||
])
|
])
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -120,13 +126,45 @@ export class AccountController {
|
|||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getAccountById(@Param('id') id: string): Promise<AccountModel> {
|
public async getAccountById(
|
||||||
return this.accountService.account({
|
@Headers('impersonation-id') impersonationId,
|
||||||
id_userId: {
|
@Param('id') id: string
|
||||||
id,
|
): Promise<AccountWithValue> {
|
||||||
userId: this.request.user.id
|
const impersonationUserId =
|
||||||
}
|
await this.impersonationService.validateImpersonationId(
|
||||||
});
|
impersonationId,
|
||||||
|
this.request.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
let accountsWithAggregations =
|
||||||
|
await this.portfolioService.getAccountsWithAggregations(
|
||||||
|
impersonationUserId || this.request.user.id,
|
||||||
|
[{ id, type: 'ACCOUNT' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
impersonationUserId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
accountsWithAggregations = {
|
||||||
|
...nullifyValuesInObject(accountsWithAggregations, [
|
||||||
|
'totalBalanceInBaseCurrency',
|
||||||
|
'totalValueInBaseCurrency'
|
||||||
|
]),
|
||||||
|
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
||||||
|
'balance',
|
||||||
|
'balanceInBaseCurrency',
|
||||||
|
'convertedBalance',
|
||||||
|
'fee',
|
||||||
|
'quantity',
|
||||||
|
'unitPrice',
|
||||||
|
'value',
|
||||||
|
'valueInBaseCurrency'
|
||||||
|
])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountsWithAggregations.accounts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ -13,6 +13,7 @@ import { AccountService } from './account.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AccountController],
|
controllers: [AccountController],
|
||||||
|
exports: [AccountService],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
import { CashDetails } from './interfaces/cash-details.interface';
|
import { CashDetails } from './interfaces/cash-details.interface';
|
||||||
|
|
||||||
@ -101,25 +104,51 @@ export class AccountService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCashDetails(
|
public async getCashDetails({
|
||||||
aUserId: string,
|
currency,
|
||||||
aCurrency: string
|
filters = [],
|
||||||
): Promise<CashDetails> {
|
userId
|
||||||
let totalCashBalance = 0;
|
}: {
|
||||||
|
currency: string;
|
||||||
|
filters?: Filter[];
|
||||||
|
userId: string;
|
||||||
|
}): Promise<CashDetails> {
|
||||||
|
let totalCashBalanceInBaseCurrency = new Big(0);
|
||||||
|
|
||||||
const accounts = await this.accounts({
|
const where: Prisma.AccountWhereInput = { userId };
|
||||||
where: { userId: aUserId }
|
|
||||||
|
const {
|
||||||
|
ACCOUNT: filtersByAccount,
|
||||||
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
|
TAG: filtersByTag
|
||||||
|
} = groupBy(filters, (filter) => {
|
||||||
|
return filter.type;
|
||||||
});
|
});
|
||||||
|
|
||||||
accounts.forEach((account) => {
|
if (filtersByAccount?.length > 0) {
|
||||||
totalCashBalance += this.exchangeRateDataService.toCurrency(
|
where.id = {
|
||||||
account.balance,
|
in: filtersByAccount.map(({ id }) => {
|
||||||
account.currency,
|
return id;
|
||||||
aCurrency
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await this.accounts({ where });
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
|
||||||
|
this.exchangeRateDataService.toCurrency(
|
||||||
|
account.balance,
|
||||||
|
account.currency,
|
||||||
|
currency
|
||||||
|
)
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
return { accounts, balance: totalCashBalance };
|
return {
|
||||||
|
accounts,
|
||||||
|
balanceInBaseCurrency: totalCashBalanceInBaseCurrency.toNumber()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateAccount(
|
public async updateAccount(
|
||||||
|
@ -2,5 +2,5 @@ import { Account } from '@prisma/client';
|
|||||||
|
|
||||||
export interface CashDetails {
|
export interface CashDetails {
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
balance: number;
|
balanceInBaseCurrency: number;
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,15 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
|
import {
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails
|
AdminMarketDataDetails,
|
||||||
|
Filter
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
@ -18,6 +23,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
@ -56,6 +62,24 @@ export class AdminController {
|
|||||||
return this.adminService.get();
|
return this.adminService.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('gather')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gather7Days(): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gather7Days();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('gather/max')
|
@Post('gather/max')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherMax(): Promise<void> {
|
public async gatherMax(): Promise<void> {
|
||||||
@ -71,10 +95,20 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.gatherProfileData();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
this.dataGatheringService.gatherMax();
|
|
||||||
|
|
||||||
return;
|
for (const { dataSource, symbol } of uniqueAssets) {
|
||||||
|
await this.dataGatheringService.addJobToQueue(
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherMax();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data')
|
@Post('gather/profile-data')
|
||||||
@ -92,9 +126,18 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringService.gatherProfileData();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
return;
|
for (const { dataSource, symbol } of uniqueAssets) {
|
||||||
|
await this.dataGatheringService.addJobToQueue(
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data/:dataSource/:symbol')
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
@ -115,9 +158,14 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
|
await this.dataGatheringService.addJobToQueue(
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
return;
|
{
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
@ -180,7 +228,9 @@ export class AdminController {
|
|||||||
|
|
||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getMarketData(): Promise<AdminMarketData> {
|
public async getMarketData(
|
||||||
|
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
||||||
|
): Promise<AdminMarketData> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
this.request.user.permissions,
|
this.request.user.permissions,
|
||||||
@ -193,7 +243,18 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.adminService.getMarketData();
|
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||||
|
|
||||||
|
const filters: Filter[] = [
|
||||||
|
...assetSubClasses.map((assetSubClass) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: assetSubClass,
|
||||||
|
type: 'ASSET_SUB_CLASS'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
return this.adminService.getMarketData(filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
|
@ -11,6 +11,7 @@ import { Module } from '@nestjs/common';
|
|||||||
|
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
|
import { QueueModule } from './queue/queue.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -21,6 +22,7 @@ import { AdminService } from './admin.service';
|
|||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
|
QueueModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
|
@ -1,67 +1,63 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminMarketDataItem
|
AdminMarketDataItem,
|
||||||
|
Filter,
|
||||||
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Property } from '@prisma/client';
|
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
|
private baseCurrency: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {
|
||||||
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
|
}
|
||||||
|
|
||||||
public async deleteProfileData({
|
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
dataSource,
|
|
||||||
symbol
|
|
||||||
}: {
|
|
||||||
dataSource: DataSource;
|
|
||||||
symbol: string;
|
|
||||||
}) {
|
|
||||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(): Promise<AdminData> {
|
public async get(): Promise<AdminData> {
|
||||||
return {
|
return {
|
||||||
dataGatheringProgress:
|
|
||||||
await this.dataGatheringService.getDataGatheringProgress(),
|
|
||||||
exchangeRates: this.exchangeRateDataService
|
exchangeRates: this.exchangeRateDataService
|
||||||
.getCurrencies()
|
.getCurrencies()
|
||||||
.filter((currency) => {
|
.filter((currency) => {
|
||||||
return currency !== baseCurrency;
|
return currency !== this.baseCurrency;
|
||||||
})
|
})
|
||||||
.map((currency) => {
|
.map((currency) => {
|
||||||
return {
|
return {
|
||||||
label1: baseCurrency,
|
label1: this.baseCurrency,
|
||||||
label2: currency,
|
label2: currency,
|
||||||
value: this.exchangeRateDataService.toCurrency(
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
1,
|
1,
|
||||||
baseCurrency,
|
this.baseCurrency,
|
||||||
currency
|
currency
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
|
||||||
settings: await this.propertyService.get(),
|
settings: await this.propertyService.get(),
|
||||||
transactionCount: await this.prismaService.order.count(),
|
transactionCount: await this.prismaService.order.count(),
|
||||||
userCount: await this.prismaService.user.count(),
|
userCount: await this.prismaService.user.count(),
|
||||||
@ -69,14 +65,27 @@ export class AdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketData(): Promise<AdminMarketData> {
|
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
|
||||||
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
|
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||||
|
filters,
|
||||||
|
(filter) => {
|
||||||
|
return filter.type;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const marketData = await this.prismaService.marketData.groupBy({
|
const marketData = await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['dataSource', 'symbol']
|
by: ['dataSource', 'symbol']
|
||||||
});
|
});
|
||||||
|
|
||||||
const currencyPairsToGather: AdminMarketDataItem[] =
|
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
||||||
this.exchangeRateDataService
|
|
||||||
|
if (filtersByAssetSubClass) {
|
||||||
|
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||||
|
} else {
|
||||||
|
currencyPairsToGather = this.exchangeRateDataService
|
||||||
.getCurrencyPairs()
|
.getCurrencyPairs()
|
||||||
.map(({ dataSource, symbol }) => {
|
.map(({ dataSource, symbol }) => {
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
@ -90,17 +99,24 @@ export class AdminService {
|
|||||||
return {
|
return {
|
||||||
dataSource,
|
dataSource,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
symbol
|
symbol,
|
||||||
|
countriesCount: 0,
|
||||||
|
sectorsCount: 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||||
await this.prismaService.symbolProfile.findMany({
|
await this.prismaService.symbolProfile.findMany({
|
||||||
|
where,
|
||||||
orderBy: [{ symbol: 'asc' }],
|
orderBy: [{ symbol: 'asc' }],
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { Order: true }
|
select: { Order: true }
|
||||||
},
|
},
|
||||||
|
assetClass: true,
|
||||||
|
assetSubClass: true,
|
||||||
|
countries: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
Order: {
|
Order: {
|
||||||
orderBy: [{ date: 'asc' }],
|
orderBy: [{ date: 'asc' }],
|
||||||
@ -108,10 +124,14 @@ export class AdminService {
|
|||||||
take: 1
|
take: 1
|
||||||
},
|
},
|
||||||
scraperConfiguration: true,
|
scraperConfiguration: true,
|
||||||
|
sectors: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).map((symbolProfile) => {
|
).map((symbolProfile) => {
|
||||||
|
const countriesCount = symbolProfile.countries
|
||||||
|
? Object.keys(symbolProfile.countries).length
|
||||||
|
: 0;
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
marketData.find((marketDataItem) => {
|
marketData.find((marketDataItem) => {
|
||||||
return (
|
return (
|
||||||
@ -119,10 +139,17 @@ export class AdminService {
|
|||||||
marketDataItem.symbol === symbolProfile.symbol
|
marketDataItem.symbol === symbolProfile.symbol
|
||||||
);
|
);
|
||||||
})?._count ?? 0;
|
})?._count ?? 0;
|
||||||
|
const sectorsCount = symbolProfile.sectors
|
||||||
|
? Object.keys(symbolProfile.sectors).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
countriesCount,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
|
sectorsCount,
|
||||||
activityCount: symbolProfile._count.Order,
|
activityCount: symbolProfile._count.Order,
|
||||||
|
assetClass: symbolProfile.assetClass,
|
||||||
|
assetSubClass: symbolProfile.assetSubClass,
|
||||||
dataSource: symbolProfile.dataSource,
|
dataSource: symbolProfile.dataSource,
|
||||||
date: symbolProfile.Order?.[0]?.date,
|
date: symbolProfile.Order?.[0]?.date,
|
||||||
symbol: symbolProfile.symbol
|
symbol: symbolProfile.symbol
|
||||||
@ -137,10 +164,7 @@ export class AdminService {
|
|||||||
public async getMarketDataBySymbol({
|
public async getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: {
|
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||||
dataSource: DataSource;
|
|
||||||
symbol: string;
|
|
||||||
}): Promise<AdminMarketDataDetails> {
|
|
||||||
return {
|
return {
|
||||||
marketData: await this.marketDataService.marketDataItems({
|
marketData: await this.marketDataService.marketDataItems({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@ -165,30 +189,11 @@ export class AdminService {
|
|||||||
|
|
||||||
if (key === PROPERTY_CURRENCIES) {
|
if (key === PROPERTY_CURRENCIES) {
|
||||||
await this.exchangeRateDataService.initialize();
|
await this.exchangeRateDataService.initialize();
|
||||||
await this.dataGatheringService.reset();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLastDataGathering() {
|
|
||||||
const lastDataGathering =
|
|
||||||
await this.dataGatheringService.getLastDataGathering();
|
|
||||||
|
|
||||||
if (lastDataGathering) {
|
|
||||||
return lastDataGathering;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataGatheringInProgress =
|
|
||||||
await this.dataGatheringService.getIsInProgress();
|
|
||||||
|
|
||||||
if (dataGatheringInProgress) {
|
|
||||||
return 'IN_PROGRESS';
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@ -200,7 +205,6 @@ export class AdminService {
|
|||||||
_count: {
|
_count: {
|
||||||
select: { Account: true, Order: true }
|
select: { Account: true, Order: true }
|
||||||
},
|
},
|
||||||
alias: true,
|
|
||||||
Analytics: {
|
Analytics: {
|
||||||
select: {
|
select: {
|
||||||
activityCount: true,
|
activityCount: true,
|
||||||
@ -220,7 +224,7 @@ export class AdminService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return usersWithAnalytics.map(
|
return usersWithAnalytics.map(
|
||||||
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
|
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||||
const daysSinceRegistration =
|
const daysSinceRegistration =
|
||||||
differenceInDays(new Date(), createdAt) + 1;
|
differenceInDays(new Date(), createdAt) + 1;
|
||||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
const engagement = Analytics.activityCount / daysSinceRegistration;
|
||||||
@ -232,7 +236,6 @@ export class AdminService {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alias,
|
|
||||||
createdAt,
|
createdAt,
|
||||||
engagement,
|
engagement,
|
||||||
id,
|
id,
|
||||||
|
87
apps/api/src/app/admin/queue/queue.controller.ts
Normal file
87
apps/api/src/app/admin/queue/queue.controller.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { JobStatus } from 'bull';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { QueueService } from './queue.service';
|
||||||
|
|
||||||
|
@Controller('admin/queue')
|
||||||
|
export class QueueController {
|
||||||
|
public constructor(
|
||||||
|
private readonly queueService: QueueService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Delete('job')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteJobs(
|
||||||
|
@Query('status') filterByStatus?: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
|
return this.queueService.deleteJobs({ status });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('job')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getJobs(
|
||||||
|
@Query('status') filterByStatus?: string
|
||||||
|
): Promise<AdminJobs> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
|
return this.queueService.getJobs({ status });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('job/:id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteJob(@Param('id') id: string): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.queueService.deleteJob(id);
|
||||||
|
}
|
||||||
|
}
|
12
apps/api/src/app/admin/queue/queue.module.ts
Normal file
12
apps/api/src/app/admin/queue/queue.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { QueueController } from './queue.controller';
|
||||||
|
import { QueueService } from './queue.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [QueueController],
|
||||||
|
imports: [DataGatheringModule],
|
||||||
|
providers: [QueueService]
|
||||||
|
})
|
||||||
|
export class QueueModule {}
|
65
apps/api/src/app/admin/queue/queue.service.ts
Normal file
65
apps/api/src/app/admin/queue/queue.service.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
DATA_GATHERING_QUEUE,
|
||||||
|
QUEUE_JOB_STATUS_LIST
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { JobStatus, Queue } from 'bull';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class QueueService {
|
||||||
|
public constructor(
|
||||||
|
@InjectQueue(DATA_GATHERING_QUEUE)
|
||||||
|
private readonly dataGatheringQueue: Queue
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async deleteJob(aId: string) {
|
||||||
|
return (await this.dataGatheringQueue.getJob(aId))?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteJobs({
|
||||||
|
status = QUEUE_JOB_STATUS_LIST
|
||||||
|
}: {
|
||||||
|
status?: JobStatus[];
|
||||||
|
}) {
|
||||||
|
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
try {
|
||||||
|
await job.remove();
|
||||||
|
} catch (error) {
|
||||||
|
Logger.warn(error, 'QueueService');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getJobs({
|
||||||
|
limit = 1000,
|
||||||
|
status = QUEUE_JOB_STATUS_LIST
|
||||||
|
}: {
|
||||||
|
limit?: number;
|
||||||
|
status?: JobStatus[];
|
||||||
|
}): Promise<AdminJobs> {
|
||||||
|
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||||
|
|
||||||
|
const jobsWithState = await Promise.all(
|
||||||
|
jobs.slice(0, limit).map(async (job) => {
|
||||||
|
return {
|
||||||
|
attemptsMade: job.attemptsMade + 1,
|
||||||
|
data: job.data,
|
||||||
|
finishedOn: job.finishedOn,
|
||||||
|
id: job.id,
|
||||||
|
name: job.name,
|
||||||
|
stacktrace: job.stacktrace,
|
||||||
|
state: await job.getState(),
|
||||||
|
timestamp: job.timestamp
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobs: jobsWithState
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,26 +1,17 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { Controller } from '@nestjs/common';
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
import { RedisCacheService } from './redis-cache/redis-cache.service';
|
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly exchangeRateDataService: ExchangeRateDataService
|
||||||
private readonly redisCacheService: RedisCacheService
|
|
||||||
) {
|
) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initialize() {
|
private async initialize() {
|
||||||
this.redisCacheService.reset();
|
try {
|
||||||
|
await this.exchangeRateDataService.initialize();
|
||||||
const isDataGatheringInProgress =
|
} catch {}
|
||||||
await this.dataGatheringService.getIsInProgress();
|
|
||||||
|
|
||||||
if (isDataGatheringInProgress) {
|
|
||||||
// Prepare for automatical data gathering, if hung up in progress state
|
|
||||||
await this.dataGatheringService.reset();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,9 @@ import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.mod
|
|||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||||
|
import { BullModule } from '@nestjs/bull';
|
||||||
|
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
@ -18,8 +20,10 @@ import { AccountModule } from './account/account.module';
|
|||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { 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 { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
|
import { FrontendMiddleware } from './frontend.middleware';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { InfoModule } from './info/info.module';
|
import { InfoModule } from './info/info.module';
|
||||||
import { OrderModule } from './order/order.module';
|
import { OrderModule } from './order/order.module';
|
||||||
@ -35,6 +39,14 @@ import { UserModule } from './user/user.module';
|
|||||||
AccountModule,
|
AccountModule,
|
||||||
AuthDeviceModule,
|
AuthDeviceModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
BenchmarkModule,
|
||||||
|
BullModule.forRoot({
|
||||||
|
redis: {
|
||||||
|
host: process.env.REDIS_HOST,
|
||||||
|
port: parseInt(process.env.REDIS_PORT, 10),
|
||||||
|
password: process.env.REDIS_PASSWORD
|
||||||
|
}
|
||||||
|
}),
|
||||||
CacheModule,
|
CacheModule,
|
||||||
ConfigModule.forRoot(),
|
ConfigModule.forRoot(),
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
@ -65,9 +77,16 @@ import { UserModule } from './user/user.module';
|
|||||||
}),
|
}),
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
|
TwitterBotModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [CronService]
|
providers: [CronService]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {
|
||||||
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer
|
||||||
|
.apply(FrontendMiddleware)
|
||||||
|
.forRoutes({ path: '*', method: RequestMethod.ALL });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,18 +1,20 @@
|
|||||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthDeviceController],
|
controllers: [AuthDeviceController],
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '180 days' }
|
signOptions: { expiresIn: '180 days' }
|
||||||
})
|
}),
|
||||||
|
PrismaModule
|
||||||
],
|
],
|
||||||
providers: [AuthDeviceService, ConfigurationService, PrismaService]
|
providers: [AuthDeviceService]
|
||||||
})
|
})
|
||||||
export class AuthDeviceModule {}
|
export class AuthDeviceModule {}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
|
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -9,7 +11,9 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
VERSION_NEUTRAL,
|
||||||
|
Version
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
@ -29,7 +33,9 @@ export class AuthController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('anonymous/:accessToken')
|
@Get('anonymous/:accessToken')
|
||||||
public async accessTokenLogin(@Param('accessToken') accessToken: string) {
|
public async accessTokenLogin(
|
||||||
|
@Param('accessToken') accessToken: string
|
||||||
|
): Promise<OAuthResponse> {
|
||||||
try {
|
try {
|
||||||
const authToken = await this.authService.validateAnonymousLogin(
|
const authToken = await this.authService.validateAnonymousLogin(
|
||||||
accessToken
|
accessToken
|
||||||
@ -51,14 +57,40 @@ export class AuthController {
|
|||||||
|
|
||||||
@Get('google/callback')
|
@Get('google/callback')
|
||||||
@UseGuards(AuthGuard('google'))
|
@UseGuards(AuthGuard('google'))
|
||||||
|
@Version(VERSION_NEUTRAL)
|
||||||
public googleLoginCallback(@Req() req, @Res() res) {
|
public googleLoginCallback(@Req() req, @Res() res) {
|
||||||
// Handles the Google OAuth2 callback
|
// Handles the Google OAuth2 callback
|
||||||
const jwt: string = req.user.jwt;
|
const jwt: string = req.user.jwt;
|
||||||
|
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth/${jwt}`);
|
res.redirect(
|
||||||
|
`${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
|
res.redirect(
|
||||||
|
`${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('internet-identity/:principalId')
|
||||||
|
public async internetIdentityLogin(
|
||||||
|
@Param('principalId') principalId: string
|
||||||
|
): Promise<OAuthResponse> {
|
||||||
|
try {
|
||||||
|
const authToken = await this.authService.validateInternetIdentityLogin(
|
||||||
|
principalId
|
||||||
|
);
|
||||||
|
return { authToken };
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,8 +2,8 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -15,20 +15,20 @@ import { JwtStrategy } from './jwt.strategy';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '180 days' }
|
signOptions: { expiresIn: '180 days' }
|
||||||
}),
|
}),
|
||||||
|
PrismaModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AuthDeviceService,
|
AuthDeviceService,
|
||||||
AuthService,
|
AuthService,
|
||||||
ConfigurationService,
|
|
||||||
GoogleStrategy,
|
GoogleStrategy,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
PrismaService,
|
|
||||||
WebAuthService
|
WebAuthService
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -2,6 +2,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Provider } from '@prisma/client';
|
||||||
|
|
||||||
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
@ -13,7 +14,7 @@ export class AuthService {
|
|||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async validateAnonymousLogin(accessToken: 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(
|
||||||
@ -26,7 +27,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const jwt: string = this.jwtService.sign({
|
const jwt = this.jwtService.sign({
|
||||||
id: user.id
|
id: user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -40,6 +41,33 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async validateInternetIdentityLogin(principalId: string) {
|
||||||
|
try {
|
||||||
|
const provider: Provider = 'INTERNET_IDENTITY';
|
||||||
|
|
||||||
|
let [user] = await this.userService.users({
|
||||||
|
where: { provider, thirdPartyId: principalId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Create new user if not found
|
||||||
|
user = await this.userService.createUser({
|
||||||
|
provider,
|
||||||
|
thirdPartyId: principalId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.jwtService.sign({
|
||||||
|
id: user.id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
'validateInternetIdentityLogin',
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async validateOAuthLogin({
|
public async validateOAuthLogin({
|
||||||
provider,
|
provider,
|
||||||
thirdPartyId
|
thirdPartyId
|
||||||
@ -57,13 +85,14 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const jwt: string = this.jwtService.sign({
|
return this.jwtService.sign({
|
||||||
id: user.id
|
id: user.id
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
return jwt;
|
throw new InternalServerErrorException(
|
||||||
} catch (err) {
|
'validateOAuthLogin',
|
||||||
throw new InternalServerErrorException('validateOAuthLogin', err.message);
|
error.message
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -42,7 +42,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
|||||||
|
|
||||||
done(null, user);
|
done(null, user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'GoogleStrategy');
|
||||||
done(error, false);
|
done(error, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -54,7 +54,7 @@ export class WebAuthService {
|
|||||||
rpName: 'Ghostfolio',
|
rpName: 'Ghostfolio',
|
||||||
rpID: this.rpID,
|
rpID: this.rpID,
|
||||||
userID: user.id,
|
userID: user.id,
|
||||||
userName: user.alias,
|
userName: '',
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
attestationType: 'indirect',
|
attestationType: 'indirect',
|
||||||
authenticatorSelection: {
|
authenticatorSelection: {
|
||||||
@ -95,7 +95,7 @@ export class WebAuthService {
|
|||||||
};
|
};
|
||||||
verification = await verifyRegistrationResponse(opts);
|
verification = await verifyRegistrationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'WebAuthService');
|
||||||
throw new InternalServerErrorException(error.message);
|
throw new InternalServerErrorException(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,7 +193,7 @@ export class WebAuthService {
|
|||||||
};
|
};
|
||||||
verification = verifyAuthenticationResponse(opts);
|
verification = verifyAuthenticationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'WebAuthService');
|
||||||
throw new InternalServerErrorException({ error: error.message });
|
throw new InternalServerErrorException({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
48
apps/api/src/app/benchmark/benchmark.controller.ts
Normal file
48
apps/api/src/app/benchmark/benchmark.controller.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
|
import {
|
||||||
|
BenchmarkMarketDataDetails,
|
||||||
|
BenchmarkResponse
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
|
@Controller('benchmark')
|
||||||
|
export class BenchmarkController {
|
||||||
|
public constructor(private readonly benchmarkService: BenchmarkService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async getBenchmark(): Promise<BenchmarkResponse> {
|
||||||
|
return {
|
||||||
|
benchmarks: await this.benchmarkService.getBenchmarks()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':dataSource/:symbol/:startDateString')
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getBenchmarkMarketDataBySymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('startDateString') startDateString: string,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<BenchmarkMarketDataDetails> {
|
||||||
|
const startDate = new Date(startDateString);
|
||||||
|
|
||||||
|
return this.benchmarkService.getMarketDataBySymbol({
|
||||||
|
dataSource,
|
||||||
|
startDate,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
27
apps/api/src/app/benchmark/benchmark.module.ts
Normal file
27
apps/api/src/app/benchmark/benchmark.module.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { BenchmarkController } from './benchmark.controller';
|
||||||
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [BenchmarkController],
|
||||||
|
exports: [BenchmarkService],
|
||||||
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataProviderModule,
|
||||||
|
MarketDataModule,
|
||||||
|
PropertyModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
],
|
||||||
|
providers: [BenchmarkService]
|
||||||
|
})
|
||||||
|
export class BenchmarkModule {}
|
15
apps/api/src/app/benchmark/benchmark.service.spec.ts
Normal file
15
apps/api/src/app/benchmark/benchmark.service.spec.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
|
describe('BenchmarkService', () => {
|
||||||
|
let benchmarkService: BenchmarkService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculateChangeInPercentage', async () => {
|
||||||
|
expect(benchmarkService.calculateChangeInPercentage(1, 2)).toEqual(1);
|
||||||
|
expect(benchmarkService.calculateChangeInPercentage(2, 2)).toEqual(0);
|
||||||
|
expect(benchmarkService.calculateChangeInPercentage(2, 1)).toEqual(-0.5);
|
||||||
|
});
|
||||||
|
});
|
187
apps/api/src/app/benchmark/benchmark.service.ts
Normal file
187
apps/api/src/app/benchmark/benchmark.service.ts
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
BenchmarkMarketDataDetails,
|
||||||
|
BenchmarkResponse,
|
||||||
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { SymbolProfile } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import ms from 'ms';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BenchmarkService {
|
||||||
|
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly marketDataService: MarketDataService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
|
private readonly redisCacheService: RedisCacheService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService,
|
||||||
|
private readonly symbolService: SymbolService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public calculateChangeInPercentage(baseValue: number, currentValue: number) {
|
||||||
|
if (baseValue && currentValue) {
|
||||||
|
return new Big(currentValue).div(baseValue).minus(1).toNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBenchmarks({ useCache = true } = {}): Promise<
|
||||||
|
BenchmarkResponse['benchmarks']
|
||||||
|
> {
|
||||||
|
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||||
|
|
||||||
|
if (useCache) {
|
||||||
|
try {
|
||||||
|
benchmarks = JSON.parse(
|
||||||
|
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (benchmarks) {
|
||||||
|
return benchmarks;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
||||||
|
|
||||||
|
const promises: Promise<number>[] = [];
|
||||||
|
|
||||||
|
const quotes = await this.dataProviderService.getQuotes(
|
||||||
|
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
|
return { dataSource, symbol };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||||
|
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTimeHighs = await Promise.all(promises);
|
||||||
|
|
||||||
|
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||||
|
const { marketPrice } =
|
||||||
|
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
|
||||||
|
|
||||||
|
let performancePercentFromAllTimeHigh = 0;
|
||||||
|
|
||||||
|
if (allTimeHigh && marketPrice) {
|
||||||
|
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||||
|
allTimeHigh,
|
||||||
|
marketPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketCondition: this.getMarketCondition(
|
||||||
|
performancePercentFromAllTimeHigh
|
||||||
|
),
|
||||||
|
name: benchmarkAssetProfiles[index].name,
|
||||||
|
performances: {
|
||||||
|
allTimeHigh: {
|
||||||
|
performancePercent: performancePercentFromAllTimeHigh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.redisCacheService.set(
|
||||||
|
this.CACHE_KEY_BENCHMARKS,
|
||||||
|
JSON.stringify(benchmarks),
|
||||||
|
ms('4 hours') / 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
return benchmarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||||
|
const symbolProfileIds: string[] = (
|
||||||
|
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
|
||||||
|
symbolProfileId: string;
|
||||||
|
}[]) ?? []
|
||||||
|
).map(({ symbolProfileId }) => {
|
||||||
|
return symbolProfileId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const assetProfiles =
|
||||||
|
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
||||||
|
|
||||||
|
return assetProfiles
|
||||||
|
.map(({ dataSource, id, name, symbol }) => {
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
symbol
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMarketDataBySymbol({
|
||||||
|
dataSource,
|
||||||
|
startDate,
|
||||||
|
symbol
|
||||||
|
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||||
|
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||||
|
this.symbolService.get({
|
||||||
|
dataGatheringItem: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
this.marketDataService.marketDataItems({
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: {
|
||||||
|
gte: startDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
marketDataItems.push({
|
||||||
|
...currentSymbolItem,
|
||||||
|
createdAt: new Date(),
|
||||||
|
date: new Date(),
|
||||||
|
id: uuidv4()
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
|
||||||
|
return {
|
||||||
|
marketData: marketDataItems.map((marketDataItem) => {
|
||||||
|
return {
|
||||||
|
date: format(marketDataItem.date, DATE_FORMAT),
|
||||||
|
value:
|
||||||
|
marketPriceAtStartDate === 0
|
||||||
|
? 0
|
||||||
|
: this.calculateChangeInPercentage(
|
||||||
|
marketPriceAtStartDate,
|
||||||
|
marketDataItem.marketPrice
|
||||||
|
) * 100
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMarketCondition(aPerformanceInPercent: number) {
|
||||||
|
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||||
|
}
|
||||||
|
}
|
30
apps/api/src/app/cache/cache.controller.ts
vendored
30
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,25 +1,39 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Post,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@Controller('cache')
|
@Controller('cache')
|
||||||
export class CacheController {
|
export class CacheController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly cacheService: CacheService,
|
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {
|
) {}
|
||||||
this.redisCacheService.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('flush')
|
@Post('flush')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async flushCache(): Promise<void> {
|
public async flushCache(): Promise<void> {
|
||||||
this.redisCacheService.reset();
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.cacheService.flush();
|
return this.redisCacheService.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
16
apps/api/src/app/cache/cache.module.ts
vendored
16
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,30 +1,24 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [CacheController],
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
|
||||||
controllers: [CacheController],
|
|
||||||
providers: [
|
|
||||||
CacheService,
|
|
||||||
ConfigurationService,
|
|
||||||
DataGatheringService,
|
|
||||||
PrismaService
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CacheModule {}
|
export class CacheModule {}
|
||||||
|
15
apps/api/src/app/cache/cache.service.ts
vendored
15
apps/api/src/app/cache/cache.service.ts
vendored
@ -1,15 +0,0 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CacheService {
|
|
||||||
public constructor(
|
|
||||||
private readonly dataGaterhingService: DataGatheringService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async flush(): Promise<void> {
|
|
||||||
await this.dataGaterhingService.reset();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,13 +1,6 @@
|
|||||||
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 {
|
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Headers,
|
|
||||||
Inject,
|
|
||||||
Query,
|
|
||||||
UseGuards
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@ -14,12 +14,11 @@ export class ExportService {
|
|||||||
activityIds?: string[];
|
activityIds?: string[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Export> {
|
}): Promise<Export> {
|
||||||
let orders = await this.prismaService.order.findMany({
|
let activities = await this.prismaService.order.findMany({
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
accountId: true,
|
accountId: true,
|
||||||
currency: true,
|
comment: true,
|
||||||
dataSource: true,
|
|
||||||
date: true,
|
date: true,
|
||||||
fee: true,
|
fee: true,
|
||||||
id: true,
|
id: true,
|
||||||
@ -32,19 +31,20 @@ export class ExportService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (activityIds) {
|
if (activityIds) {
|
||||||
orders = orders.filter((order) => {
|
activities = activities.filter((activity) => {
|
||||||
return activityIds.includes(order.id);
|
return activityIds.includes(activity.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta: { date: new Date().toISOString(), version: environment.version },
|
meta: { date: new Date().toISOString(), version: environment.version },
|
||||||
orders: orders.map(
|
activities: activities.map(
|
||||||
({
|
({
|
||||||
accountId,
|
accountId,
|
||||||
currency,
|
comment,
|
||||||
date,
|
date,
|
||||||
fee,
|
fee,
|
||||||
|
id,
|
||||||
quantity,
|
quantity,
|
||||||
SymbolProfile,
|
SymbolProfile,
|
||||||
type,
|
type,
|
||||||
@ -52,14 +52,16 @@ export class ExportService {
|
|||||||
}) => {
|
}) => {
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
currency,
|
comment,
|
||||||
date,
|
|
||||||
fee,
|
fee,
|
||||||
|
id,
|
||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
|
currency: SymbolProfile.currency,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
symbol: SymbolProfile.symbol
|
date: date.toISOString(),
|
||||||
|
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
99
apps/api/src/app/frontend.middleware.ts
Normal file
99
apps/api/src/app/frontend.middleware.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FrontendMiddleware implements NestMiddleware {
|
||||||
|
public indexHtmlDe = '';
|
||||||
|
public indexHtmlEn = '';
|
||||||
|
public isProduction: boolean;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {
|
||||||
|
const NODE_ENV =
|
||||||
|
this.configService.get<'development' | 'production'>('NODE_ENV') ??
|
||||||
|
'development';
|
||||||
|
|
||||||
|
this.isProduction = NODE_ENV === 'production';
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.indexHtmlDe = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('de'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
this.indexHtmlEn = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public use(req: Request, res: Response, next: NextFunction) {
|
||||||
|
let featureGraphicPath = 'assets/cover.png';
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.path === '/en/blog/2022/08/500-stars-on-github' ||
|
||||||
|
req.path === '/en/blog/2022/08/500-stars-on-github/'
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.path.startsWith('/api/') ||
|
||||||
|
this.isFileRequest(req.url) ||
|
||||||
|
!this.isProduction
|
||||||
|
) {
|
||||||
|
// Skip
|
||||||
|
next();
|
||||||
|
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
|
||||||
|
res.send(
|
||||||
|
this.interpolate(this.indexHtmlDe, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: 'de',
|
||||||
|
path: req.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
res.send(
|
||||||
|
this.interpolate(this.indexHtmlEn, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||||
|
path: req.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPathOfIndexHtmlFile(aLocale: string) {
|
||||||
|
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
private interpolate(template: string, context: any) {
|
||||||
|
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
|
||||||
|
const properties = objectPath.split('.');
|
||||||
|
return properties.reduce(
|
||||||
|
(previous, current) => previous?.[current],
|
||||||
|
context
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isFileRequest(filename: string) {
|
||||||
|
if (filename === '/assets/LICENSE') {
|
||||||
|
return true;
|
||||||
|
} else if (filename.includes('auth/ey')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename.split('.').pop() !== filename;
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,4 @@
|
|||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { Order } from '@prisma/client';
|
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, ValidateNested } from 'class-validator';
|
import { IsArray, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
@ -7,5 +6,5 @@ export class ImportDataDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@Type(() => CreateOrderDto)
|
@Type(() => CreateOrderDto)
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
orders: Order[];
|
activities: CreateOrderDto[];
|
||||||
}
|
}
|
||||||
|
@ -34,13 +34,25 @@ export class ImportController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let maxActivitiesToImport = this.configurationService.get(
|
||||||
|
'MAX_ACTIVITIES_TO_IMPORT'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Premium'
|
||||||
|
) {
|
||||||
|
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.importService.import({
|
return await this.importService.import({
|
||||||
orders: importData.orders,
|
maxActivitiesToImport,
|
||||||
|
activities: importData.activities,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, ImportController);
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
{
|
{
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
@ -11,7 +12,10 @@ import { ImportController } from './import.controller';
|
|||||||
import { ImportService } from './import.service';
|
import { ImportService } from './import.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [ImportController],
|
||||||
imports: [
|
imports: [
|
||||||
|
AccountModule,
|
||||||
|
CacheModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
@ -19,7 +23,6 @@ import { ImportService } from './import.service';
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule
|
RedisCacheModule
|
||||||
],
|
],
|
||||||
controllers: [ImportController],
|
providers: [ImportService]
|
||||||
providers: [CacheService, ImportService]
|
|
||||||
})
|
})
|
||||||
export class ImportModule {}
|
export class ImportModule {}
|
||||||
|
@ -1,34 +1,54 @@
|
|||||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/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 { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Order } from '@prisma/client';
|
|
||||||
import { isSameDay, parseISO } from 'date-fns';
|
import { isSameDay, parseISO } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportService {
|
export class ImportService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accountService: AccountService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly orderService: OrderService
|
private readonly orderService: OrderService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async import({
|
public async import({
|
||||||
orders,
|
activities,
|
||||||
|
maxActivitiesToImport,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
orders: Partial<Order>[];
|
activities: Partial<CreateOrderDto>[];
|
||||||
|
maxActivitiesToImport: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
for (const order of orders) {
|
for (const activity of activities) {
|
||||||
order.dataSource =
|
if (!activity.dataSource) {
|
||||||
order.dataSource ?? this.dataProviderService.getPrimaryDataSource();
|
if (activity.type === 'ITEM') {
|
||||||
|
activity.dataSource = 'MANUAL';
|
||||||
|
} else {
|
||||||
|
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.validateOrders({ orders, userId });
|
await this.validateActivities({
|
||||||
|
activities,
|
||||||
|
maxActivitiesToImport,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
||||||
|
(account) => {
|
||||||
|
return account.id;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
accountId,
|
accountId,
|
||||||
|
comment,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date,
|
||||||
@ -37,21 +57,20 @@ export class ImportService {
|
|||||||
symbol,
|
symbol,
|
||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
} of orders) {
|
} of activities) {
|
||||||
await this.orderService.createOrder({
|
await this.orderService.createOrder({
|
||||||
accountId,
|
comment,
|
||||||
currency,
|
|
||||||
dataSource,
|
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
symbol,
|
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
userId,
|
userId,
|
||||||
|
accountId: accountIds.includes(accountId) ? accountId : undefined,
|
||||||
date: parseISO(<string>(<unknown>date)),
|
date: parseISO(<string>(<unknown>date)),
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
create: {
|
create: {
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
},
|
},
|
||||||
@ -68,24 +87,21 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateOrders({
|
private async validateActivities({
|
||||||
orders,
|
activities,
|
||||||
|
maxActivitiesToImport,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
orders: Partial<Order>[];
|
activities: Partial<CreateOrderDto>[];
|
||||||
|
maxActivitiesToImport: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
if (
|
if (activities?.length > maxActivitiesToImport) {
|
||||||
orders?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
|
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Too many transactions (${this.configurationService.get(
|
|
||||||
'MAX_ORDERS_TO_IMPORT'
|
|
||||||
)} at most)`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingOrders = await this.orderService.orders({
|
const existingActivities = await this.orderService.orders({
|
||||||
|
include: { SymbolProfile: true },
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
where: { userId }
|
where: { userId }
|
||||||
});
|
});
|
||||||
@ -93,38 +109,40 @@ export class ImportService {
|
|||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||||
] of orders.entries()) {
|
] of activities.entries()) {
|
||||||
const duplicateOrder = existingOrders.find((order) => {
|
const duplicateActivity = existingActivities.find((activity) => {
|
||||||
return (
|
return (
|
||||||
order.currency === currency &&
|
activity.SymbolProfile.currency === currency &&
|
||||||
order.dataSource === dataSource &&
|
activity.SymbolProfile.dataSource === dataSource &&
|
||||||
isSameDay(order.date, parseISO(<string>(<unknown>date))) &&
|
isSameDay(activity.date, parseISO(<string>(<unknown>date))) &&
|
||||||
order.fee === fee &&
|
activity.fee === fee &&
|
||||||
order.quantity === quantity &&
|
activity.quantity === quantity &&
|
||||||
order.symbol === symbol &&
|
activity.SymbolProfile.symbol === symbol &&
|
||||||
order.type === type &&
|
activity.type === type &&
|
||||||
order.unitPrice === unitPrice
|
activity.unitPrice === unitPrice
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (duplicateOrder) {
|
if (duplicateActivity) {
|
||||||
throw new Error(`orders.${index} is a duplicate transaction`);
|
throw new Error(`activities.${index} is a duplicate activity`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.dataProviderService.get([
|
if (dataSource !== 'MANUAL') {
|
||||||
{ dataSource, symbol }
|
const quotes = await this.dataProviderService.getQuotes([
|
||||||
]);
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
if (result[symbol] === undefined) {
|
if (quotes[symbol] === undefined) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result[symbol].currency !== currency) {
|
if (quotes[symbol].currency !== currency) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"`
|
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
||||||
|
|
||||||
import { InfoService } from './info.service';
|
import { InfoService } from './info.service';
|
||||||
|
|
||||||
@ -8,6 +9,7 @@ export class InfoController {
|
|||||||
public constructor(private readonly infoService: InfoService) {}
|
public constructor(private readonly infoService: InfoService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getInfo(): Promise<InfoItem> {
|
public async getInfo(): Promise<InfoItem> {
|
||||||
return this.infoService.get();
|
return this.infoService.get();
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
|
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -14,7 +15,10 @@ import { InfoController } from './info.controller';
|
|||||||
import { InfoService } from './info.service';
|
import { InfoService } from './info.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [InfoController],
|
||||||
imports: [
|
imports: [
|
||||||
|
BenchmarkModule,
|
||||||
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
@ -22,16 +26,12 @@ import { InfoService } from './info.service';
|
|||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
}),
|
}),
|
||||||
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule,
|
||||||
|
TagModule
|
||||||
],
|
],
|
||||||
controllers: [InfoController],
|
providers: [InfoService]
|
||||||
providers: [
|
|
||||||
ConfigurationService,
|
|
||||||
DataGatheringService,
|
|
||||||
InfoService,
|
|
||||||
PrismaService
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class InfoModule {}
|
export class InfoModule {}
|
||||||
|
@ -1,25 +1,30 @@
|
|||||||
|
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
DEMO_USER_ID,
|
DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
PROPERTY_STRIPE_CONFIG,
|
||||||
PROPERTY_SYSTEM_MESSAGE
|
PROPERTY_SYSTEM_MESSAGE,
|
||||||
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { encodeDataSource } from '@ghostfolio/common/helper';
|
import {
|
||||||
|
encodeDataSource,
|
||||||
|
extractNumberFromString
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
import { subDays } from 'date-fns';
|
import { subDays } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -27,13 +32,14 @@ export class InfoService {
|
|||||||
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly benchmarkService: BenchmarkService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService
|
private readonly redisCacheService: RedisCacheService,
|
||||||
|
private readonly tagService: TagService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
@ -52,7 +58,17 @@ export class InfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
info.fearAndGreedDataSource = encodeDataSource(DataSource.RAKUTEN);
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||||
|
) {
|
||||||
|
info.fearAndGreedDataSource = encodeDataSource(
|
||||||
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalPermissions.push(permissions.enableFearAndGreedIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||||
@ -93,11 +109,13 @@ export class InfoService {
|
|||||||
isReadOnlyMode,
|
isReadOnlyMode,
|
||||||
platforms,
|
platforms,
|
||||||
systemMessage,
|
systemMessage,
|
||||||
|
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
||||||
|
benchmarks: await this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||||
demoAuthToken: this.getDemoAuthToken(),
|
demoAuthToken: this.getDemoAuthToken(),
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
|
||||||
statistics: await this.getStatistics(),
|
statistics: await this.getStatistics(),
|
||||||
subscriptions: await this.getSubscriptions()
|
subscriptions: await this.getSubscriptions(),
|
||||||
|
tags: await this.tagService.get()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,19 +148,23 @@ export class InfoService {
|
|||||||
private async countGitHubContributors(): Promise<number> {
|
private async countGitHubContributors(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const get = bent(
|
||||||
`https://api.github.com/repos/ghostfolio/ghostfolio/contributors`,
|
'https://github.com/ghostfolio/ghostfolio',
|
||||||
'GET',
|
'GET',
|
||||||
'json',
|
'string',
|
||||||
200,
|
200,
|
||||||
{
|
{}
|
||||||
'User-Agent': 'request'
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const contributors = await get();
|
const html = await get();
|
||||||
return contributors?.length;
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
return extractNumberFromString(
|
||||||
|
$(
|
||||||
|
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
||||||
|
).text()
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'InfoService');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -163,7 +185,7 @@ export class InfoService {
|
|||||||
const { stargazers_count } = await get();
|
const { stargazers_count } = await get();
|
||||||
return stargazers_count;
|
return stargazers_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'InfoService');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -203,13 +225,6 @@ export class InfoService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLastDataGathering() {
|
|
||||||
const lastDataGathering =
|
|
||||||
await this.dataGatheringService.getLastDataGathering();
|
|
||||||
|
|
||||||
return lastDataGathering ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getStatistics() {
|
private async getStatistics() {
|
||||||
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
|
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -1,23 +1,47 @@
|
|||||||
import { DataSource, Type } from '@prisma/client';
|
|
||||||
import {
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
Tag,
|
||||||
|
Type
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class CreateOrderDto {
|
export class CreateOrderDto {
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
accountId: string;
|
@IsString()
|
||||||
|
accountId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetClass, { each: true })
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetSubClass, { each: true })
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsEnum(DataSource, { each: true })
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
dataSource: DataSource;
|
@IsEnum(DataSource, { each: true })
|
||||||
|
dataSource?: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
date: string;
|
date: string;
|
||||||
@ -31,6 +55,10 @@ export class CreateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
tags?: Tag[];
|
||||||
|
|
||||||
@IsEnum(Type, { each: true })
|
@IsEnum(Type, { each: true })
|
||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||||
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
@ -16,6 +18,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -42,8 +45,12 @@ export class OrderController {
|
|||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||||
|
const order = await this.orderService.order({ id });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
|
||||||
|
!order ||
|
||||||
|
order.userId !== this.request.user.id
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -52,27 +59,54 @@ export class OrderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.orderService.deleteOrder({
|
return this.orderService.deleteOrder({
|
||||||
id_userId: {
|
id
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getAllOrders(
|
public async getAllOrders(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId,
|
||||||
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<Activities> {
|
): Promise<Activities> {
|
||||||
|
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||||
|
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||||
|
const tagIds = filterByTags?.split(',') ?? [];
|
||||||
|
|
||||||
|
const filters: Filter[] = [
|
||||||
|
...accountIds.map((accountId) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: accountId,
|
||||||
|
type: 'ACCOUNT'
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...assetClasses.map((assetClass) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: assetClass,
|
||||||
|
type: 'ASSET_CLASS'
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...tagIds.map((tagId) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: tagId,
|
||||||
|
type: 'TAG'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
let activities = await this.orderService.getOrders({
|
let activities = await this.orderService.getOrders({
|
||||||
|
filters,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
userId: impersonationUserId || this.request.user.id
|
userId: impersonationUserId || this.request.user.id
|
||||||
@ -114,6 +148,7 @@ export class OrderController {
|
|||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
create: {
|
create: {
|
||||||
|
currency: data.currency,
|
||||||
dataSource: data.dataSource,
|
dataSource: data.dataSource,
|
||||||
symbol: data.symbol
|
symbol: data.symbol
|
||||||
},
|
},
|
||||||
@ -134,23 +169,15 @@ export class OrderController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.updateOrder)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalOrder = await this.orderService.order({
|
const originalOrder = await this.orderService.order({
|
||||||
id_userId: {
|
id
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!originalOrder) {
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
|
||||||
|
!originalOrder ||
|
||||||
|
originalOrder.userId !== this.request.user.id
|
||||||
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -171,13 +198,23 @@ export class OrderController {
|
|||||||
id_userId: { id: accountId, userId: this.request.user.id }
|
id_userId: { id: accountId, userId: this.request.user.id }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
SymbolProfile: {
|
||||||
|
connect: {
|
||||||
|
dataSource_symbol: {
|
||||||
|
dataSource: data.dataSource,
|
||||||
|
symbol: data.symbol
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
assetClass: data.assetClass,
|
||||||
|
assetSubClass: data.assetSubClass,
|
||||||
|
name: data.symbol
|
||||||
|
}
|
||||||
|
},
|
||||||
User: { connect: { id: this.request.user.id } }
|
User: { connect: { id: this.request.user.id } }
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
id_userId: {
|
id
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
@ -8,13 +8,17 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
|||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { OrderController } from './order.controller';
|
import { OrderController } from './order.controller';
|
||||||
import { OrderService } from './order.service';
|
import { OrderService } from './order.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [OrderController],
|
||||||
|
exports: [OrderService],
|
||||||
imports: [
|
imports: [
|
||||||
|
CacheModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
@ -22,10 +26,9 @@ import { OrderService } from './order.service';
|
|||||||
ImpersonationModule,
|
ImpersonationModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [OrderController],
|
providers: [AccountService, OrderService]
|
||||||
providers: [AccountService, CacheService, OrderService],
|
|
||||||
exports: [OrderService]
|
|
||||||
})
|
})
|
||||||
export class OrderModule {}
|
export class OrderModule {}
|
||||||
|
@ -1,13 +1,28 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import {
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
Order,
|
||||||
|
Prisma,
|
||||||
|
Tag,
|
||||||
|
Type as TypeOfOrder
|
||||||
|
} from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { endOfToday, isAfter } from 'date-fns';
|
import { endOfToday, isAfter } from 'date-fns';
|
||||||
|
import { groupBy } from 'lodash';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { Activity } from './interfaces/activities.interface';
|
import { Activity } from './interfaces/activities.interface';
|
||||||
|
|
||||||
@ -15,10 +30,10 @@ import { Activity } from './interfaces/activities.interface';
|
|||||||
export class OrderService {
|
export class OrderService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly cacheService: CacheService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async order(
|
public async order(
|
||||||
@ -50,7 +65,16 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async createOrder(
|
public async createOrder(
|
||||||
data: Prisma.OrderCreateInput & { accountId?: string; userId: string }
|
data: Prisma.OrderCreateInput & {
|
||||||
|
accountId?: string;
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
|
currency?: string;
|
||||||
|
dataSource?: DataSource;
|
||||||
|
symbol?: string;
|
||||||
|
tags?: Tag[];
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
): Promise<Order> {
|
): Promise<Order> {
|
||||||
const defaultAccount = (
|
const defaultAccount = (
|
||||||
await this.accountService.getAccounts(data.userId)
|
await this.accountService.getAccounts(data.userId)
|
||||||
@ -58,7 +82,9 @@ export class OrderService {
|
|||||||
return account.isDefault === true;
|
return account.isDefault === true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const Account = {
|
const tags = data.tags ?? [];
|
||||||
|
|
||||||
|
let Account = {
|
||||||
connect: {
|
connect: {
|
||||||
id_userId: {
|
id_userId: {
|
||||||
userId: data.userId,
|
userId: data.userId,
|
||||||
@ -67,29 +93,65 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
if (data.type === 'ITEM') {
|
||||||
|
const assetClass = data.assetClass;
|
||||||
|
const assetSubClass = data.assetSubClass;
|
||||||
|
const currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||||
|
const dataSource: DataSource = 'MANUAL';
|
||||||
|
const id = uuidv4();
|
||||||
|
const name = data.SymbolProfile.connectOrCreate.create.symbol;
|
||||||
|
|
||||||
// Convert the symbol to uppercase to avoid case-sensitive duplicates
|
Account = undefined;
|
||||||
const symbol = data.symbol.toUpperCase();
|
data.id = id;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.currency = currency;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.name = name;
|
||||||
|
data.SymbolProfile.connectOrCreate.create.symbol = id;
|
||||||
|
data.SymbolProfile.connectOrCreate.where.dataSource_symbol = {
|
||||||
|
dataSource,
|
||||||
|
symbol: id
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
data.SymbolProfile.connectOrCreate.create.symbol =
|
||||||
|
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataGatheringService.addJobToQueue(
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
{
|
||||||
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
|
},
|
||||||
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
if (!isDraft) {
|
if (!isDraft) {
|
||||||
// Gather symbol data of order in the background, if not draft
|
// Gather symbol data of order in the background, if not draft
|
||||||
this.dataGatheringService.gatherSymbols([
|
this.dataGatheringService.gatherSymbols([
|
||||||
{
|
{
|
||||||
symbol,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
dataSource: data.dataSource,
|
date: <Date>data.date,
|
||||||
date: <Date>data.date
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringService.gatherProfileData([
|
|
||||||
{ symbol, dataSource: data.dataSource }
|
|
||||||
]);
|
|
||||||
|
|
||||||
await this.cacheService.flush();
|
|
||||||
|
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
|
delete data.assetClass;
|
||||||
|
delete data.assetSubClass;
|
||||||
|
|
||||||
|
if (!data.comment) {
|
||||||
|
delete data.comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete data.currency;
|
||||||
|
delete data.dataSource;
|
||||||
|
delete data.symbol;
|
||||||
|
delete data.tags;
|
||||||
delete data.userId;
|
delete data.userId;
|
||||||
|
|
||||||
const orderData: Prisma.OrderCreateInput = data;
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
@ -99,7 +161,11 @@ export class OrderService {
|
|||||||
...orderData,
|
...orderData,
|
||||||
Account,
|
Account,
|
||||||
isDraft,
|
isDraft,
|
||||||
symbol
|
tags: {
|
||||||
|
connect: tags.map(({ id }) => {
|
||||||
|
return { id };
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -107,17 +173,25 @@ export class OrderService {
|
|||||||
public async deleteOrder(
|
public async deleteOrder(
|
||||||
where: Prisma.OrderWhereUniqueInput
|
where: Prisma.OrderWhereUniqueInput
|
||||||
): Promise<Order> {
|
): Promise<Order> {
|
||||||
return this.prismaService.order.delete({
|
const order = await this.prismaService.order.delete({
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (order.type === 'ITEM') {
|
||||||
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getOrders({
|
public async getOrders({
|
||||||
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
types,
|
types,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
types?: TypeOfOrder[];
|
types?: TypeOfOrder[];
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
@ -125,10 +199,65 @@ export class OrderService {
|
|||||||
}): Promise<Activity[]> {
|
}): Promise<Activity[]> {
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
|
const {
|
||||||
|
ACCOUNT: filtersByAccount,
|
||||||
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
|
TAG: filtersByTag
|
||||||
|
} = groupBy(filters, (filter) => {
|
||||||
|
return filter.type;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filtersByAccount?.length > 0) {
|
||||||
|
where.accountId = {
|
||||||
|
in: filtersByAccount.map(({ id }) => {
|
||||||
|
return id;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (includeDrafts === false) {
|
if (includeDrafts === false) {
|
||||||
where.isDraft = false;
|
where.isDraft = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filtersByAssetClass?.length > 0) {
|
||||||
|
where.SymbolProfile = {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: filtersByAssetClass.map(({ id }) => {
|
||||||
|
return { assetClass: AssetClass[id] };
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ SymbolProfileOverrides: { is: null } },
|
||||||
|
{ SymbolProfileOverrides: { assetClass: null } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SymbolProfileOverrides: {
|
||||||
|
OR: filtersByAssetClass.map(({ id }) => {
|
||||||
|
return { assetClass: AssetClass[id] };
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtersByTag?.length > 0) {
|
||||||
|
where.tags = {
|
||||||
|
some: {
|
||||||
|
OR: filtersByTag.map(({ id }) => {
|
||||||
|
return { id };
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (types) {
|
if (types) {
|
||||||
where.OR = types.map((type) => {
|
where.OR = types.map((type) => {
|
||||||
return {
|
return {
|
||||||
@ -150,7 +279,8 @@ export class OrderService {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
SymbolProfile: true
|
SymbolProfile: true,
|
||||||
|
tags: true
|
||||||
},
|
},
|
||||||
orderBy: { date: 'asc' }
|
orderBy: { date: 'asc' }
|
||||||
})
|
})
|
||||||
@ -162,43 +292,79 @@ export class OrderService {
|
|||||||
value,
|
value,
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
order.fee,
|
order.fee,
|
||||||
order.currency,
|
order.SymbolProfile.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
),
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
value,
|
value,
|
||||||
order.currency,
|
order.SymbolProfile.currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateOrder(params: {
|
public async updateOrder({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
}: {
|
||||||
|
data: Prisma.OrderUpdateInput & {
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
|
currency?: string;
|
||||||
|
dataSource?: DataSource;
|
||||||
|
symbol?: string;
|
||||||
|
tags?: Tag[];
|
||||||
|
};
|
||||||
where: Prisma.OrderWhereUniqueInput;
|
where: Prisma.OrderWhereUniqueInput;
|
||||||
data: Prisma.OrderUpdateInput;
|
|
||||||
}): Promise<Order> {
|
}): Promise<Order> {
|
||||||
const { data, where } = params;
|
if (data.Account.connect.id_userId.id === null) {
|
||||||
|
delete data.Account;
|
||||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
|
||||||
|
|
||||||
if (!isDraft) {
|
|
||||||
// Gather symbol data of order in the background, if not draft
|
|
||||||
this.dataGatheringService.gatherSymbols([
|
|
||||||
{
|
|
||||||
dataSource: <DataSource>data.dataSource,
|
|
||||||
date: <Date>data.date,
|
|
||||||
symbol: <string>data.symbol
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.cacheService.flush();
|
if (!data.comment) {
|
||||||
|
data.comment = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = data.tags ?? [];
|
||||||
|
|
||||||
|
let isDraft = false;
|
||||||
|
|
||||||
|
if (data.type === 'ITEM') {
|
||||||
|
delete data.SymbolProfile.connect;
|
||||||
|
} else {
|
||||||
|
delete data.SymbolProfile.update;
|
||||||
|
|
||||||
|
isDraft = isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
|
if (!isDraft) {
|
||||||
|
// Gather symbol data of order in the background, if not draft
|
||||||
|
this.dataGatheringService.gatherSymbols([
|
||||||
|
{
|
||||||
|
dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource,
|
||||||
|
date: <Date>data.date,
|
||||||
|
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete data.assetClass;
|
||||||
|
delete data.assetSubClass;
|
||||||
|
delete data.currency;
|
||||||
|
delete data.dataSource;
|
||||||
|
delete data.symbol;
|
||||||
|
delete data.tags;
|
||||||
|
|
||||||
return this.prismaService.order.update({
|
return this.prismaService.order.update({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
isDraft
|
isDraft,
|
||||||
|
tags: {
|
||||||
|
connect: tags.map(({ id }) => {
|
||||||
|
return { id };
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
@ -1,9 +1,40 @@
|
|||||||
import { DataSource, Type } from '@prisma/client';
|
import {
|
||||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
Tag,
|
||||||
|
Type
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsISO8601,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString
|
||||||
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateOrderDto {
|
export class UpdateOrderDto {
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
accountId: string;
|
accountId?: string;
|
||||||
|
|
||||||
|
@IsEnum(AssetClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
|
||||||
|
@IsEnum(AssetSubClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
@ -26,6 +57,10 @@ export class UpdateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
tags?: Tag[];
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
|
75
apps/api/src/app/portfolio/current-rate.service.mock.ts
Normal file
75
apps/api/src/app/portfolio/current-rate.service.mock.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||||
|
|
||||||
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
|
|
||||||
|
function mockGetValue(symbol: string, date: Date) {
|
||||||
|
switch (symbol) {
|
||||||
|
case 'BALN.SW':
|
||||||
|
if (isSameDay(parseDate('2021-11-12'), date)) {
|
||||||
|
return { marketPrice: 146 };
|
||||||
|
} else if (isSameDay(parseDate('2021-11-22'), date)) {
|
||||||
|
return { marketPrice: 142.9 };
|
||||||
|
} else if (isSameDay(parseDate('2021-11-26'), date)) {
|
||||||
|
return { marketPrice: 139.9 };
|
||||||
|
} else if (isSameDay(parseDate('2021-11-30'), date)) {
|
||||||
|
return { marketPrice: 136.6 };
|
||||||
|
} else if (isSameDay(parseDate('2021-12-18'), date)) {
|
||||||
|
return { marketPrice: 148.9 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { marketPrice: 0 };
|
||||||
|
|
||||||
|
case 'NOVN.SW':
|
||||||
|
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||||
|
return { marketPrice: 87.8 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { marketPrice: 0 };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { marketPrice: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrentRateServiceMock = {
|
||||||
|
getValues: ({
|
||||||
|
dataGatheringItems,
|
||||||
|
dateQuery
|
||||||
|
}: GetValuesParams): Promise<GetValueObject[]> => {
|
||||||
|
const result: GetValueObject[] = [];
|
||||||
|
if (dateQuery.lt) {
|
||||||
|
for (
|
||||||
|
let date = resetHours(dateQuery.gte);
|
||||||
|
isBefore(date, endOfDay(dateQuery.lt));
|
||||||
|
date = addDays(date, 1)
|
||||||
|
) {
|
||||||
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
|
result.push({
|
||||||
|
date,
|
||||||
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
|
dataGatheringItem.symbol,
|
||||||
|
date
|
||||||
|
).marketPrice,
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const date of dateQuery.in) {
|
||||||
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
|
result.push({
|
||||||
|
date,
|
||||||
|
marketPriceInBaseCurrency: mockGetValue(
|
||||||
|
dataGatheringItem.symbol,
|
||||||
|
date
|
||||||
|
).marketPrice,
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve(result);
|
||||||
|
}
|
||||||
|
};
|
@ -4,6 +4,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
|
|||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
|
||||||
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||||
return {
|
return {
|
||||||
@ -73,7 +74,12 @@ describe('CurrentRateService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
dataProviderService = new DataProviderService(null, [], null);
|
dataProviderService = new DataProviderService(null, [], null);
|
||||||
exchangeRateDataService = new ExchangeRateDataService(null, null, null);
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
marketDataService = new MarketDataService(null);
|
marketDataService = new MarketDataService(null);
|
||||||
|
|
||||||
await exchangeRateDataService.initialize();
|
await exchangeRateDataService.initialize();
|
||||||
@ -96,15 +102,15 @@ describe('CurrentRateService', () => {
|
|||||||
},
|
},
|
||||||
userCurrency: 'CHF'
|
userCurrency: 'CHF'
|
||||||
})
|
})
|
||||||
).toMatchObject([
|
).toMatchObject<GetValueObject[]>([
|
||||||
{
|
{
|
||||||
date: undefined,
|
date: undefined,
|
||||||
marketPrice: 1841.823902,
|
marketPriceInBaseCurrency: 1841.823902,
|
||||||
symbol: 'AMZN'
|
symbol: 'AMZN'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: undefined,
|
date: undefined,
|
||||||
marketPrice: 1847.839966,
|
marketPriceInBaseCurrency: 1847.839966,
|
||||||
symbol: 'AMZN'
|
symbol: 'AMZN'
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
@ -28,30 +28,25 @@ export class CurrentRateService {
|
|||||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||||
(!dateQuery.in || this.containsToday(dateQuery.in));
|
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||||
|
|
||||||
const promises: Promise<
|
const promises: Promise<GetValueObject[]>[] = [];
|
||||||
{
|
|
||||||
date: Date;
|
|
||||||
marketPrice: number;
|
|
||||||
symbol: string;
|
|
||||||
}[]
|
|
||||||
>[] = [];
|
|
||||||
|
|
||||||
if (includeToday) {
|
if (includeToday) {
|
||||||
const today = resetHours(new Date());
|
const today = resetHours(new Date());
|
||||||
promises.push(
|
promises.push(
|
||||||
this.dataProviderService
|
this.dataProviderService
|
||||||
.get(dataGatheringItems)
|
.getQuotes(dataGatheringItems)
|
||||||
.then((dataResultProvider) => {
|
.then((dataResultProvider) => {
|
||||||
const result = [];
|
const result: GetValueObject[] = [];
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
result.push({
|
result.push({
|
||||||
date: today,
|
date: today,
|
||||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
marketPriceInBaseCurrency:
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ??
|
this.exchangeRateDataService.toCurrency(
|
||||||
0,
|
dataResultProvider?.[dataGatheringItem.symbol]
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
?.marketPrice ?? 0,
|
||||||
userCurrency
|
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||||
),
|
userCurrency
|
||||||
|
),
|
||||||
symbol: dataGatheringItem.symbol
|
symbol: dataGatheringItem.symbol
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -74,11 +69,12 @@ export class CurrentRateService {
|
|||||||
return data.map((marketDataItem) => {
|
return data.map((marketDataItem) => {
|
||||||
return {
|
return {
|
||||||
date: marketDataItem.date,
|
date: marketDataItem.date,
|
||||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
marketPriceInBaseCurrency:
|
||||||
marketDataItem.marketPrice,
|
this.exchangeRateDataService.toCurrency(
|
||||||
currencies[marketDataItem.symbol],
|
marketDataItem.marketPrice,
|
||||||
userCurrency
|
currencies[marketDataItem.symbol],
|
||||||
),
|
userCurrency
|
||||||
|
),
|
||||||
symbol: marketDataItem.symbol
|
symbol: marketDataItem.symbol
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
export interface CurrentPositions {
|
export interface CurrentPositions extends ResponseError {
|
||||||
hasErrors: boolean;
|
|
||||||
positions: TimelinePosition[];
|
positions: TimelinePosition[];
|
||||||
grossPerformance: Big;
|
grossPerformance: Big;
|
||||||
grossPerformancePercentage: Big;
|
grossPerformancePercentage: Big;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
export interface GetValueObject {
|
export interface GetValueObject {
|
||||||
date: Date;
|
date: Date;
|
||||||
marketPrice: number;
|
marketPriceInBaseCurrency: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
|
import {
|
||||||
|
EnhancedSymbolProfile,
|
||||||
|
HistoricalDataItem
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
import { Tag } from '@prisma/client';
|
||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioPositionDetail {
|
||||||
assetClass?: AssetClass;
|
|
||||||
assetSubClass?: AssetSubClass;
|
|
||||||
averagePrice: number;
|
averagePrice: number;
|
||||||
currency: string;
|
|
||||||
firstBuyDate: string;
|
firstBuyDate: string;
|
||||||
grossPerformance: number;
|
grossPerformance: number;
|
||||||
grossPerformancePercent: number;
|
grossPerformancePercent: number;
|
||||||
@ -14,12 +15,12 @@ export interface PortfolioPositionDetail {
|
|||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
maxPrice: number;
|
maxPrice: number;
|
||||||
minPrice: number;
|
minPrice: number;
|
||||||
name: string;
|
|
||||||
netPerformance: number;
|
netPerformance: number;
|
||||||
netPerformancePercent: number;
|
netPerformancePercent: number;
|
||||||
orders: OrderWithAccount[];
|
orders: OrderWithAccount[];
|
||||||
quantity: number;
|
quantity: number;
|
||||||
symbol: string;
|
SymbolProfile: EnhancedSymbolProfile;
|
||||||
|
tags: Tag[];
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
@ -29,10 +30,3 @@ export interface HistoricalDataContainer {
|
|||||||
isAllTimeLow: boolean;
|
isAllTimeLow: boolean;
|
||||||
items: HistoricalDataItem[];
|
items: HistoricalDataItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoricalDataItem {
|
|
||||||
averagePrice?: number;
|
|
||||||
date: string;
|
|
||||||
grossPerformancePercent?: number;
|
|
||||||
value: number;
|
|
||||||
}
|
|
||||||
|
@ -0,0 +1,109 @@
|
|||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||||
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with BALN.SW buy and sell', async () => {
|
||||||
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currentRateService,
|
||||||
|
currency: 'CHF',
|
||||||
|
orders: [
|
||||||
|
{
|
||||||
|
currency: 'CHF',
|
||||||
|
date: '2021-11-22',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(1.55),
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
quantity: new Big(2),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: new Big(142.9)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currency: 'CHF',
|
||||||
|
date: '2021-11-30',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(1.65),
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
quantity: new Big(2),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: new Big(136.6)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
parseDate('2021-11-22')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(currentPositions).toEqual({
|
||||||
|
currentValue: new Big('0'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('-12.6'),
|
||||||
|
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('-15.8'),
|
||||||
|
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('0'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
firstBuyDate: '2021-11-22',
|
||||||
|
grossPerformance: new Big('-12.6'),
|
||||||
|
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||||
|
investment: new Big('0'),
|
||||||
|
netPerformance: new Big('-15.8'),
|
||||||
|
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
||||||
|
marketPrice: 148.9,
|
||||||
|
quantity: new Big('0'),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
transactionCount: 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalInvestment: new Big('0')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2021-11-22', investment: new Big('285.8') },
|
||||||
|
{ date: '2021-11-30', investment: new Big('0') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2021-11-01', investment: new Big('12.6') }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,97 @@
|
|||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||||
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with BALN.SW buy', async () => {
|
||||||
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currentRateService,
|
||||||
|
currency: 'CHF',
|
||||||
|
orders: [
|
||||||
|
{
|
||||||
|
currency: 'CHF',
|
||||||
|
date: '2021-11-30',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(1.55),
|
||||||
|
name: 'Bâloise Holding AG',
|
||||||
|
quantity: new Big(2),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: new Big(136.6)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
parseDate('2021-11-30')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(currentPositions).toEqual({
|
||||||
|
currentValue: new Big('297.8'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('24.6'),
|
||||||
|
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('23.05'),
|
||||||
|
netPerformancePercentage: new Big('0.08437042459736456808'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('136.6'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
firstBuyDate: '2021-11-30',
|
||||||
|
grossPerformance: new Big('24.6'),
|
||||||
|
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||||
|
investment: new Big('273.2'),
|
||||||
|
netPerformance: new Big('23.05'),
|
||||||
|
netPerformancePercentage: new Big('0.08437042459736456808'),
|
||||||
|
marketPrice: 148.9,
|
||||||
|
quantity: new Big('2'),
|
||||||
|
symbol: 'BALN.SW',
|
||||||
|
transactionCount: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalInvestment: new Big('273.2')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2021-11-30', investment: new Big('273.2') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2021-11-01', investment: new Big('273.2') }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,73 +0,0 @@
|
|||||||
import Big from 'big.js';
|
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
|
||||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
|
||||||
|
|
||||||
describe('PortfolioCalculatorNew', () => {
|
|
||||||
let currentRateService: CurrentRateService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
currentRateService = new CurrentRateService(null, null, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('annualized performance percentage', () => {
|
|
||||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
|
||||||
currentRateService,
|
|
||||||
currency: 'USD',
|
|
||||||
orders: []
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Get annualized performance', async () => {
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
|
||||||
netPerformancePercent: new Big(0)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toEqual(0);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 0,
|
|
||||||
netPerformancePercent: new Big(0)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toEqual(0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
|
||||||
*/
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 65, // < 1 year
|
|
||||||
netPerformancePercent: new Big(0.1025)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.729705);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 365, // 1 year
|
|
||||||
netPerformancePercent: new Big(0.05)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.05);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
|
||||||
*/
|
|
||||||
expect(
|
|
||||||
portfolioCalculatorNew
|
|
||||||
.getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket: 575, // > 1 year
|
|
||||||
netPerformancePercent: new Big(0.2374)
|
|
||||||
})
|
|
||||||
.toNumber()
|
|
||||||
).toBeCloseTo(0.145);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,858 +0,0 @@
|
|||||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
|
||||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
|
||||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { Type as TypeOfOrder } from '@prisma/client';
|
|
||||||
import Big from 'big.js';
|
|
||||||
import {
|
|
||||||
addDays,
|
|
||||||
addMilliseconds,
|
|
||||||
addMonths,
|
|
||||||
addYears,
|
|
||||||
endOfDay,
|
|
||||||
format,
|
|
||||||
isAfter,
|
|
||||||
isBefore,
|
|
||||||
max,
|
|
||||||
min
|
|
||||||
} from 'date-fns';
|
|
||||||
import { first, flatten, isNumber, sortBy } from 'lodash';
|
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
|
||||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
|
||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
|
||||||
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
|
|
||||||
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
|
||||||
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
|
||||||
import {
|
|
||||||
Accuracy,
|
|
||||||
TimelineSpecification
|
|
||||||
} from './interfaces/timeline-specification.interface';
|
|
||||||
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
|
|
||||||
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
|
||||||
|
|
||||||
export class PortfolioCalculatorNew {
|
|
||||||
private currency: string;
|
|
||||||
private currentRateService: CurrentRateService;
|
|
||||||
private orders: PortfolioOrder[];
|
|
||||||
private transactionPoints: TransactionPoint[];
|
|
||||||
|
|
||||||
public constructor({
|
|
||||||
currency,
|
|
||||||
currentRateService,
|
|
||||||
orders
|
|
||||||
}: {
|
|
||||||
currency: string;
|
|
||||||
currentRateService: CurrentRateService;
|
|
||||||
orders: PortfolioOrder[];
|
|
||||||
}) {
|
|
||||||
this.currency = currency;
|
|
||||||
this.currentRateService = currentRateService;
|
|
||||||
this.orders = orders;
|
|
||||||
|
|
||||||
this.orders.sort((a, b) => a.date.localeCompare(b.date));
|
|
||||||
}
|
|
||||||
|
|
||||||
public computeTransactionPoints() {
|
|
||||||
this.transactionPoints = [];
|
|
||||||
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
|
||||||
|
|
||||||
let lastDate: string = null;
|
|
||||||
let lastTransactionPoint: TransactionPoint = null;
|
|
||||||
for (const order of this.orders) {
|
|
||||||
const currentDate = order.date;
|
|
||||||
|
|
||||||
let currentTransactionPointItem: TransactionPointSymbol;
|
|
||||||
const oldAccumulatedSymbol = symbols[order.symbol];
|
|
||||||
|
|
||||||
const factor = this.getFactor(order.type);
|
|
||||||
const unitPrice = new Big(order.unitPrice);
|
|
||||||
if (oldAccumulatedSymbol) {
|
|
||||||
const newQuantity = order.quantity
|
|
||||||
.mul(factor)
|
|
||||||
.plus(oldAccumulatedSymbol.quantity);
|
|
||||||
currentTransactionPointItem = {
|
|
||||||
currency: order.currency,
|
|
||||||
dataSource: order.dataSource,
|
|
||||||
fee: order.fee.plus(oldAccumulatedSymbol.fee),
|
|
||||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
|
||||||
investment: newQuantity.eq(0)
|
|
||||||
? new Big(0)
|
|
||||||
: unitPrice
|
|
||||||
.mul(order.quantity)
|
|
||||||
.mul(factor)
|
|
||||||
.add(oldAccumulatedSymbol.investment),
|
|
||||||
quantity: newQuantity,
|
|
||||||
symbol: order.symbol,
|
|
||||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
currentTransactionPointItem = {
|
|
||||||
currency: order.currency,
|
|
||||||
dataSource: order.dataSource,
|
|
||||||
fee: order.fee,
|
|
||||||
firstBuyDate: order.date,
|
|
||||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
|
||||||
quantity: order.quantity.mul(factor),
|
|
||||||
symbol: order.symbol,
|
|
||||||
transactionCount: 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
symbols[order.symbol] = currentTransactionPointItem;
|
|
||||||
|
|
||||||
const items = lastTransactionPoint?.items ?? [];
|
|
||||||
const newItems = items.filter(
|
|
||||||
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
|
||||||
);
|
|
||||||
newItems.push(currentTransactionPointItem);
|
|
||||||
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
|
||||||
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
|
||||||
lastTransactionPoint = {
|
|
||||||
date: currentDate,
|
|
||||||
items: newItems
|
|
||||||
};
|
|
||||||
this.transactionPoints.push(lastTransactionPoint);
|
|
||||||
} else {
|
|
||||||
lastTransactionPoint.items = newItems;
|
|
||||||
}
|
|
||||||
lastDate = currentDate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getAnnualizedPerformancePercent({
|
|
||||||
daysInMarket,
|
|
||||||
netPerformancePercent
|
|
||||||
}: {
|
|
||||||
daysInMarket: number;
|
|
||||||
netPerformancePercent: Big;
|
|
||||||
}): Big {
|
|
||||||
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
|
||||||
const exponent = new Big(365).div(daysInMarket).toNumber();
|
|
||||||
return new Big(
|
|
||||||
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
|
|
||||||
).minus(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Big(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getTransactionPoints(): TransactionPoint[] {
|
|
||||||
return this.transactionPoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
|
|
||||||
this.transactionPoints = transactionPoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
|
||||||
if (!this.transactionPoints?.length) {
|
|
||||||
return {
|
|
||||||
currentValue: new Big(0),
|
|
||||||
hasErrors: false,
|
|
||||||
grossPerformance: new Big(0),
|
|
||||||
grossPerformancePercentage: new Big(0),
|
|
||||||
netPerformance: new Big(0),
|
|
||||||
netPerformancePercentage: new Big(0),
|
|
||||||
positions: [],
|
|
||||||
totalInvestment: new Big(0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastTransactionPoint =
|
|
||||||
this.transactionPoints[this.transactionPoints.length - 1];
|
|
||||||
|
|
||||||
// use Date.now() to use the mock for today
|
|
||||||
const today = new Date(Date.now());
|
|
||||||
|
|
||||||
let firstTransactionPoint: TransactionPoint = null;
|
|
||||||
let firstIndex = this.transactionPoints.length;
|
|
||||||
const dates = [];
|
|
||||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
|
||||||
const currencies: { [symbol: string]: string } = {};
|
|
||||||
|
|
||||||
dates.push(resetHours(start));
|
|
||||||
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
|
||||||
dataGatheringItems.push({
|
|
||||||
dataSource: item.dataSource,
|
|
||||||
symbol: item.symbol
|
|
||||||
});
|
|
||||||
currencies[item.symbol] = item.currency;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < this.transactionPoints.length; i++) {
|
|
||||||
if (
|
|
||||||
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
|
|
||||||
firstTransactionPoint === null
|
|
||||||
) {
|
|
||||||
firstTransactionPoint = this.transactionPoints[i];
|
|
||||||
firstIndex = i;
|
|
||||||
}
|
|
||||||
if (firstTransactionPoint !== null) {
|
|
||||||
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dates.push(resetHours(today));
|
|
||||||
|
|
||||||
const marketSymbols = await this.currentRateService.getValues({
|
|
||||||
currencies,
|
|
||||||
dataGatheringItems,
|
|
||||||
dateQuery: {
|
|
||||||
in: dates
|
|
||||||
},
|
|
||||||
userCurrency: this.currency
|
|
||||||
});
|
|
||||||
|
|
||||||
const marketSymbolMap: {
|
|
||||||
[date: string]: { [symbol: string]: Big };
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
for (const marketSymbol of marketSymbols) {
|
|
||||||
const date = format(marketSymbol.date, DATE_FORMAT);
|
|
||||||
if (!marketSymbolMap[date]) {
|
|
||||||
marketSymbolMap[date] = {};
|
|
||||||
}
|
|
||||||
if (marketSymbol.marketPrice) {
|
|
||||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
|
||||||
marketSymbol.marketPrice
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const todayString = format(today, DATE_FORMAT);
|
|
||||||
|
|
||||||
if (firstIndex > 0) {
|
|
||||||
firstIndex--;
|
|
||||||
}
|
|
||||||
const initialValues: { [symbol: string]: Big } = {};
|
|
||||||
|
|
||||||
const positions: TimelinePosition[] = [];
|
|
||||||
let hasErrorsInSymbolMetrics = false;
|
|
||||||
|
|
||||||
for (const item of lastTransactionPoint.items) {
|
|
||||||
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
|
||||||
|
|
||||||
const {
|
|
||||||
grossPerformance,
|
|
||||||
grossPerformancePercentage,
|
|
||||||
hasErrors,
|
|
||||||
initialValue,
|
|
||||||
netPerformance,
|
|
||||||
netPerformancePercentage
|
|
||||||
} = this.getSymbolMetrics({
|
|
||||||
marketSymbolMap,
|
|
||||||
start,
|
|
||||||
symbol: item.symbol
|
|
||||||
});
|
|
||||||
|
|
||||||
hasErrorsInSymbolMetrics = hasErrorsInSymbolMetrics || hasErrors;
|
|
||||||
|
|
||||||
initialValues[item.symbol] = initialValue;
|
|
||||||
|
|
||||||
positions.push({
|
|
||||||
averagePrice: item.quantity.eq(0)
|
|
||||||
? new Big(0)
|
|
||||||
: item.investment.div(item.quantity),
|
|
||||||
currency: item.currency,
|
|
||||||
dataSource: item.dataSource,
|
|
||||||
firstBuyDate: item.firstBuyDate,
|
|
||||||
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
|
||||||
grossPerformancePercentage: !hasErrors
|
|
||||||
? grossPerformancePercentage ?? null
|
|
||||||
: null,
|
|
||||||
investment: item.investment,
|
|
||||||
marketPrice: marketValue?.toNumber() ?? null,
|
|
||||||
netPerformance: !hasErrors ? netPerformance ?? null : null,
|
|
||||||
netPerformancePercentage: !hasErrors
|
|
||||||
? netPerformancePercentage ?? null
|
|
||||||
: null,
|
|
||||||
quantity: item.quantity,
|
|
||||||
symbol: item.symbol,
|
|
||||||
transactionCount: item.transactionCount
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const overall = this.calculateOverallPerformance(positions, initialValues);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...overall,
|
|
||||||
positions,
|
|
||||||
hasErrors: hasErrorsInSymbolMetrics || overall.hasErrors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public getSymbolMetrics({
|
|
||||||
marketSymbolMap,
|
|
||||||
start,
|
|
||||||
symbol
|
|
||||||
}: {
|
|
||||||
marketSymbolMap: {
|
|
||||||
[date: string]: { [symbol: string]: Big };
|
|
||||||
};
|
|
||||||
start: Date;
|
|
||||||
symbol: string;
|
|
||||||
}) {
|
|
||||||
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
|
|
||||||
return order.symbol === symbol;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (orders.length <= 0) {
|
|
||||||
return {
|
|
||||||
hasErrors: false,
|
|
||||||
initialValue: new Big(0),
|
|
||||||
netPerformance: new Big(0),
|
|
||||||
netPerformancePercentage: new Big(0),
|
|
||||||
grossPerformance: new Big(0),
|
|
||||||
grossPerformancePercentage: new Big(0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateOfFirstTransaction = new Date(first(orders).date);
|
|
||||||
const endDate = new Date(Date.now());
|
|
||||||
|
|
||||||
const unitPriceAtStartDate =
|
|
||||||
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
|
||||||
|
|
||||||
const unitPriceAtEndDate =
|
|
||||||
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
|
|
||||||
|
|
||||||
if (
|
|
||||||
!unitPriceAtEndDate ||
|
|
||||||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
hasErrors: true,
|
|
||||||
initialValue: new Big(0),
|
|
||||||
netPerformance: new Big(0),
|
|
||||||
netPerformancePercentage: new Big(0),
|
|
||||||
grossPerformance: new Big(0),
|
|
||||||
grossPerformancePercentage: new Big(0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let feesAtStartDate = new Big(0);
|
|
||||||
let fees = new Big(0);
|
|
||||||
let grossPerformance = new Big(0);
|
|
||||||
let grossPerformanceAtStartDate = new Big(0);
|
|
||||||
let grossPerformanceFromSells = new Big(0);
|
|
||||||
let initialValue: Big;
|
|
||||||
let lastAveragePrice = new Big(0);
|
|
||||||
let lastTransactionInvestment = new Big(0);
|
|
||||||
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
|
||||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
|
||||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
|
||||||
let totalInvestment = new Big(0);
|
|
||||||
let totalUnits = new Big(0);
|
|
||||||
|
|
||||||
// Add a synthetic order at the start and the end date
|
|
||||||
orders.push({
|
|
||||||
symbol,
|
|
||||||
currency: null,
|
|
||||||
date: format(start, DATE_FORMAT),
|
|
||||||
dataSource: null,
|
|
||||||
fee: new Big(0),
|
|
||||||
itemType: 'start',
|
|
||||||
name: '',
|
|
||||||
quantity: new Big(0),
|
|
||||||
type: TypeOfOrder.BUY,
|
|
||||||
unitPrice: unitPriceAtStartDate ?? new Big(0)
|
|
||||||
});
|
|
||||||
|
|
||||||
orders.push({
|
|
||||||
symbol,
|
|
||||||
currency: null,
|
|
||||||
date: format(endDate, DATE_FORMAT),
|
|
||||||
dataSource: null,
|
|
||||||
fee: new Big(0),
|
|
||||||
itemType: 'end',
|
|
||||||
name: '',
|
|
||||||
quantity: new Big(0),
|
|
||||||
type: TypeOfOrder.BUY,
|
|
||||||
unitPrice: unitPriceAtEndDate ?? new Big(0)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort orders so that the start and end placeholder order are at the right
|
|
||||||
// position
|
|
||||||
orders = sortBy(orders, (order) => {
|
|
||||||
let sortIndex = new Date(order.date);
|
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
|
||||||
sortIndex = addMilliseconds(sortIndex, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.itemType === 'end') {
|
|
||||||
sortIndex = addMilliseconds(sortIndex, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortIndex.getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
const indexOfStartOrder = orders.findIndex((order) => {
|
|
||||||
return order.itemType === 'start';
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < orders.length; i += 1) {
|
|
||||||
const order = orders[i];
|
|
||||||
|
|
||||||
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
|
|
||||||
order.unitPrice
|
|
||||||
);
|
|
||||||
|
|
||||||
const transactionInvestment = order.quantity
|
|
||||||
.mul(order.unitPrice)
|
|
||||||
.mul(this.getFactor(order.type));
|
|
||||||
|
|
||||||
if (
|
|
||||||
!initialValue &&
|
|
||||||
order.itemType !== 'start' &&
|
|
||||||
order.itemType !== 'end'
|
|
||||||
) {
|
|
||||||
initialValue = transactionInvestment;
|
|
||||||
}
|
|
||||||
|
|
||||||
fees = fees.plus(order.fee);
|
|
||||||
|
|
||||||
totalUnits = totalUnits.plus(
|
|
||||||
order.quantity.mul(this.getFactor(order.type))
|
|
||||||
);
|
|
||||||
|
|
||||||
const valueOfInvestment = totalUnits.mul(order.unitPrice);
|
|
||||||
|
|
||||||
const grossPerformanceFromSell =
|
|
||||||
order.type === TypeOfOrder.SELL
|
|
||||||
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
|
|
||||||
: new Big(0);
|
|
||||||
|
|
||||||
grossPerformanceFromSells = grossPerformanceFromSells.plus(
|
|
||||||
grossPerformanceFromSell
|
|
||||||
);
|
|
||||||
|
|
||||||
totalInvestment = totalInvestment
|
|
||||||
.plus(transactionInvestment)
|
|
||||||
.plus(grossPerformanceFromSell);
|
|
||||||
|
|
||||||
lastAveragePrice = totalUnits.eq(0)
|
|
||||||
? new Big(0)
|
|
||||||
: totalInvestment.div(totalUnits);
|
|
||||||
|
|
||||||
const newGrossPerformance = valueOfInvestment
|
|
||||||
.minus(totalInvestment)
|
|
||||||
.plus(grossPerformanceFromSells);
|
|
||||||
|
|
||||||
if (
|
|
||||||
i > indexOfStartOrder &&
|
|
||||||
!lastValueOfInvestmentBeforeTransaction
|
|
||||||
.plus(lastTransactionInvestment)
|
|
||||||
.eq(0)
|
|
||||||
) {
|
|
||||||
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
|
||||||
.sub(
|
|
||||||
lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
lastTransactionInvestment
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.div(
|
|
||||||
lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
lastTransactionInvestment
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
timeWeightedGrossPerformancePercentage =
|
|
||||||
timeWeightedGrossPerformancePercentage.mul(
|
|
||||||
new Big(1).plus(grossHoldingPeriodReturn)
|
|
||||||
);
|
|
||||||
|
|
||||||
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
|
|
||||||
.sub(fees.sub(order.fee))
|
|
||||||
.sub(
|
|
||||||
lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
lastTransactionInvestment
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.div(
|
|
||||||
lastValueOfInvestmentBeforeTransaction.plus(
|
|
||||||
lastTransactionInvestment
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
timeWeightedNetPerformancePercentage =
|
|
||||||
timeWeightedNetPerformancePercentage.mul(
|
|
||||||
new Big(1).plus(netHoldingPeriodReturn)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
grossPerformance = newGrossPerformance;
|
|
||||||
|
|
||||||
lastTransactionInvestment = transactionInvestment;
|
|
||||||
|
|
||||||
lastValueOfInvestmentBeforeTransaction =
|
|
||||||
valueOfInvestmentBeforeTransaction;
|
|
||||||
|
|
||||||
if (order.itemType === 'start') {
|
|
||||||
feesAtStartDate = fees;
|
|
||||||
grossPerformanceAtStartDate = grossPerformance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
timeWeightedGrossPerformancePercentage =
|
|
||||||
timeWeightedGrossPerformancePercentage.sub(1);
|
|
||||||
|
|
||||||
timeWeightedNetPerformancePercentage =
|
|
||||||
timeWeightedNetPerformancePercentage.sub(1);
|
|
||||||
|
|
||||||
const totalGrossPerformance = grossPerformance.minus(
|
|
||||||
grossPerformanceAtStartDate
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalNetPerformance = grossPerformance
|
|
||||||
.minus(grossPerformanceAtStartDate)
|
|
||||||
.minus(fees.minus(feesAtStartDate));
|
|
||||||
|
|
||||||
return {
|
|
||||||
initialValue,
|
|
||||||
hasErrors: !initialValue || !unitPriceAtEndDate,
|
|
||||||
netPerformance: totalNetPerformance,
|
|
||||||
netPerformancePercentage: timeWeightedNetPerformancePercentage,
|
|
||||||
grossPerformance: totalGrossPerformance,
|
|
||||||
grossPerformancePercentage: timeWeightedGrossPerformancePercentage
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public getInvestments(): { date: string; investment: Big }[] {
|
|
||||||
if (this.transactionPoints.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.transactionPoints.map((transactionPoint) => {
|
|
||||||
return {
|
|
||||||
date: transactionPoint.date,
|
|
||||||
investment: transactionPoint.items.reduce(
|
|
||||||
(investment, transactionPointSymbol) =>
|
|
||||||
investment.add(transactionPointSymbol.investment),
|
|
||||||
new Big(0)
|
|
||||||
)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async calculateTimeline(
|
|
||||||
timelineSpecification: TimelineSpecification[],
|
|
||||||
endDate: string
|
|
||||||
): Promise<TimelineInfoInterface> {
|
|
||||||
if (timelineSpecification.length === 0) {
|
|
||||||
return {
|
|
||||||
maxNetPerformance: new Big(0),
|
|
||||||
minNetPerformance: new Big(0),
|
|
||||||
timelinePeriods: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const startDate = timelineSpecification[0].start;
|
|
||||||
const start = parseDate(startDate);
|
|
||||||
const end = parseDate(endDate);
|
|
||||||
|
|
||||||
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
|
|
||||||
let i = 0;
|
|
||||||
let j = -1;
|
|
||||||
for (
|
|
||||||
let currentDate = start;
|
|
||||||
!isAfter(currentDate, end);
|
|
||||||
currentDate = this.addToDate(
|
|
||||||
currentDate,
|
|
||||||
timelineSpecification[i].accuracy
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
while (
|
|
||||||
j + 1 < this.transactionPoints.length &&
|
|
||||||
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
|
|
||||||
) {
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
|
|
||||||
let periodEndDate = currentDate;
|
|
||||||
if (timelineSpecification[i].accuracy === 'day') {
|
|
||||||
let nextEndDate = end;
|
|
||||||
if (j + 1 < this.transactionPoints.length) {
|
|
||||||
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
|
|
||||||
}
|
|
||||||
periodEndDate = min([
|
|
||||||
addMonths(currentDate, 3),
|
|
||||||
max([currentDate, nextEndDate])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
const timePeriodForDates = this.getTimePeriodForDate(
|
|
||||||
j,
|
|
||||||
currentDate,
|
|
||||||
endOfDay(periodEndDate)
|
|
||||||
);
|
|
||||||
currentDate = periodEndDate;
|
|
||||||
if (timePeriodForDates != null) {
|
|
||||||
timelinePeriodPromises.push(timePeriodForDates);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
|
||||||
timelinePeriodPromises
|
|
||||||
);
|
|
||||||
const minNetPerformance = timelineInfoInterfaces
|
|
||||||
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
|
||||||
.filter((performance) => performance !== null)
|
|
||||||
.reduce((minPerformance, current) => {
|
|
||||||
if (minPerformance.lt(current)) {
|
|
||||||
return minPerformance;
|
|
||||||
} else {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxNetPerformance = timelineInfoInterfaces
|
|
||||||
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
|
||||||
.filter((performance) => performance !== null)
|
|
||||||
.reduce((maxPerformance, current) => {
|
|
||||||
if (maxPerformance.gt(current)) {
|
|
||||||
return maxPerformance;
|
|
||||||
} else {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const timelinePeriods = timelineInfoInterfaces.map(
|
|
||||||
(timelineInfo) => timelineInfo.timelinePeriods
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
maxNetPerformance,
|
|
||||||
minNetPerformance,
|
|
||||||
timelinePeriods: flatten(timelinePeriods)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateOverallPerformance(
|
|
||||||
positions: TimelinePosition[],
|
|
||||||
initialValues: { [p: string]: Big }
|
|
||||||
) {
|
|
||||||
let hasErrors = false;
|
|
||||||
let currentValue = new Big(0);
|
|
||||||
let totalInvestment = new Big(0);
|
|
||||||
let grossPerformance = new Big(0);
|
|
||||||
let grossPerformancePercentage = new Big(0);
|
|
||||||
let netPerformance = new Big(0);
|
|
||||||
let netPerformancePercentage = new Big(0);
|
|
||||||
let completeInitialValue = new Big(0);
|
|
||||||
|
|
||||||
for (const currentPosition of positions) {
|
|
||||||
if (currentPosition.marketPrice) {
|
|
||||||
currentValue = currentValue.add(
|
|
||||||
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
hasErrors = true;
|
|
||||||
}
|
|
||||||
totalInvestment = totalInvestment.add(currentPosition.investment);
|
|
||||||
if (currentPosition.grossPerformance) {
|
|
||||||
grossPerformance = grossPerformance.plus(
|
|
||||||
currentPosition.grossPerformance
|
|
||||||
);
|
|
||||||
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
|
||||||
hasErrors = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
currentPosition.grossPerformancePercentage &&
|
|
||||||
initialValues[currentPosition.symbol]
|
|
||||||
) {
|
|
||||||
const currentInitialValue = initialValues[currentPosition.symbol];
|
|
||||||
completeInitialValue = completeInitialValue.plus(currentInitialValue);
|
|
||||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
|
||||||
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
|
|
||||||
);
|
|
||||||
netPerformancePercentage = netPerformancePercentage.plus(
|
|
||||||
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
|
||||||
);
|
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
|
||||||
Logger.warn(
|
|
||||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
|
|
||||||
);
|
|
||||||
hasErrors = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!completeInitialValue.eq(0)) {
|
|
||||||
grossPerformancePercentage =
|
|
||||||
grossPerformancePercentage.div(completeInitialValue);
|
|
||||||
netPerformancePercentage =
|
|
||||||
netPerformancePercentage.div(completeInitialValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentValue,
|
|
||||||
grossPerformance,
|
|
||||||
grossPerformancePercentage,
|
|
||||||
hasErrors,
|
|
||||||
netPerformance,
|
|
||||||
netPerformancePercentage,
|
|
||||||
totalInvestment
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getTimePeriodForDate(
|
|
||||||
j: number,
|
|
||||||
startDate: Date,
|
|
||||||
endDate: Date
|
|
||||||
): Promise<TimelineInfoInterface> {
|
|
||||||
let investment: Big = new Big(0);
|
|
||||||
let fees: Big = new Big(0);
|
|
||||||
|
|
||||||
const marketSymbolMap: {
|
|
||||||
[date: string]: { [symbol: string]: Big };
|
|
||||||
} = {};
|
|
||||||
if (j >= 0) {
|
|
||||||
const currencies: { [name: string]: string } = {};
|
|
||||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
|
||||||
|
|
||||||
for (const item of this.transactionPoints[j].items) {
|
|
||||||
currencies[item.symbol] = item.currency;
|
|
||||||
dataGatheringItems.push({
|
|
||||||
dataSource: item.dataSource,
|
|
||||||
symbol: item.symbol
|
|
||||||
});
|
|
||||||
investment = investment.add(item.investment);
|
|
||||||
fees = fees.add(item.fee);
|
|
||||||
}
|
|
||||||
|
|
||||||
let marketSymbols: GetValueObject[] = [];
|
|
||||||
if (dataGatheringItems.length > 0) {
|
|
||||||
try {
|
|
||||||
marketSymbols = await this.currentRateService.getValues({
|
|
||||||
currencies,
|
|
||||||
dataGatheringItems,
|
|
||||||
dateQuery: {
|
|
||||||
gte: startDate,
|
|
||||||
lt: endOfDay(endDate)
|
|
||||||
},
|
|
||||||
userCurrency: this.currency
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(
|
|
||||||
`Failed to fetch info for date ${startDate} with exception`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const marketSymbol of marketSymbols) {
|
|
||||||
const date = format(marketSymbol.date, DATE_FORMAT);
|
|
||||||
if (!marketSymbolMap[date]) {
|
|
||||||
marketSymbolMap[date] = {};
|
|
||||||
}
|
|
||||||
if (marketSymbol.marketPrice) {
|
|
||||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
|
||||||
marketSymbol.marketPrice
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: TimelinePeriod[] = [];
|
|
||||||
let maxNetPerformance: Big = null;
|
|
||||||
let minNetPerformance: Big = null;
|
|
||||||
for (
|
|
||||||
let currentDate = startDate;
|
|
||||||
isBefore(currentDate, endDate);
|
|
||||||
currentDate = addDays(currentDate, 1)
|
|
||||||
) {
|
|
||||||
let value = new Big(0);
|
|
||||||
const currentDateAsString = format(currentDate, DATE_FORMAT);
|
|
||||||
let invalid = false;
|
|
||||||
if (j >= 0) {
|
|
||||||
for (const item of this.transactionPoints[j].items) {
|
|
||||||
if (
|
|
||||||
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
|
|
||||||
) {
|
|
||||||
invalid = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
value = value.add(
|
|
||||||
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!invalid) {
|
|
||||||
const grossPerformance = value.minus(investment);
|
|
||||||
const netPerformance = grossPerformance.minus(fees);
|
|
||||||
if (
|
|
||||||
minNetPerformance === null ||
|
|
||||||
minNetPerformance.gt(netPerformance)
|
|
||||||
) {
|
|
||||||
minNetPerformance = netPerformance;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
maxNetPerformance === null ||
|
|
||||||
maxNetPerformance.lt(netPerformance)
|
|
||||||
) {
|
|
||||||
maxNetPerformance = netPerformance;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
grossPerformance,
|
|
||||||
investment,
|
|
||||||
netPerformance,
|
|
||||||
value,
|
|
||||||
date: currentDateAsString
|
|
||||||
};
|
|
||||||
results.push(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
maxNetPerformance,
|
|
||||||
minNetPerformance,
|
|
||||||
timelinePeriods: results
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private getFactor(type: TypeOfOrder) {
|
|
||||||
let factor: number;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'BUY':
|
|
||||||
factor = 1;
|
|
||||||
break;
|
|
||||||
case 'SELL':
|
|
||||||
factor = -1;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
factor = 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return factor;
|
|
||||||
}
|
|
||||||
|
|
||||||
private addToDate(date: Date, accuracy: Accuracy): Date {
|
|
||||||
switch (accuracy) {
|
|
||||||
case 'day':
|
|
||||||
return addDays(date, 1);
|
|
||||||
case 'month':
|
|
||||||
return addMonths(date, 1);
|
|
||||||
case 'year':
|
|
||||||
return addYears(date, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private isNextItemActive(
|
|
||||||
timelineSpecification: TimelineSpecification[],
|
|
||||||
currentDate: Date,
|
|
||||||
i: number
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
i + 1 < timelineSpecification.length &&
|
|
||||||
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,64 @@
|
|||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||||
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it('with no orders', async () => {
|
||||||
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currentRateService,
|
||||||
|
currency: 'CHF',
|
||||||
|
orders: []
|
||||||
|
});
|
||||||
|
|
||||||
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||||
|
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(currentPositions).toEqual({
|
||||||
|
currentValue: new Big(0),
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big(0),
|
||||||
|
netPerformancePercentage: new Big(0),
|
||||||
|
positions: [],
|
||||||
|
totalInvestment: new Big(0)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,110 @@
|
|||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||||
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with BALN.SW buy and sell', async () => {
|
||||||
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currentRateService,
|
||||||
|
currency: 'CHF',
|
||||||
|
orders: [
|
||||||
|
{
|
||||||
|
currency: 'CHF',
|
||||||
|
date: '2022-03-07',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(1.3),
|
||||||
|
name: 'Novartis AG',
|
||||||
|
quantity: new Big(2),
|
||||||
|
symbol: 'NOVN.SW',
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: new Big(75.8)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currency: 'CHF',
|
||||||
|
date: '2022-04-08',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(2.95),
|
||||||
|
name: 'Novartis AG',
|
||||||
|
quantity: new Big(1),
|
||||||
|
symbol: 'NOVN.SW',
|
||||||
|
type: 'SELL',
|
||||||
|
unitPrice: new Big(85.73)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||||
|
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
parseDate('2022-03-07')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(currentPositions).toEqual({
|
||||||
|
currentValue: new Big('87.8'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('21.93'),
|
||||||
|
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('17.68'),
|
||||||
|
netPerformancePercentage: new Big('0.11662269129287598945'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('75.80'),
|
||||||
|
currency: 'CHF',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
firstBuyDate: '2022-03-07',
|
||||||
|
grossPerformance: new Big('21.93'),
|
||||||
|
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||||
|
investment: new Big('75.80'),
|
||||||
|
netPerformance: new Big('17.68'),
|
||||||
|
netPerformancePercentage: new Big('0.11662269129287598945'),
|
||||||
|
marketPrice: 87.8,
|
||||||
|
quantity: new Big('1'),
|
||||||
|
symbol: 'NOVN.SW',
|
||||||
|
transactionCount: 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalInvestment: new Big('75.80')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2022-03-07', investment: new Big('151.6') },
|
||||||
|
{ date: '2022-04-08', investment: new Big('75.8') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2022-03-01', investment: new Big('151.6') },
|
||||||
|
{ date: '2022-04-01', investment: new Big('-85.73') }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,25 +0,0 @@
|
|||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
|
|
||||||
import { PortfolioService } from './portfolio.service';
|
|
||||||
import { PortfolioServiceNew } from './portfolio.service-new';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PortfolioServiceStrategy {
|
|
||||||
public constructor(
|
|
||||||
private readonly portfolioService: PortfolioService,
|
|
||||||
private readonly portfolioServiceNew: PortfolioServiceNew,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public get() {
|
|
||||||
if (
|
|
||||||
this.request.user?.Settings?.settings?.['isNewCalculationEngine'] === true
|
|
||||||
) {
|
|
||||||
return this.portfolioServiceNew;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.portfolioService;
|
|
||||||
}
|
|
||||||
}
|
|
@ -4,22 +4,28 @@ import {
|
|||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
nullifyValuesInObject
|
nullifyValuesInObject
|
||||||
} from '@ghostfolio/api/helper/object.helper';
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { baseCurrency } from '@ghostfolio/common/config';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
|
Filter,
|
||||||
PortfolioChart,
|
PortfolioChart,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioInvestments,
|
PortfolioInvestments,
|
||||||
PortfolioPerformance,
|
PortfolioPerformanceResponse,
|
||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
|
import type {
|
||||||
|
DateRange,
|
||||||
|
GroupBy,
|
||||||
|
RequestWithUser
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@ -29,7 +35,8 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors,
|
||||||
|
Version
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
@ -37,18 +44,22 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|||||||
|
|
||||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||||
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||||
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
|
private baseCurrency: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {
|
||||||
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
|
}
|
||||||
|
|
||||||
@Get('chart')
|
@Get('chart')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@ -56,9 +67,10 @@ export class PortfolioController {
|
|||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range
|
@Query('range') range
|
||||||
): Promise<PortfolioChart> {
|
): Promise<PortfolioChart> {
|
||||||
const historicalDataContainer = await this.portfolioServiceStrategy
|
const historicalDataContainer = await this.portfolioService.getChart(
|
||||||
.get()
|
impersonationId,
|
||||||
.getChart(impersonationId, range);
|
range
|
||||||
|
);
|
||||||
|
|
||||||
let chartData = historicalDataContainer.items;
|
let chartData = historicalDataContainer.items;
|
||||||
|
|
||||||
@ -98,29 +110,77 @@ export class PortfolioController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('chart')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@Version('2')
|
||||||
|
public async getChartV2(
|
||||||
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Query('range') range
|
||||||
|
): Promise<PortfolioChart> {
|
||||||
|
const historicalDataContainer = await this.portfolioService.getChartV2(
|
||||||
|
impersonationId,
|
||||||
|
range
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chart: historicalDataContainer.items,
|
||||||
|
hasError: false,
|
||||||
|
isAllTimeHigh: false,
|
||||||
|
isAllTimeLow: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('range') range?: DateRange,
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||||
if (
|
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
||||||
this.request.user.subscription.type === 'Basic'
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
const { accounts, holdings, hasErrors } =
|
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||||
await this.portfolioServiceStrategy
|
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||||
.get()
|
const tagIds = filterByTags?.split(',') ?? [];
|
||||||
.getDetails(impersonationId, this.request.user.id, range);
|
|
||||||
|
const filters: Filter[] = [
|
||||||
|
...accountIds.map((accountId) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: accountId,
|
||||||
|
type: 'ACCOUNT'
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...assetClasses.map((assetClass) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: assetClass,
|
||||||
|
type: 'ASSET_CLASS'
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...tagIds.map((tagId) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: tagId,
|
||||||
|
type: 'TAG'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
const {
|
||||||
|
accounts,
|
||||||
|
filteredValueInBaseCurrency,
|
||||||
|
filteredValueInPercentage,
|
||||||
|
hasErrors,
|
||||||
|
holdings,
|
||||||
|
totalValueInBaseCurrency
|
||||||
|
} = await this.portfolioService.getDetails(
|
||||||
|
impersonationId,
|
||||||
|
this.request.user.id,
|
||||||
|
range,
|
||||||
|
filters
|
||||||
|
);
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
@ -141,7 +201,7 @@ export class PortfolioController {
|
|||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
portfolioPosition.currency,
|
portfolioPosition.currency,
|
||||||
this.request.user.Settings.currency
|
this.request.user.Settings.settings.baseCurrency
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
@ -161,13 +221,38 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { accounts, hasError, holdings };
|
let hasDetails = true;
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
|
holdings[symbol] = {
|
||||||
|
...portfolioPosition,
|
||||||
|
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
|
||||||
|
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
|
||||||
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
|
sectors: hasDetails ? portfolioPosition.sectors : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accounts,
|
||||||
|
filteredValueInBaseCurrency,
|
||||||
|
filteredValueInPercentage,
|
||||||
|
hasError,
|
||||||
|
holdings,
|
||||||
|
totalValueInBaseCurrency
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('investments')
|
@Get('investments')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
@Headers('impersonation-id') impersonationId: string
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Query('groupBy') groupBy?: GroupBy
|
||||||
): Promise<PortfolioInvestments> {
|
): Promise<PortfolioInvestments> {
|
||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
@ -179,9 +264,16 @@ export class PortfolioController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let investments = await this.portfolioServiceStrategy
|
let investments: InvestmentItem[];
|
||||||
.get()
|
|
||||||
.getInvestments(impersonationId);
|
if (groupBy === 'month') {
|
||||||
|
investments = await this.portfolioService.getInvestments(
|
||||||
|
impersonationId,
|
||||||
|
'month'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
investments = await this.portfolioService.getInvestments(impersonationId);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -203,16 +295,19 @@ export class PortfolioController {
|
|||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPerformance(
|
public async getPerformance(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range
|
@Query('range') range
|
||||||
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
const performanceInformation = await this.portfolioServiceStrategy
|
const performanceInformation = await this.portfolioService.getPerformance(
|
||||||
.get()
|
impersonationId,
|
||||||
.getPerformance(impersonationId, range);
|
range
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
|
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
performanceInformation.performance = nullifyValuesInObject(
|
performanceInformation.performance = nullifyValuesInObject(
|
||||||
@ -231,9 +326,10 @@ export class PortfolioController {
|
|||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range
|
@Query('range') range
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
const result = await this.portfolioServiceStrategy
|
const result = await this.portfolioService.getPositions(
|
||||||
.get()
|
impersonationId,
|
||||||
.getPositions(impersonationId, range);
|
range
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -273,39 +369,43 @@ export class PortfolioController {
|
|||||||
hasDetails = user.subscription.type === 'Premium';
|
hasDetails = user.subscription.type === 'Premium';
|
||||||
}
|
}
|
||||||
|
|
||||||
const { holdings } = await this.portfolioServiceStrategy
|
const { holdings } = await this.portfolioService.getDetails(
|
||||||
.get()
|
access.userId,
|
||||||
.getDetails(access.userId, access.userId);
|
access.userId,
|
||||||
|
'max',
|
||||||
|
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
|
||||||
|
);
|
||||||
|
|
||||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
hasDetails,
|
hasDetails,
|
||||||
|
alias: access.alias,
|
||||||
holdings: {}
|
holdings: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalValue = Object.values(holdings)
|
const totalValue = Object.values(holdings)
|
||||||
.filter((holding) => {
|
|
||||||
return holding.assetClass === 'EQUITY';
|
|
||||||
})
|
|
||||||
.map((portfolioPosition) => {
|
.map((portfolioPosition) => {
|
||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
portfolioPosition.currency,
|
portfolioPosition.currency,
|
||||||
this.request.user?.Settings?.currency ?? baseCurrency
|
this.request.user?.Settings?.settings.baseCurrency ??
|
||||||
|
this.baseCurrency
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
if (portfolioPosition.assetClass === 'EQUITY') {
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
portfolioPublicDetails.holdings[symbol] = {
|
allocationCurrent: portfolioPosition.value / totalValue,
|
||||||
allocationCurrent: portfolioPosition.allocationCurrent,
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
currency: portfolioPosition.currency,
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
name: portfolioPosition.name,
|
name: portfolioPosition.name,
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||||
value: portfolioPosition.value / totalValue
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
};
|
symbol: portfolioPosition.symbol,
|
||||||
}
|
url: portfolioPosition.url,
|
||||||
|
value: portfolioPosition.value / totalValue
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return portfolioPublicDetails;
|
return portfolioPublicDetails;
|
||||||
@ -316,9 +416,17 @@ export class PortfolioController {
|
|||||||
public async getSummary(
|
public async getSummary(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId
|
||||||
): Promise<PortfolioSummary> {
|
): Promise<PortfolioSummary> {
|
||||||
let summary = await this.portfolioServiceStrategy
|
if (
|
||||||
.get()
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
.getSummary(impersonationId);
|
this.request.user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = await this.portfolioService.getSummary(impersonationId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -331,7 +439,9 @@ export class PortfolioController {
|
|||||||
'currentNetPerformance',
|
'currentNetPerformance',
|
||||||
'currentValue',
|
'currentValue',
|
||||||
'dividend',
|
'dividend',
|
||||||
|
'emergencyFund',
|
||||||
'fees',
|
'fees',
|
||||||
|
'items',
|
||||||
'netWorth',
|
'netWorth',
|
||||||
'totalBuy',
|
'totalBuy',
|
||||||
'totalSell'
|
'totalSell'
|
||||||
@ -343,15 +453,18 @@ export class PortfolioController {
|
|||||||
|
|
||||||
@Get('position/:dataSource/:symbol')
|
@Get('position/:dataSource/:symbol')
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers('impersonation-id') impersonationId: string,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Param('dataSource') dataSource,
|
@Param('dataSource') dataSource,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
let position = await this.portfolioServiceStrategy
|
let position = await this.portfolioService.getPosition(
|
||||||
.get()
|
dataSource,
|
||||||
.getPosition(dataSource, impersonationId, symbol);
|
impersonationId,
|
||||||
|
symbol
|
||||||
|
);
|
||||||
|
|
||||||
if (position) {
|
if (position) {
|
||||||
if (
|
if (
|
||||||
@ -392,6 +505,6 @@ export class PortfolioController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.portfolioServiceStrategy.get().getReport(impersonationId);
|
return await this.portfolioService.getReport(impersonationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,14 +13,13 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
|
||||||
import { PortfolioController } from './portfolio.controller';
|
import { PortfolioController } from './portfolio.controller';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
import { PortfolioServiceNew } from './portfolio.service-new';
|
|
||||||
import { RulesService } from './rules.service';
|
import { RulesService } from './rules.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
exports: [PortfolioServiceStrategy],
|
controllers: [PortfolioController],
|
||||||
|
exports: [PortfolioService],
|
||||||
imports: [
|
imports: [
|
||||||
AccessModule,
|
AccessModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
@ -34,13 +33,10 @@ import { RulesService } from './rules.service';
|
|||||||
SymbolProfileModule,
|
SymbolProfileModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [PortfolioController],
|
|
||||||
providers: [
|
providers: [
|
||||||
AccountService,
|
AccountService,
|
||||||
CurrentRateService,
|
CurrentRateService,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
PortfolioServiceNew,
|
|
||||||
PortfolioServiceStrategy,
|
|
||||||
RulesService
|
RulesService
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { Rule } from '@ghostfolio/api/models/rule';
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
|
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -8,7 +9,7 @@ export class RulesService {
|
|||||||
|
|
||||||
public async evaluate<T extends RuleSettings>(
|
public async evaluate<T extends RuleSettings>(
|
||||||
aRules: Rule<T>[],
|
aRules: Rule<T>[],
|
||||||
aUserSettings: { baseCurrency: string }
|
aUserSettings: UserSettings
|
||||||
) {
|
) {
|
||||||
return aRules
|
return aRules
|
||||||
.filter((rule) => {
|
.filter((rule) => {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { CacheModule, Module } from '@nestjs/common';
|
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
||||||
import * as redisStore from 'cache-manager-redis-store';
|
import * as redisStore from 'cache-manager-redis-store';
|
||||||
|
|
||||||
import { RedisCacheService } from './redis-cache.service';
|
import { RedisCacheService } from './redis-cache.service';
|
||||||
@ -8,18 +8,22 @@ import { RedisCacheService } from './redis-cache.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
CacheModule.registerAsync({
|
CacheModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigurationModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigurationService],
|
||||||
useFactory: async (configurationService: ConfigurationService) => ({
|
useFactory: async (configurationService: ConfigurationService) => {
|
||||||
host: configurationService.get('REDIS_HOST'),
|
return <CacheManagerOptions>{
|
||||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
host: configurationService.get('REDIS_HOST'),
|
||||||
port: configurationService.get('REDIS_PORT'),
|
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||||
store: redisStore,
|
password: configurationService.get('REDIS_PASSWORD'),
|
||||||
ttl: configurationService.get('CACHE_TTL')
|
port: configurationService.get('REDIS_PORT'),
|
||||||
})
|
store: redisStore,
|
||||||
})
|
ttl: configurationService.get('CACHE_TTL')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
ConfigurationModule
|
||||||
],
|
],
|
||||||
providers: [ConfigurationService, RedisCacheService],
|
providers: [RedisCacheService],
|
||||||
exports: [RedisCacheService]
|
exports: [RedisCacheService]
|
||||||
})
|
})
|
||||||
export class RedisCacheModule {}
|
export class RedisCacheModule {}
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
|
import {
|
||||||
|
DEFAULT_LANGUAGE_CODE,
|
||||||
|
PROPERTY_COUPONS
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { Coupon } from '@ghostfolio/common/interfaces';
|
import { Coupon } from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
@ -46,22 +49,25 @@ export class SubscriptionController {
|
|||||||
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
|
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
|
||||||
[];
|
[];
|
||||||
|
|
||||||
const isValid = coupons.some((coupon) => {
|
const coupon = coupons.find((currentCoupon) => {
|
||||||
return coupon.code === couponCode;
|
return currentCoupon.code === couponCode;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isValid) {
|
if (coupon === undefined) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
StatusCodes.BAD_REQUEST
|
StatusCodes.BAD_REQUEST
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.subscriptionService.createSubscription(this.request.user.id);
|
await this.subscriptionService.createSubscription({
|
||||||
|
duration: coupon.duration,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
// Destroy coupon
|
// Destroy coupon
|
||||||
coupons = coupons.filter((coupon) => {
|
coupons = coupons.filter((currentCoupon) => {
|
||||||
return coupon.code !== couponCode;
|
return currentCoupon.code !== couponCode;
|
||||||
});
|
});
|
||||||
await this.propertyService.put({
|
await this.propertyService.put({
|
||||||
key: PROPERTY_COUPONS,
|
key: PROPERTY_COUPONS,
|
||||||
@ -69,7 +75,8 @@ export class SubscriptionController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Logger.log(
|
Logger.log(
|
||||||
`Subscription for user '${this.request.user.id}' has been created with coupon`
|
`Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`,
|
||||||
|
'SubscriptionController'
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -84,9 +91,16 @@ export class SubscriptionController {
|
|||||||
req.query.checkoutSessionId
|
req.query.checkoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
Logger.log(`Subscription for user '${userId}' has been created via Stripe`);
|
Logger.log(
|
||||||
|
`Subscription for user '${userId}' has been created via Stripe`,
|
||||||
|
'SubscriptionController'
|
||||||
|
);
|
||||||
|
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
|
res.redirect(
|
||||||
|
`${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('stripe/checkout-session')
|
@Post('stripe/checkout-session')
|
||||||
@ -101,7 +115,7 @@ export class SubscriptionController {
|
|||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'SubscriptionController');
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@ -7,9 +7,9 @@ import { SubscriptionController } from './subscription.controller';
|
|||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PropertyModule],
|
|
||||||
controllers: [SubscriptionController],
|
controllers: [SubscriptionController],
|
||||||
providers: [ConfigurationService, PrismaService, SubscriptionService],
|
exports: [SubscriptionService],
|
||||||
exports: [SubscriptionService]
|
imports: [ConfigurationModule, PrismaModule, PropertyModule],
|
||||||
|
providers: [SubscriptionService]
|
||||||
})
|
})
|
||||||
export class SubscriptionModule {}
|
export class SubscriptionModule {}
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Subscription, User } from '@prisma/client';
|
import { Subscription } from '@prisma/client';
|
||||||
import { addDays, isBefore } from 'date-fns';
|
import { addMilliseconds, isBefore } from 'date-fns';
|
||||||
|
import ms, { StringValue } from 'ms';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -32,7 +34,9 @@ export class SubscriptionService {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
||||||
cancel_url: `${this.configurationService.get('ROOT_URL')}/account`,
|
cancel_url: `${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/account`,
|
||||||
client_reference_id: userId,
|
client_reference_id: userId,
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
@ -44,7 +48,7 @@ export class SubscriptionService {
|
|||||||
payment_method_types: ['card'],
|
payment_method_types: ['card'],
|
||||||
success_url: `${this.configurationService.get(
|
success_url: `${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
|
)}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
|
||||||
};
|
};
|
||||||
|
|
||||||
if (couponId) {
|
if (couponId) {
|
||||||
@ -64,13 +68,19 @@ export class SubscriptionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createSubscription(aUserId: string) {
|
public async createSubscription({
|
||||||
|
duration = '1 year',
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
duration?: StringValue;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
await this.prismaService.subscription.create({
|
await this.prismaService.subscription.create({
|
||||||
data: {
|
data: {
|
||||||
expiresAt: addDays(new Date(), 365),
|
expiresAt: addMilliseconds(new Date(), ms(duration)),
|
||||||
User: {
|
User: {
|
||||||
connect: {
|
connect: {
|
||||||
id: aUserId
|
id: userId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -83,7 +93,7 @@ export class SubscriptionService {
|
|||||||
aCheckoutSessionId
|
aCheckoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.createSubscription(session.client_reference_id);
|
await this.createSubscription({ userId: session.client_reference_id });
|
||||||
|
|
||||||
await this.stripe.customers.update(session.customer as string, {
|
await this.stripe.customers.update(session.customer as string, {
|
||||||
description: session.client_reference_id
|
description: session.client_reference_id
|
||||||
@ -91,7 +101,7 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
return session.client_reference_id;
|
return session.client_reference_id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'SubscriptionService');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
|
|
||||||
export interface SymbolItem {
|
export interface SymbolItem extends UniqueAsset {
|
||||||
currency: string;
|
currency: string;
|
||||||
dataSource: DataSource;
|
|
||||||
historicalData: HistoricalDataItem[];
|
historicalData: HistoricalDataItem[];
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,6 @@ export class SymbolController {
|
|||||||
* Must be after /lookup
|
* Must be after /lookup
|
||||||
*/
|
*/
|
||||||
@Get(':dataSource/:symbol')
|
@Get(':dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getSymbolData(
|
public async getSymbolData(
|
||||||
|
@ -8,13 +8,14 @@ import { SymbolController } from './symbol.controller';
|
|||||||
import { SymbolService } from './symbol.service';
|
import { SymbolService } from './symbol.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [SymbolController],
|
||||||
|
exports: [SymbolService],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule
|
PrismaModule
|
||||||
],
|
],
|
||||||
controllers: [SymbolController],
|
|
||||||
providers: [SymbolService]
|
providers: [SymbolService]
|
||||||
})
|
})
|
||||||
export class SymbolModule {}
|
export class SymbolModule {}
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import {
|
import {
|
||||||
IDataGatheringItem,
|
IDataGatheringItem,
|
||||||
@ -6,6 +5,7 @@ import {
|
|||||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { format, subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
@ -27,8 +27,10 @@ export class SymbolService {
|
|||||||
dataGatheringItem: IDataGatheringItem;
|
dataGatheringItem: IDataGatheringItem;
|
||||||
includeHistoricalData?: number;
|
includeHistoricalData?: number;
|
||||||
}): Promise<SymbolItem> {
|
}): Promise<SymbolItem> {
|
||||||
const response = await this.dataProviderService.get([dataGatheringItem]);
|
const quotes = await this.dataProviderService.getQuotes([
|
||||||
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
|
dataGatheringItem
|
||||||
|
]);
|
||||||
|
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
|
||||||
|
|
||||||
if (dataGatheringItem.dataSource && marketPrice) {
|
if (dataGatheringItem.dataSource && marketPrice) {
|
||||||
let historicalData: HistoricalDataItem[] = [];
|
let historicalData: HistoricalDataItem[] = [];
|
||||||
@ -53,7 +55,8 @@ export class SymbolService {
|
|||||||
currency,
|
currency,
|
||||||
historicalData,
|
historicalData,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
dataSource: dataGatheringItem.dataSource
|
dataSource: dataGatheringItem.dataSource,
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,7 +96,7 @@ export class SymbolService {
|
|||||||
results.items = items;
|
results.items = items;
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error, 'SymbolService');
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
export interface Access {
|
|
||||||
alias?: string;
|
|
||||||
id: string;
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
import { ViewMode } from '@prisma/client';
|
|
||||||
|
|
||||||
export interface UserSettingsParams {
|
|
||||||
currency?: string;
|
|
||||||
userId: string;
|
|
||||||
viewMode?: ViewMode;
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
export interface UserSettings {
|
|
||||||
isRestrictedView?: boolean;
|
|
||||||
}
|
|
@ -1,11 +1,50 @@
|
|||||||
import { IsBoolean, IsOptional } from 'class-validator';
|
import type { DateRange, ViewMode } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateUserSettingDto {
|
export class UpdateUserSettingDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
baseCurrency?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
benchmark?: string;
|
||||||
|
|
||||||
|
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'ytd'])
|
||||||
|
@IsOptional()
|
||||||
|
dateRange?: DateRange;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
emergencyFund?: number;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isNewCalculationEngine?: boolean;
|
isExperimentalFeatures?: boolean;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
language?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
locale?: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
savingsRate?: number;
|
||||||
|
|
||||||
|
@IsIn(<ViewMode[]>['DEFAULT', 'ZEN'])
|
||||||
|
@IsOptional()
|
||||||
|
viewMode?: ViewMode;
|
||||||
}
|
}
|
||||||
|
@ -1,10 +0,0 @@
|
|||||||
import { ViewMode } from '@prisma/client';
|
|
||||||
import { IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class UpdateUserSettingsDto {
|
|
||||||
@IsString()
|
|
||||||
baseCurrency: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
viewMode: ViewMode;
|
|
||||||
}
|
|
@ -1,18 +1,15 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
||||||
import { User } from '@ghostfolio/common/interfaces';
|
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
hasPermission,
|
|
||||||
hasRole,
|
|
||||||
permissions
|
|
||||||
} from '@ghostfolio/common/permissions';
|
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
@ -23,22 +20,19 @@ import {
|
|||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Provider, Role } from '@prisma/client';
|
|
||||||
import { User as UserModel } from '@prisma/client';
|
import { User as UserModel } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
import { size } from 'lodash';
|
||||||
|
|
||||||
import { UserItem } from './interfaces/user-item.interface';
|
import { UserItem } from './interfaces/user-item.interface';
|
||||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
|
||||||
import { UserSettings } from './interfaces/user-settings.interface';
|
|
||||||
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
||||||
import { UpdateUserSettingsDto } from './update-user-settings.dto';
|
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
@Controller('user')
|
@Controller('user')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
@ -64,8 +58,13 @@ export class UserController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getUser(@Param('id') id: string): Promise<User> {
|
public async getUser(
|
||||||
return this.userService.getUser(this.request.user);
|
@Headers('accept-language') acceptLanguage: string
|
||||||
|
): Promise<User> {
|
||||||
|
return this.userService.getUser(
|
||||||
|
this.request.user,
|
||||||
|
acceptLanguage?.split(',')?.[0]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ -102,6 +101,12 @@ export class UserController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
||||||
if (
|
if (
|
||||||
|
size(data) === 1 &&
|
||||||
|
(data.benchmark || data.dateRange) &&
|
||||||
|
this.request.user.role === 'DEMO'
|
||||||
|
) {
|
||||||
|
// Allow benchmark or date range change for demo user
|
||||||
|
} else if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
this.request.user.permissions,
|
this.request.user.permissions,
|
||||||
permissions.updateUserSettings
|
permissions.updateUserSettings
|
||||||
@ -119,7 +124,7 @@ export class UserController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const key in userSettings) {
|
for (const key in userSettings) {
|
||||||
if (userSettings[key] === false) {
|
if (userSettings[key] === false || userSettings[key] === null) {
|
||||||
delete userSettings[key];
|
delete userSettings[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -129,33 +134,4 @@ export class UserController {
|
|||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('settings')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
|
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.updateUserSettings
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userSettings: UserSettingsParams = {
|
|
||||||
currency: data.baseCurrency,
|
|
||||||
userId: this.request.user.id
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
hasPermission(this.request.user.permissions, permissions.updateViewMode)
|
|
||||||
) {
|
|
||||||
userSettings.viewMode = data.viewMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.userService.updateUserSettings(userSettings);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -9,16 +10,19 @@ import { UserController } from './user.controller';
|
|||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [UserController],
|
||||||
|
exports: [UserService],
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
}),
|
}),
|
||||||
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
SubscriptionModule
|
SubscriptionModule,
|
||||||
|
TagModule
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
providers: [UserService]
|
||||||
providers: [ConfigurationService, PrismaService, UserService],
|
|
||||||
exports: [UserService]
|
|
||||||
})
|
})
|
||||||
export class UserModule {}
|
export class UserModule {}
|
||||||
|
@ -2,23 +2,21 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
|
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
User as IUser,
|
||||||
baseCurrency,
|
UserSettings,
|
||||||
locale
|
UserWithSettings
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
getPermissions,
|
||||||
hasRole,
|
hasRole,
|
||||||
permissions
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma, Provider, Role, User, ViewMode } from '@prisma/client';
|
import { Prisma, Role, User } from '@prisma/client';
|
||||||
|
import { sortBy } from 'lodash';
|
||||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
|
||||||
import { UserSettings } from './interfaces/user-settings.interface';
|
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
@ -26,46 +24,53 @@ const crypto = require('crypto');
|
|||||||
export class UserService {
|
export class UserService {
|
||||||
public static DEFAULT_CURRENCY = 'USD';
|
public static DEFAULT_CURRENCY = 'USD';
|
||||||
|
|
||||||
|
private baseCurrency: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService
|
private readonly subscriptionService: SubscriptionService,
|
||||||
) {}
|
private readonly tagService: TagService
|
||||||
|
) {
|
||||||
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
|
}
|
||||||
|
|
||||||
public async getUser({
|
public async getUser(
|
||||||
Account,
|
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
|
||||||
alias,
|
aLocale = locale
|
||||||
id,
|
): Promise<IUser> {
|
||||||
permissions,
|
|
||||||
Settings,
|
|
||||||
subscription
|
|
||||||
}: UserWithSettings): Promise<IUser> {
|
|
||||||
const access = await this.prismaService.access.findMany({
|
const access = await this.prismaService.access.findMany({
|
||||||
include: {
|
include: {
|
||||||
User: true
|
User: true
|
||||||
},
|
},
|
||||||
orderBy: { User: { alias: 'asc' } },
|
orderBy: { alias: 'asc' },
|
||||||
where: { GranteeUser: { id } }
|
where: { GranteeUser: { id } }
|
||||||
});
|
});
|
||||||
|
let tags = await this.tagService.getByUser(id);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
tags = [];
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alias,
|
|
||||||
id,
|
id,
|
||||||
permissions,
|
permissions,
|
||||||
subscription,
|
subscription,
|
||||||
|
tags,
|
||||||
access: access.map((accessItem) => {
|
access: access.map((accessItem) => {
|
||||||
return {
|
return {
|
||||||
alias: accessItem.User.alias,
|
alias: accessItem.alias,
|
||||||
id: accessItem.id
|
id: accessItem.id
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
accounts: Account,
|
accounts: Account,
|
||||||
settings: {
|
settings: {
|
||||||
...(<UserSettings>Settings.settings),
|
...(<UserSettings>Settings.settings),
|
||||||
locale,
|
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale
|
||||||
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
|
||||||
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -83,23 +88,81 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public isRestrictedView(aUser: UserWithSettings) {
|
public isRestrictedView(aUser: UserWithSettings) {
|
||||||
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
|
return aUser.Settings.settings.isRestrictedView ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async user(
|
public async user(
|
||||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
||||||
): Promise<UserWithSettings | null> {
|
): Promise<UserWithSettings | null> {
|
||||||
const userFromDatabase = await this.prismaService.user.findUnique({
|
const {
|
||||||
|
accessToken,
|
||||||
|
Account,
|
||||||
|
authChallenge,
|
||||||
|
createdAt,
|
||||||
|
id,
|
||||||
|
provider,
|
||||||
|
role,
|
||||||
|
Settings,
|
||||||
|
Subscription,
|
||||||
|
thirdPartyId,
|
||||||
|
updatedAt
|
||||||
|
} = await this.prismaService.user.findUnique({
|
||||||
include: { Account: true, Settings: true, Subscription: true },
|
include: { Account: true, Settings: true, Subscription: true },
|
||||||
where: userWhereUniqueInput
|
where: userWhereUniqueInput
|
||||||
});
|
});
|
||||||
|
|
||||||
const user: UserWithSettings = userFromDatabase;
|
const user: UserWithSettings = {
|
||||||
|
accessToken,
|
||||||
|
Account,
|
||||||
|
authChallenge,
|
||||||
|
createdAt,
|
||||||
|
id,
|
||||||
|
provider,
|
||||||
|
role,
|
||||||
|
Settings,
|
||||||
|
thirdPartyId,
|
||||||
|
updatedAt
|
||||||
|
};
|
||||||
|
|
||||||
let currentPermissions = getPermissions(userFromDatabase.role);
|
if (user?.Settings) {
|
||||||
|
if (!user.Settings.settings) {
|
||||||
|
user.Settings.settings = {};
|
||||||
|
}
|
||||||
|
} else if (user) {
|
||||||
|
// Set default settings if needed
|
||||||
|
user.Settings = {
|
||||||
|
settings: {},
|
||||||
|
updatedAt: new Date(),
|
||||||
|
userId: user?.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
// Set default value for base currency
|
||||||
currentPermissions.push(permissions.accessFearAndGreedIndex);
|
if (!(user.Settings.settings as UserSettings)?.baseCurrency) {
|
||||||
|
(user.Settings.settings as UserSettings).baseCurrency =
|
||||||
|
UserService.DEFAULT_CURRENCY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default value for date range
|
||||||
|
(user.Settings.settings as UserSettings).dateRange =
|
||||||
|
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
|
||||||
|
? 'max'
|
||||||
|
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max';
|
||||||
|
|
||||||
|
// Set default value for view mode
|
||||||
|
if (!(user.Settings.settings as UserSettings).viewMode) {
|
||||||
|
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
user.subscription =
|
||||||
|
this.subscriptionService.getSubscription(Subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPermissions = getPermissions(user.role);
|
||||||
|
|
||||||
|
if (user.subscription?.type === 'Premium') {
|
||||||
|
currentPermissions.push(permissions.reportDataGlitch);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||||
@ -122,36 +185,10 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
user.permissions = currentPermissions;
|
user.Account = sortBy(user.Account, (account) => {
|
||||||
|
return account.name;
|
||||||
if (userFromDatabase?.Settings) {
|
});
|
||||||
if (!userFromDatabase.Settings.currency) {
|
user.permissions = currentPermissions.sort();
|
||||||
// Set default currency if needed
|
|
||||||
userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
|
|
||||||
}
|
|
||||||
} else if (userFromDatabase) {
|
|
||||||
// Set default settings if needed
|
|
||||||
userFromDatabase.Settings = {
|
|
||||||
currency: UserService.DEFAULT_CURRENCY,
|
|
||||||
settings: null,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
userId: userFromDatabase?.id,
|
|
||||||
viewMode: ViewMode.DEFAULT
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
|
||||||
user.subscription = this.subscriptionService.getSubscription(
|
|
||||||
userFromDatabase?.Subscription
|
|
||||||
);
|
|
||||||
|
|
||||||
if (user.subscription.type === SubscriptionType.Basic) {
|
|
||||||
user.permissions = user.permissions.filter((permission) => {
|
|
||||||
return permission !== permissions.updateViewMode;
|
|
||||||
});
|
|
||||||
user.Settings.viewMode = ViewMode.ZEN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@ -190,14 +227,16 @@ export class UserService {
|
|||||||
...data,
|
...data,
|
||||||
Account: {
|
Account: {
|
||||||
create: {
|
create: {
|
||||||
currency: baseCurrency,
|
currency: this.baseCurrency,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
name: 'Default Account'
|
name: 'Default Account'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Settings: {
|
Settings: {
|
||||||
create: {
|
create: {
|
||||||
currency: baseCurrency
|
settings: {
|
||||||
|
currency: this.baseCurrency
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -271,7 +310,7 @@ export class UserService {
|
|||||||
userId: string;
|
userId: string;
|
||||||
userSettings: UserSettings;
|
userSettings: UserSettings;
|
||||||
}) {
|
}) {
|
||||||
const settings = userSettings as Prisma.JsonObject;
|
const settings = userSettings as unknown as Prisma.JsonObject;
|
||||||
|
|
||||||
await this.prismaService.settings.upsert({
|
await this.prismaService.settings.upsert({
|
||||||
create: {
|
create: {
|
||||||
@ -293,33 +332,6 @@ export class UserService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateUserSettings({
|
|
||||||
currency,
|
|
||||||
userId,
|
|
||||||
viewMode
|
|
||||||
}: UserSettingsParams) {
|
|
||||||
await this.prismaService.settings.upsert({
|
|
||||||
create: {
|
|
||||||
currency,
|
|
||||||
User: {
|
|
||||||
connect: {
|
|
||||||
id: userId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
viewMode
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
currency,
|
|
||||||
viewMode
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
userId: userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getRandomString(length: number) {
|
private getRandomString(length: number) {
|
||||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
const result = [];
|
const result = [];
|
||||||
|
26
apps/api/src/assets/countries/developed-markets.json
Normal file
26
apps/api/src/assets/countries/developed-markets.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
[
|
||||||
|
"AT",
|
||||||
|
"AU",
|
||||||
|
"BE",
|
||||||
|
"CA",
|
||||||
|
"CH",
|
||||||
|
"DE",
|
||||||
|
"DK",
|
||||||
|
"ES",
|
||||||
|
"FI",
|
||||||
|
"FR",
|
||||||
|
"GB",
|
||||||
|
"HK",
|
||||||
|
"IE",
|
||||||
|
"IL",
|
||||||
|
"IT",
|
||||||
|
"JP",
|
||||||
|
"LU",
|
||||||
|
"NL",
|
||||||
|
"NO",
|
||||||
|
"NZ",
|
||||||
|
"PT",
|
||||||
|
"SE",
|
||||||
|
"SG",
|
||||||
|
"US"
|
||||||
|
]
|
28
apps/api/src/assets/countries/emerging-markets.json
Normal file
28
apps/api/src/assets/countries/emerging-markets.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
[
|
||||||
|
"AE",
|
||||||
|
"BR",
|
||||||
|
"CL",
|
||||||
|
"CN",
|
||||||
|
"CO",
|
||||||
|
"CY",
|
||||||
|
"CZ",
|
||||||
|
"EG",
|
||||||
|
"GR",
|
||||||
|
"HK",
|
||||||
|
"HU",
|
||||||
|
"ID",
|
||||||
|
"IN",
|
||||||
|
"KR",
|
||||||
|
"KW",
|
||||||
|
"MX",
|
||||||
|
"MY",
|
||||||
|
"PE",
|
||||||
|
"PH",
|
||||||
|
"PL",
|
||||||
|
"QA",
|
||||||
|
"SA",
|
||||||
|
"TH",
|
||||||
|
"TR",
|
||||||
|
"TW",
|
||||||
|
"ZA"
|
||||||
|
]
|
7793
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
Normal file
7793
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
Normal file
File diff suppressed because it is too large
Load Diff
7
apps/api/src/assets/cryptocurrencies/custom.json
Normal file
7
apps/api/src/assets/cryptocurrencies/custom.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"LUNA1": "Terra",
|
||||||
|
"LUNA2": "Terra",
|
||||||
|
"SGB1": "Songbird",
|
||||||
|
"UNI1": "Uniswap",
|
||||||
|
"UST": "TerraUSD"
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RedactValuesInResponseInterceptor<T>
|
||||||
|
implements NestInterceptor<T, any>
|
||||||
|
{
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
public intercept(
|
||||||
|
context: ExecutionContext,
|
||||||
|
next: CallHandler<T>
|
||||||
|
): Observable<any> {
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data: any) => {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const hasImpersonationId = !!request.headers?.['impersonation-id'];
|
||||||
|
|
||||||
|
if (hasImpersonationId) {
|
||||||
|
if (data.accounts) {
|
||||||
|
for (const accountId of Object.keys(data.accounts)) {
|
||||||
|
if (data.accounts[accountId]?.balance !== undefined) {
|
||||||
|
data.accounts[accountId].balance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.activities) {
|
||||||
|
data.activities = data.activities.map((activity: Activity) => {
|
||||||
|
if (activity.Account?.balance !== undefined) {
|
||||||
|
activity.Account.balance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return activity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.filteredValueInBaseCurrency) {
|
||||||
|
data.filteredValueInBaseCurrency = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.totalValueInBaseCurrency) {
|
||||||
|
data.totalValueInBaseCurrency = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -5,6 +5,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NestInterceptor
|
NestInterceptor
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { isArray } from 'lodash';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -32,15 +33,29 @@ export class TransformDataSourceInResponseInterceptor<T>
|
|||||||
activity.SymbolProfile.dataSource = encodeDataSource(
|
activity.SymbolProfile.dataSource = encodeDataSource(
|
||||||
activity.SymbolProfile.dataSource
|
activity.SymbolProfile.dataSource
|
||||||
);
|
);
|
||||||
activity.dataSource = encodeDataSource(activity.dataSource);
|
|
||||||
return activity;
|
return activity;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isArray(data.benchmarks)) {
|
||||||
|
data.benchmarks.map((benchmark) => {
|
||||||
|
benchmark.dataSource = encodeDataSource(benchmark.dataSource);
|
||||||
|
return benchmark;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (data.dataSource) {
|
if (data.dataSource) {
|
||||||
data.dataSource = encodeDataSource(data.dataSource);
|
data.dataSource = encodeDataSource(data.dataSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.errors) {
|
||||||
|
for (const error of data.errors) {
|
||||||
|
if (error.dataSource) {
|
||||||
|
error.dataSource = encodeDataSource(error.dataSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (data.holdings) {
|
if (data.holdings) {
|
||||||
for (const symbol of Object.keys(data.holdings)) {
|
for (const symbol of Object.keys(data.holdings)) {
|
||||||
if (data.holdings[symbol].dataSource) {
|
if (data.holdings[symbol].dataSource) {
|
||||||
@ -64,6 +79,12 @@ export class TransformDataSourceInResponseInterceptor<T>
|
|||||||
return position;
|
return position;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.SymbolProfile) {
|
||||||
|
data.SymbolProfile.dataSource = encodeDataSource(
|
||||||
|
data.SymbolProfile.dataSource
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
@ -1,14 +1,30 @@
|
|||||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
import { environment } from './environments/environment';
|
import { environment } from './environments/environment';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const configApp = await NestFactory.create(AppModule);
|
||||||
|
const configService = configApp.get<ConfigService>(ConfigService);
|
||||||
|
|
||||||
|
const NODE_ENV =
|
||||||
|
configService.get<'development' | 'production'>('NODE_ENV') ??
|
||||||
|
'development';
|
||||||
|
|
||||||
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
logger:
|
||||||
|
NODE_ENV === 'production'
|
||||||
|
? ['error', 'log', 'warn']
|
||||||
|
: ['debug', 'error', 'log', 'verbose', 'warn']
|
||||||
|
});
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
const globalPrefix = 'api';
|
app.enableVersioning({
|
||||||
app.setGlobalPrefix(globalPrefix);
|
defaultVersion: '1',
|
||||||
|
type: VersioningType.URI
|
||||||
|
});
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
forbidNonWhitelisted: true,
|
forbidNonWhitelisted: true,
|
||||||
@ -17,10 +33,11 @@ async function bootstrap() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const port = process.env.PORT || 3333;
|
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
||||||
await app.listen(port, () => {
|
const PORT = configService.get<number>('PORT') || 3333;
|
||||||
|
await app.listen(PORT, HOST, () => {
|
||||||
logLogo();
|
logLogo();
|
||||||
Logger.log(`Listening at http://localhost:${port}`);
|
Logger.log(`Listening at http://${HOST}:${PORT}`);
|
||||||
Logger.log('');
|
Logger.log('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { EvaluationResult } from './evaluation-result.interface';
|
import { EvaluationResult } from './evaluation-result.interface';
|
||||||
|
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
export interface UserSettings {
|
|
||||||
baseCurrency: string;
|
|
||||||
}
|
|
@ -1,8 +1,7 @@
|
|||||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { groupBy } from '@ghostfolio/common/helper';
|
import { groupBy } from '@ghostfolio/common/helper';
|
||||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
||||||
import { RuleInterface } from './interfaces/rule.interface';
|
import { RuleInterface } from './interfaces/rule.interface';
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user