Compare commits

..

116 Commits

Author SHA1 Message Date
67dbef3b7a Release 2.30.0 (#2743) 2023-12-12 19:56:46 +01:00
0e94112dc7 Feature/adjust holdings weight threshold in trackinsight data enhancer (#2742)
* Adjust holdings weight threshold

* Update changelog
2023-12-12 19:54:58 +01:00
b22edff16b Feature/add support for column sorting to lazy loaded activities table (#2738)
* Add support for column sorting

* Update changelog
2023-12-12 19:54:40 +01:00
ffb7cbff50 Feature/highlight all time high in benchmarks of the markets overview (#2740)
* Highlight all time high

* Update changelog
2023-12-11 19:45:30 +01:00
25424ad280 Feature/update prisma to version 5.7.0 (#2737)
* Update prisma to version 5.7.0

* Update changelog
2023-12-10 14:50:32 +01:00
a768902b00 Feature/update prisma to version 5.7.0 (#2734)
* Update prisma to version 5.7.0

* Update changelog
2023-12-09 17:24:25 +01:00
2c7ece50fe Release 2.29.0 (#2733) 2023-12-09 17:14:13 +01:00
51a0ede3e4 Feature/introduce lazy loaded activities table (#2729)
* Introduce lazy-loaded activities table

* Add icon column

* Emit paginator event

* Add pagination logic

* Integrate total items

* Update changelog
2023-12-09 17:12:09 +01:00
531964636b Fix type (#2708) 2023-12-08 19:59:06 +01:00
e461fff1d7 Extend turkish localization (#2730) 2023-12-07 14:13:28 +01:00
4f9a5f0340 Feature/set actions columns of tables to stick at end (#2726)
* Set up stickyEnd in actions columns

* Update changelog
2023-12-06 18:35:03 +01:00
8d80e840b8 Feature/upgrade ngx markdown to version 17.1.1 (#2714)
* Upgrade marked and ngx-markdown

* Update changelog
2023-12-05 18:16:03 +01:00
833982a9de Bugfix/fix biometric authentication registration (#2713)
* Remove token on device registration

* Update changelog
2023-12-05 18:13:35 +01:00
c85966e5ed Improve language localization for tr (#2717)
* Improve language localization for tr

* Update changelog
2023-12-04 21:04:20 +01:00
43f67ba832 Feature/upgrade ng extract i18n merge to version 2.9.0 (#2715)
* Upgrade ng-extract-i18n-merge to version 2.9.0

* Update changelog
2023-12-04 19:54:43 +01:00
cbea8ac9d3 Feature/increase tab height on mobile (#2712)
* Increase tab height on mobile

* Update changelog
2023-12-04 19:53:50 +01:00
d4c939e41d Feature/improve language localization for german 20231202 (#2710)
* Update locales

* Update changelog
2023-12-03 09:26:12 +01:00
c1f129501a Release 2.28.0 (#2709) 2023-12-02 17:20:10 +01:00
377ba75e4c Add support to delete a cash balance (#2707) 2023-12-02 17:17:25 +01:00
77b13b88f0 Relax check for duplicates in activities import (#2704)
* Relax check for duplicates in activities import (allow same day)

* Update changelog
2023-12-02 10:37:03 +01:00
813e73a0a3 Introduce HasPermission annotation (#2693)
* Introduce HasPermission annotation

* Update changelog
2023-12-02 10:21:19 +01:00
1d796a9597 Bugfix/change intraday data gathering to operate synchronously (#2705)
* Change intraday data gathering to operate synchronously

* Update changelog
2023-12-02 10:20:05 +01:00
4eedf64a3c Update OSS Friends (#2701) 2023-12-02 10:00:00 +01:00
ed4dd79c72 Add cash balances table to account detail dialog (#2549)
* Add cash balances table to account detail dialog

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-12-01 21:12:41 +01:00
6f4fd0826c Feature/respect with excluded accounts flag in get account balances (#2697)
* Respect withExcludedAccounts in getAccountBalances()

* Update changelog
2023-12-01 17:22:13 +01:00
8e3a144a37 Fix date (#2699) 2023-11-30 07:43:41 +01:00
07b0a2c40a Add guard (#2696) 2023-11-29 20:10:37 +01:00
c5dc3d4272 Release 2.27.1 (#2698) 2023-11-28 07:53:00 +01:00
73e69273b4 Upgrade Nx to version 17.1.3 (#2694) 2023-11-27 18:47:15 +01:00
e0b74ef418 Release 2.27.0 (#2692) 2023-11-26 21:18:37 +01:00
2b491dc732 Extend performance endpoint by net worth per day (#2574)
* Extend performance endpoint by net worth per day

* Update changelog
2023-11-26 21:17:15 +01:00
79fc22b5ae Change tweet to post (#2684) 2023-11-26 08:30:02 +01:00
0a83bcd697 Feature/improve error log for data source request timeout (#2688)
* Improve error log for timeouts

* Update changelog
2023-11-25 18:50:24 +01:00
52540d460b Fix alignment (#2689) 2023-11-25 18:48:54 +01:00
6ff2e0f952 Introduce base product page (#2687) 2023-11-25 18:48:39 +01:00
b3e72383bc Feature/extract locales 20231125 (#2686)
* Update locales

* Update changelog
2023-11-25 12:46:56 +01:00
bdfba4d509 Upgrade to Nx 17.1 (#2635)
* Upgrade to Nx 17

* Run migrations

* Extend instructions

* Update changelog
2023-11-25 12:31:45 +01:00
8a411b707d Release 2.26.0 (#2685) 2023-11-24 19:17:54 +01:00
e21601202e update ci and add permissions block (#2683) 2023-11-24 10:20:26 +01:00
8f66040df1 Feature/upgrade prisma to version 5.6.0 (#2680)
* Upgrade prisma to version 5.6.0

* Update changelog
2023-11-23 15:16:01 +01:00
5ad248a643 Improve algorithm (#2676) 2023-11-23 15:15:40 +01:00
fa36c42af4 Improve yeekatee comparison data (#2682) 2023-11-23 15:14:59 +01:00
d4ddc781e1 Feature/upgrade yahoo finance2 to version 2.9.0 (#2679)
* Upgrade yahoo-finance2 to version 2.9.0

* Upgdate changelog
2023-11-22 08:11:04 +01:00
386dd56590 Feature/extend personal finance tools pages 20231120 (#2678)
* Improve tags

* Add Compound Planning

* Add Whal

* Add De.Fi

* Add Tiller

* Add Empower
2023-11-22 08:10:42 +01:00
f28b13604a Add as const (#2677) 2023-11-21 20:12:59 +01:00
d827858d0b Release 2.25.1 (#2674) 2023-11-19 16:46:32 +01:00
c758ca4bfa Release 2.25.0 (#2673) 2023-11-19 16:30:36 +01:00
37183a07bd Change black friday to black week (#2672)
* Change black friday to black week

* Change image
2023-11-19 16:28:30 +01:00
fb294fc6e2 Improve wording (#2668) 2023-11-18 11:15:03 +01:00
8898d02442 Bugfix/fix cannot read properties of undefined reading items in get position (#2667)
* Fix "Cannot read properties of undefined (reading 'items')"

* Update changelog
2023-11-18 11:05:05 +01:00
232d30234c Update OSS Friends (#2663) 2023-11-18 09:59:27 +01:00
e2234c4966 Feature/add black friday 2023 blog post (#2664)
* Add blog post: Black Friday 2023

* Update changelog
2023-11-17 20:20:49 +01:00
272a34195b Refactor folder (#2665) 2023-11-17 20:09:19 +01:00
8c25294da7 Feature/upgrade http status codes to version 2.3.0 (#2644)
* Upgrade http-status-codes to version 2.3.0

* Update changelog
2023-11-17 20:08:23 +01:00
6f11627006 Release 2.24.0 (#2661) 2023-11-16 20:29:49 +01:00
215098e418 Feature/improve language localization for german 20231116 (#2660)
* Update locales

* Update changelog
2023-11-16 20:28:09 +01:00
781496383b Bugfix/improve get range query in market data service (#2659)
* Attempt to fix "too many bind variables in prepared statement, expected maximum of 32767"

* Update changelog
2023-11-16 20:22:56 +01:00
f0f304c012 Change tweet to post (#2658) 2023-11-16 20:22:18 +01:00
4bf97c104b Update changelog (#2656) 2023-11-15 21:45:38 +01:00
0b35a3c7a7 Release 2.23.0 (#2655) 2023-11-15 21:22:20 +01:00
1586cd3a59 Feature/change twitter to x (#2654)
* Change Twitter to X

* Update changelog
2023-11-15 21:20:51 +01:00
ae763cbb87 Improve style of sub title (#2652) 2023-11-15 21:11:10 +01:00
aa72287d54 Extend benchmarks in the markets overview by 50-Day and 200-Day trends (#2575)
* Extend benchmarks in the markets overview by 50-Day and 200-Day trends

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-11-15 20:25:16 +01:00
d155ab6f28 Feature/improve data source validation in activities import (#2645)
* Improve data source validation

* Update changelog
2023-11-14 19:15:57 +01:00
913ca71aa5 Feature/upgrade prettier to version 3.1.0 (#2649)
* Upgrade prettier to version 3.1.0

* Update changelog
2023-11-13 20:40:25 +01:00
1ffde2a27e Feature/setup polish (#2650)
* Set up polski

* Update changelog
2023-11-13 20:35:15 +01:00
fcf0cea982 Change BTC to BTCUSD (#2646) 2023-11-13 20:22:47 +01:00
ae1968aadf Feature/extract locales 20231112 (#2643)
* Update locales

* Update changelog
2023-11-12 19:03:11 +01:00
3e6333ef95 Feature/upgrade ng extract i18n merge to version 2.8.3 (#2642)
* Upgrade ng-extract-i18n-merge to version 2.8.3

* Update changelog
2023-11-12 18:28:26 +01:00
c69686651e Release 2.22.0 (#2638) 2023-11-11 18:59:43 +01:00
93b6011ddc Feature/refactor get range in market data service (#2631)
* Refactor to unique asset in getRange()

* Update changelog
2023-11-11 18:57:41 +01:00
f567e25f27 Feature/introduce action menus in overview of action control panel (#2637)
* Introduce action menus

* Exchange rates management
* Coupons management

* Update changelog
2023-11-11 18:48:23 +01:00
5dc538bafb Feature/optimize testimonial carousel style on mobile (#2634)
* Optimize style on mobile

* Update changelog
2023-11-11 17:29:59 +01:00
b4de06fcf0 Feature/add platform icons to account selectors (#2633)
* Add platform icons to account selectors

* Update changelog
2023-11-11 17:27:29 +01:00
27da0eb26e Feature/harmonize name column of historical market data table (#2632)
* Harmonize name column

* Update changelog
2023-11-11 09:02:12 +01:00
8ff80c10e5 Feature/extend personal finance tools pages 20231110 (#2630)
* Add Magnifi

* Add Basil Finance
2023-11-11 09:01:58 +01:00
5db5d5e79a Release 2.21.0 (#2629) 2023-11-09 19:25:14 +01:00
12aac101bd Feature/extend system message (#2628)
* Extend system message

* Update changelog
2023-11-09 19:23:36 +01:00
3a66ccdebe Bugfix/fix unit in overview of home page (#2626)
* Fix unit

* Update changelog
2023-11-09 19:22:15 +01:00
6a722d1bb7 Bugfix/fix get quotes in financial modeling prep service (#2627)
* Fix get quotes

* Update changelog
2023-11-09 18:03:09 +01:00
7c9407d5dc Release 2.20.0 (#2623) 2023-11-08 19:39:38 +01:00
8abb517ac6 Feature/remove loading indicator of unit in overview of home page (#2622)
* Remove loading indicator of unit

* Update changelog
2023-11-08 19:37:45 +01:00
dec1d89c5c Feature/increase timeout in data provider and enhancer health check endpoint (#2621)
* Increase timeout in health check endpoint (data enhancer and provider)

* Update changelog
2023-11-08 18:02:38 +01:00
24e9ecc3e2 Update locales (#2620) 2023-11-08 16:51:44 +01:00
4a1e05b8cd Improve historical market data import (#2581)
* Add form group for historical data import

* Update changelog
2023-11-07 18:16:00 +01:00
39d1a85267 Feature/extend personal finance tools pages 20231107 (#2618)
* Add Monarch Money

* Add YNAB

* Add Allvue Systems
2023-11-07 18:14:54 +01:00
7cb86de7af Feature/remove account type from account database model (#2616)
* Remove account type

* Update changelog
2023-11-07 17:56:18 +01:00
aa078588e8 Release 2.19.0 (#2615) 2023-11-06 18:02:15 +01:00
fcef0a72d5 Bugfix/improve handling of derived currencies (#2604)
* Improve handling of derived currencies

* Update changelog
2023-11-06 17:58:39 +01:00
29987d3e2f Add missing activity types (#2601)
* FEE
* INTEREST
* LIABILITY
2023-11-06 13:09:14 +01:00
6284b4dfe8 Feature/improve localization of fear and greed index (#2612)
* Improve localization

* Update changelog
2023-11-06 13:07:53 +01:00
00342ca1f7 Improve wording (#2603) 2023-11-06 12:15:42 +01:00
234c4fd511 Add CoinGecko (#2611) 2023-11-06 08:33:23 +01:00
669f1fb60c Feature/add database migration to reset account type in account table (#2602)
* Set accountType to NULL

* Update changelog
2023-11-05 18:31:16 +01:00
52df0c62ab Release 2.18.0 (#2600) 2023-11-05 11:53:38 +01:00
e8e1bb83bf Fix get quotes in CoinGecko service (#2595)
* Fix get quotes in CoinGecko service

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-11-05 11:52:09 +01:00
45510702d0 Feature/add hacktoberfest 2023 debriefing blog post (#2599)
* Add blog post: Hacktoberfest 2023 Debriefing

* Update changelog
2023-11-05 11:36:52 +01:00
1b7e3a1e47 Feature/support activities import by isin for yahoo finance (#2597)
* Add support to import activities by isin

* Update changelog
2023-11-05 09:58:57 +01:00
35f98b9d2d Bugfix/handle failing database query for account find many (#2598)
* Handle issue with account.findMany() -> where: { id: { in: [ null ] } }

* Update changelog
2023-11-05 09:57:23 +01:00
e980aed9e7 Reorder functions (#2594) 2023-11-05 08:50:43 +01:00
d993067e9a Feature/extend personal finance tools pages 20231104 2 (#2591)
* Add Vyzer

* Add FinWise
2023-11-05 08:50:27 +01:00
3d09bfdb0c Feature/upgrade angular to version 16.2.12 (#2590)
* Add upgrade guides for Angular

* Upgrade Angular dependencies to version 16.2.12

* Update changelog
2023-11-04 11:59:49 +01:00
3fbc4f500f Add empty columns (#2589) 2023-11-04 10:39:36 +01:00
373201a98f Add major version to docker tags (#2586)
* Add major version to docker tags

* Update changelog
2023-11-04 10:38:35 +01:00
681f88f002 Clean up import (#2492) 2023-11-04 10:19:15 +01:00
8a523a981a Bugfix/fix fees on account level (#2588)
* Fix fees on account level

* Update changelog
2023-11-04 10:17:58 +01:00
81ded53363 Center membership card (#2582) 2023-11-04 10:17:35 +01:00
5272407af8 Feature/extend personal finance tools pages 20231104 (#2587)
* Introduce alias

* Add Rocket Money

* Add 8FIGURES
2023-11-04 10:17:17 +01:00
c48f89d117 Add empty columns (#2583) 2023-11-04 10:11:58 +01:00
046fdd3ae7 Release 2.17.0 (#2579) 2023-11-02 19:36:59 +01:00
e69c7a753c Feature/add edit exchange rate button to admin control (#2577)
* Ad edit button

* Update changelog
2023-11-02 19:35:03 +01:00
5191415b5a Add Intuit Mint (#2578) 2023-11-02 19:34:42 +01:00
a704378702 Refactor interface of getQuotes() to object (#2570) 2023-11-01 13:55:48 +01:00
cf7ce64de7 Bugfix/improve alignment of menu item icons (#2566)
* Improve alignment

* Update changelog
2023-10-31 14:01:33 +01:00
8c1b45f35b Bugfix/fix exception in webauthn page (#2564)
* Remove useBrowserAutofill option in startAuthentication()

* Update changelog
2023-10-30 19:23:55 +01:00
6ad1528d01 Feature/improve language localization for german 20231029 (#2565)
* Update locales

* Update changelog
2023-10-29 19:33:42 +01:00
244 changed files with 54691 additions and 11526 deletions

View File

@ -4,6 +4,9 @@ on:
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -13,12 +16,12 @@ jobs:
- 18 - 18
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Use Node.js ${{ matrix.node_version }} - name: Use Node.js ${{ matrix.node_version }}
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node_version }} node-version: ${{ matrix.node_version }}
cache: 'yarn' cache: 'yarn'

View File

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Docker metadata - name: Docker metadata
id: meta id: meta
@ -21,6 +21,7 @@ jobs:
with: with:
images: ghostfolio/ghostfolio images: ghostfolio/ghostfolio
tags: | tags: |
type=semver,pattern={{major}}
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Set up QEMU - name: Set up QEMU

View File

@ -5,11 +5,205 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.30.0 - 2023-12-12
### Added
- Added support for column sorting to the lazy-loaded activities table on the portfolio activities page (experimental)
- Extended the benchmarks of the markets overview by the current market condition (all time high)
### Changed
- Adjusted the threshold to skip the data enhancement (_Trackinsight_) if data is inaccurate
- Upgraded `prisma` from version `5.6.0` to `5.7.0`
## 2.29.0 - 2023-12-09
### Added
- Introduced a lazy-loaded activities table on the portfolio activities page (experimental)
### Changed
- Set the actions columns of various tables to stick at the end
- Increased the height of the tabs on mobile
- Improved the language localization for German (`de`)
- Improved the language localization for Türkçe (`tr`)
- Upgraded `marked` from version `4.2.12` to `9.1.6`
- Upgraded `ngx-markdown` from version `15.1.0` to `17.1.1`
- Upgraded `ng-extract-i18n-merge` from version `2.8.3` to `2.9.0`
### Fixed
- Fixed an issue in the biometric authentication registration
## 2.28.0 - 2023-12-02
### Added
- Added a historical cash balances table to the account detail dialog
- Introduced a `HasPermission` annotation for endpoints
### Changed
- Relaxed the check for duplicates in the preview step of the activities import (allow same day)
- Respected the `withExcludedAccounts` flag in the account balance time series
### Fixed
- Changed the mechanism of the `INTRADAY` data gathering to operate synchronously avoiding database deadlocks
## 2.27.1 - 2023-11-28
### Changed
- Reverted `Nx` from version `17.1.3` to `17.0.2`
## 2.27.0 - 2023-11-26
### Changed
- Extended the chart in the account detail dialog by historical cash balances
- Improved the error log for a timeout in the data source request
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `16.2.12` to `17.0.4`
- Upgraded `Nx` from version `17.0.2` to `17.1.3`
## 2.26.0 - 2023-11-24
### Changed
- Upgraded `prisma` from version `5.5.2` to `5.6.0`
- Upgraded `yahoo-finance2` from version `2.8.1` to `2.9.0`
## 2.25.1 - 2023-11-19
### Added
- Added a blog post: _Black Friday 2023_
### Changed
- Upgraded `http-status-codes` from version `2.2.0` to `2.3.0`
### Fixed
- Handled reading items from missing transaction point while getting the position (`getPosition()`) in portfolio service
## 2.24.0 - 2023-11-16
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed the "too many bind variables in prepared statement" issue of the data range functionality (`getRange()`) in the market data service
## 2.23.0 - 2023-11-15
### Added
- Extended the benchmarks in the markets overview by 50-Day and 200-Day trends (experimental)
- Set up the language localization for Polski (`pl`)
### Changed
- Improved the data source validation in the activities import
- Changed _Twitter_ to _𝕏_
- Improved the selection in the twitter bot service
- Improved the language localization for German (`de`)
- Upgraded `ng-extract-i18n-merge` from version `2.7.0` to `2.8.3`
- Upgraded `prettier` from version `3.0.3` to `3.1.0`
## 2.22.0 - 2023-11-11
### Added
- Added the platform icon to the account selectors in the cash balance transfer from one to another account
- Added the platform icon to the account selector of the create or edit activity dialog
### Changed
- Optimized the style of the carousel component on mobile for the testimonial section on the landing page
- Introduced action menus in the overview of the admin control panel
- Harmonized the name column in the historical market data table of the admin control panel
- Refactored the implementation of the data range functionality (`getRange()`) in the market data service
## 2.21.0 - 2023-11-09
### Changed
- Extended the system message
### Fixed
- Fixed the unit for the _Zen Mode_ in the overview tab of the home page
- Fixed an issue to get quotes in the _Financial Modeling Prep_ service
## 2.20.0 - 2023-11-08
### Changed
- Removed the loading indicator of the unit in the overview tab of the home page
- Improved the import of historical market data in the admin control panel
- Increased the timeout in the health check endpoint for data enhancers
- Increased the timeout in the health check endpoint for data providers
- Removed the account type from the `Account` database schema
## 2.19.0 - 2023-11-06
### Added
- Added a data migration to set `accountType` to `NULL` in the account database table
### Changed
- Improved the language localization for the _Fear & Greed Index_ (market mood)
- Improved the language localization for German (`de`)
### Fixed
- Improved the handling of derived currencies (`GBp`, `ILA`, `ZAc`)
## 2.18.0 - 2023-11-05
### Added
- Added support to import activities by `isin` in the _Yahoo Finance_ service
- Added a new tag with the major version to the docker image on _Docker Hub_
- Added a blog post: _Hacktoberfest 2023 Debriefing_
### Changed
- Upgraded `angular` from version `16.2.1` to `16.2.12`
### Fixed
- Fixed an issue to get quotes in the _CoinGecko_ service
- Loosened the validation in the activities import (expects values greater than or equal to 0 for `fee`, `quantity` and `unitPrice`)
- Handled an issue with a failing database query (`account.findMany()`) related to activities without account
## 2.17.0 - 2023-11-02
### Added
- Added a button to edit the exchange rates in the admin control panel
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue in the biometric authentication
- Fixed the alignment of the icons in various menus
## 2.16.0 - 2023-10-29 ## 2.16.0 - 2023-10-29
### Changed ### Changed
- Improved the check for duplicates in the preview step of the activities import (allow different accounts) - Relaxed the check for duplicates in the preview step of the activities import (allow different accounts)
- Improved the usability and validation in the cash balance transfer from one to another account - Improved the usability and validation in the cash balance transfer from one to another account
- Changed the checkboxes to slide toggles in the overview of the admin control panel - Changed the checkboxes to slide toggles in the overview of the admin control panel
- Switched from the deprecated (`PUT`) to the new endpoint (`POST`) to manage historical market data in the asset profile details dialog of the admin control panel - Switched from the deprecated (`PUT`) to the new endpoint (`POST`) to manage historical market data in the asset profile details dialog of the admin control panel
@ -101,7 +295,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added support to transfer a part of the cash balance from one to another account - Added support to transfer a part of the cash balance from one to another account
- Extended the markets overview by benchmarks (date of last all time high) - Extended the benchmarks in the markets overview by the date of the last all time high
- Added support to import historical market data in the admin control panel - Added support to import historical market data in the admin control panel
### Changed ### Changed
@ -313,7 +507,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added health check endpoints for data enhancers - Added a health check endpoint for data enhancers
### Changed ### Changed
@ -489,7 +683,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the usability of the login dialog - Improved the usability of the login dialog
- Disabled the caching in the health check endpoints for data providers - Disabled the caching in the health check endpoint for data providers
- Improved the content of the Frequently Asked Questions (FAQ) page - Improved the content of the Frequently Asked Questions (FAQ) page
- Upgraded `prisma` from version `4.15.0` to `4.16.2` - Upgraded `prisma` from version `4.15.0` to `4.16.2`
@ -877,7 +1071,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added a fallback to historical market data if a data provider does not provide live data - Added a fallback to historical market data if a data provider does not provide live data
- Added a general health check endpoint - Added a general health check endpoint
- Added health check endpoints for data providers - Added a health check endpoint for data providers
### Changed ### Changed
@ -2341,7 +2535,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the _Ghostfolio_ trailer to the landing page - Added the _Ghostfolio_ trailer to the landing page
- Extended the markets overview by benchmarks (current change to the all time high) - Extended the benchmarks in the markets overview by the current change to the all time high
## 1.151.0 - 24.05.2022 ## 1.151.0 - 24.05.2022

View File

@ -20,13 +20,19 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
## Dependencies ## Dependencies
### Angular
#### Upgrade (minor versions)
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"`
### Nx ### Nx
#### Upgrade #### Upgrade
1. Run `yarn nx migrate latest` 1. Run `yarn nx migrate latest`
1. Make sure `package.json` changes make sense and then run `yarn install` 1. Make sure `package.json` changes make sense and then run `yarn install`
1. Run `yarn nx migrate --run-migrations` 1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
### Prisma ### Prisma

View File

@ -231,16 +231,16 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
``` ```
| Field | Type | Description | | Field | Type | Description |
| ---------- | ------------------- | -------------------------------------------------- | | ---------- | ------------------- | ----------------------------------------------------------------------------- |
| accountId | string (`optional`) | Id of the account | | accountId | string (`optional`) | Id of the account |
| comment | string (`optional`) | Comment of the activity | | comment | string (`optional`) | Comment of the activity |
| currency | string | `CHF` \| `EUR` \| `USD` etc. | | currency | string | `CHF` \| `EUR` \| `USD` etc. |
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` | | dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
| date | string | Date in the format `ISO-8601` | | date | string | Date in the format `ISO-8601` |
| fee | number | Fee of the activity | | fee | number | Fee of the activity |
| quantity | number | Quantity of the activity | | quantity | number | Quantity of the activity |
| symbol | string | Symbol of the activity (suitable for `dataSource`) | | symbol | string | Symbol of the activity (suitable for `dataSource`) |
| type | string | `BUY` \| `DIVIDEND` \| `ITEM` \| `SELL` | | type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| unitPrice | number | Price per unit of the activity | | unitPrice | number | Price per unit of the activity |
#### Response #### Response
@ -272,7 +272,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you. Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio). If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).

View File

@ -47,8 +47,7 @@
"test": { "test": {
"executor": "@nx/jest:jest", "executor": "@nx/jest:jest",
"options": { "options": {
"jestConfig": "apps/api/jest.config.ts", "jestConfig": "apps/api/jest.config.ts"
"passWithNoTests": true
}, },
"outputs": ["{workspaceRoot}/coverage/apps/api"] "outputs": ["{workspaceRoot}/coverage/apps/api"]
} }

View File

@ -17,7 +17,6 @@ import { AuthGuard } from '@nestjs/passport';
import { Access as AccessModel } from '@prisma/client'; import { Access as AccessModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessModule } from './access.module';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto'; import { CreateAccessDto } from './create-access.dto';
@ -83,7 +82,7 @@ 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<AccessModel> {
const access = await this.accessService.access({ id }); const access = await this.accessService.access({ id });
if ( if (

View File

@ -0,0 +1,51 @@
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AccountBalanceService } from './account-balance.service';
import { AuthGuard } from '@nestjs/passport';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalance } from '@prisma/client';
@Controller('account-balance')
export class AccountBalanceController {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAccountBalance(
@Param('id') id: string
): Promise<AccountBalance> {
const accountBalance = await this.accountBalanceService.accountBalance({
id
});
if (
!hasPermission(
this.request.user.permissions,
permissions.deleteAccountBalance
) ||
!accountBalance ||
accountBalance.userId !== this.request.user.id
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accountBalanceService.deleteAccountBalance({
id
});
}
}

View File

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

View File

@ -0,0 +1,91 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { AccountBalance, Prisma } from '@prisma/client';
@Injectable()
export class AccountBalanceService {
public constructor(
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
public async accountBalance(
accountBalanceWhereInput: Prisma.AccountBalanceWhereInput
): Promise<AccountBalance | null> {
return this.prismaService.accountBalance.findFirst({
include: {
Account: true
},
where: accountBalanceWhereInput
});
}
public async createAccountBalance(
data: Prisma.AccountBalanceCreateInput
): Promise<AccountBalance> {
return this.prismaService.accountBalance.create({
data
});
}
public async deleteAccountBalance(
where: Prisma.AccountBalanceWhereUniqueInput
): Promise<AccountBalance> {
return this.prismaService.accountBalance.delete({
where
});
}
public async getAccountBalances({
filters,
user,
withExcludedAccounts
}: {
filters?: Filter[];
user: UserWithSettings;
withExcludedAccounts?: boolean;
}): Promise<AccountBalancesResponse> {
const where: Prisma.AccountBalanceWhereInput = { userId: user.id };
const accountFilter = filters?.find(({ type }) => {
return type === 'ACCOUNT';
});
if (accountFilter) {
where.accountId = accountFilter.id;
}
if (withExcludedAccounts === false) {
where.Account = { isExcluded: false };
}
const balances = await this.prismaService.accountBalance.findMany({
where,
orderBy: {
date: 'asc'
},
select: {
Account: true,
date: true,
id: true,
value: true
}
});
return {
balances: balances.map((balance) => {
return {
...balance,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value,
balance.Account.currency,
user.Settings.settings.baseCurrency
)
};
})
};
}
}

View File

@ -1,6 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {
@ -128,8 +128,8 @@ export class AccountController {
@Param('id') id: string @Param('id') id: string
): Promise<AccountBalancesResponse> { ): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({ return this.accountBalanceService.getAccountBalances({
accountId: id, filters: [{ id, type: 'ACCOUNT' }],
userId: this.request.user.id user: this.request.user
}); });
} }

View File

@ -1,7 +1,7 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { 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 { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';

View File

@ -1,4 +1,4 @@
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';

View File

@ -1,4 +1,3 @@
import { AccountType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
@ -10,10 +9,6 @@ import {
import { isString } from 'lodash'; import { isString } from 'lodash';
export class CreateAccountDto { export class CreateAccountDto {
@IsOptional()
@IsString()
accountType?: AccountType;
@IsNumber() @IsNumber()
balance: number; balance: number;

View File

@ -1,4 +1,3 @@
import { AccountType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
@ -10,10 +9,6 @@ import {
import { isString } from 'lodash'; import { isString } from 'lodash';
export class UpdateAccountDto { export class UpdateAccountDto {
@IsOptional()
@IsString()
accountType?: AccountType;
@IsNumber() @IsNumber()
balance: number; balance: number;

View File

@ -23,7 +23,13 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client'; import {
AssetSubClass,
DataSource,
Prisma,
Property,
SymbolProfile
} from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@ -94,9 +100,17 @@ export class AdminService {
return currency !== DEFAULT_CURRENCY; return currency !== DEFAULT_CURRENCY;
}) })
.map((currency) => { .map((currency) => {
const label1 = DEFAULT_CURRENCY;
const label2 = currency;
return { return {
label1: DEFAULT_CURRENCY, label1,
label2: currency, label2,
dataSource:
DataSource[
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
],
symbol: `${label1}${label2}`,
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
1, 1,
DEFAULT_CURRENCY, DEFAULT_CURRENCY,

View File

@ -9,17 +9,22 @@ import {
MAX_CHART_ITEMS, MAX_CHART_ITEMS,
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
DATE_FORMAT,
calculateBenchmarkTrend
} from '@ghostfolio/common/helper';
import {
Benchmark,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkProperty, BenchmarkProperty,
BenchmarkResponse, BenchmarkResponse,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { BenchmarkTrend } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { format } from 'date-fns'; import { format, subDays } from 'date-fns';
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@ -45,9 +50,34 @@ export class BenchmarkService {
return 0; return 0;
} }
public async getBenchmarks({ useCache = true } = {}): Promise< public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) {
BenchmarkResponse['benchmarks'] const historicalData = await this.marketDataService.marketDataItems({
> { orderBy: {
date: 'desc'
},
where: {
dataSource,
symbol,
date: { gte: subDays(new Date(), 400) }
}
});
const fiftyDayAverage = calculateBenchmarkTrend({
historicalData,
days: 50
});
const twoHundredDayAverage = calculateBenchmarkTrend({
historicalData,
days: 200
});
return { trend50d: fiftyDayAverage, trend200d: twoHundredDayAverage };
}
public async getBenchmarks({
enableSharing = false,
useCache = true
} = {}): Promise<BenchmarkResponse['benchmarks']> {
let benchmarks: BenchmarkResponse['benchmarks']; let benchmarks: BenchmarkResponse['benchmarks'];
if (useCache) { if (useCache) {
@ -62,9 +92,16 @@ export class BenchmarkService {
} catch {} } catch {}
} }
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles(); const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
enableSharing
});
const promises: Promise<{ date: Date; marketPrice: number }>[] = []; const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
[];
const promisesBenchmarkTrends: Promise<{
trend50d: BenchmarkTrend;
trend200d: BenchmarkTrend;
}>[] = [];
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
@ -73,10 +110,18 @@ export class BenchmarkService {
}); });
for (const { dataSource, symbol } of benchmarkAssetProfiles) { for (const { dataSource, symbol } of benchmarkAssetProfiles) {
promises.push(this.marketDataService.getMax({ dataSource, symbol })); promisesAllTimeHighs.push(
this.marketDataService.getMax({ dataSource, symbol })
);
promisesBenchmarkTrends.push(
this.getBenchmarkTrends({ dataSource, symbol })
);
} }
const allTimeHighs = await Promise.all(promises); const [allTimeHighs, benchmarkTrends] = await Promise.all([
Promise.all(promisesAllTimeHighs),
Promise.all(promisesBenchmarkTrends)
]);
let storeInCache = true; let storeInCache = true;
benchmarks = allTimeHighs.map((allTimeHigh, index) => { benchmarks = allTimeHighs.map((allTimeHigh, index) => {
@ -93,6 +138,7 @@ export class BenchmarkService {
} else { } else {
storeInCache = false; storeInCache = false;
} }
return { return {
marketCondition: this.getMarketCondition( marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh performancePercentFromAllTimeHigh
@ -100,10 +146,12 @@ export class BenchmarkService {
name: benchmarkAssetProfiles[index].name, name: benchmarkAssetProfiles[index].name,
performances: { performances: {
allTimeHigh: { allTimeHigh: {
date: allTimeHigh.date, date: allTimeHigh?.date,
performancePercent: performancePercentFromAllTimeHigh performancePercent: performancePercentFromAllTimeHigh
} }
} },
trend50d: benchmarkTrends[index].trend50d,
trend200d: benchmarkTrends[index].trend200d
}; };
}); });
@ -118,12 +166,22 @@ export class BenchmarkService {
return benchmarks; return benchmarks;
} }
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> { public async getBenchmarkAssetProfiles({
enableSharing = false
} = {}): Promise<Partial<SymbolProfile>[]> {
const symbolProfileIds: string[] = ( const symbolProfileIds: string[] = (
((await this.propertyService.getByKey( ((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? [] )) as BenchmarkProperty[]) ?? []
).map(({ symbolProfileId }) => { )
.filter((benchmark) => {
if (enableSharing) {
return benchmark.enableSharing;
}
return true;
})
.map(({ symbolProfileId }) => {
return symbolProfileId; return symbolProfileId;
}); });
@ -282,7 +340,15 @@ export class BenchmarkService {
}; };
} }
private getMarketCondition(aPerformanceInPercent: number) { private getMarketCondition(
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET'; aPerformanceInPercent: number
): Benchmark['marketCondition'] {
if (aPerformanceInPercent === 0) {
return 'ALL_TIME_HIGH';
} else if (aPerformanceInPercent <= -0.2) {
return 'BEAR_MARKET';
} else {
return 'NEUTRAL_MARKET';
}
} }
} }

View File

@ -8,6 +8,7 @@ import {
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -25,7 +26,7 @@ import {
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { endOfToday, format, isAfter, isSameDay, parseISO } from 'date-fns'; import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
import { uniqBy } from 'lodash'; import { uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -33,6 +34,7 @@ import { v4 as uuidv4 } from 'uuid';
export class ImportService { export class ImportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -81,12 +83,13 @@ export class ImportService {
const value = new Big(quantity).mul(marketPrice).toNumber(); const value = new Big(quantity).mul(marketPrice).toNumber();
const date = parseDate(dateString);
const isDuplicate = orders.some((activity) => { const isDuplicate = orders.some((activity) => {
return ( return (
activity.accountId === Account?.id && activity.accountId === Account?.id &&
activity.SymbolProfile.currency === assetProfile.currency && activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource && activity.SymbolProfile.dataSource === assetProfile.dataSource &&
isSameDay(activity.date, parseDate(dateString)) && isSameSecond(activity.date, date) &&
activity.quantity === quantity && activity.quantity === quantity &&
activity.SymbolProfile.symbol === assetProfile.symbol && activity.SymbolProfile.symbol === assetProfile.symbol &&
activity.type === 'DIVIDEND' && activity.type === 'DIVIDEND' &&
@ -100,6 +103,7 @@ export class ImportService {
return { return {
Account, Account,
date,
error, error,
quantity, quantity,
value, value,
@ -107,7 +111,6 @@ export class ImportService {
accountUserId: undefined, accountUserId: undefined,
comment: undefined, comment: undefined,
createdAt: undefined, createdAt: undefined,
date: parseDate(dateString),
fee: 0, fee: 0,
feeInBaseCurrency: 0, feeInBaseCurrency: 0,
id: assetProfile.id, id: assetProfile.id,
@ -480,13 +483,13 @@ export class ImportService {
type, type,
unitPrice unitPrice
}) => { }) => {
const date = parseISO(<string>(<unknown>dateString)); const date = parseISO(dateString);
const isDuplicate = existingActivities.some((activity) => { const isDuplicate = existingActivities.some((activity) => {
return ( return (
activity.accountId === accountId && activity.accountId === accountId &&
activity.SymbolProfile.currency === currency && activity.SymbolProfile.currency === currency &&
activity.SymbolProfile.dataSource === dataSource && activity.SymbolProfile.dataSource === dataSource &&
isSameDay(activity.date, date) && isSameSecond(activity.date, date) &&
activity.fee === fee && activity.fee === fee &&
activity.quantity === quantity && activity.quantity === quantity &&
activity.SymbolProfile.symbol === symbol && activity.SymbolProfile.symbol === symbol &&
@ -570,6 +573,12 @@ export class ImportService {
index, index,
{ currency, dataSource, symbol } { currency, dataSource, symbol }
] of uniqueActivitiesDto.entries()) { ] of uniqueActivitiesDto.entries()) {
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid`
);
}
if (dataSource !== 'MANUAL') { if (dataSource !== 'MANUAL') {
const assetProfile = ( const assetProfile = (
await this.dataProviderService.getAssetProfiles([ await this.dataProviderService.getAssetProfiles([

View File

@ -15,7 +15,6 @@ import {
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,
ghostfolioFearAndGreedIndexDataSource ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
@ -58,7 +57,6 @@ export class InfoService {
const platforms = await this.platformService.getPlatforms({ const platforms = await this.platformService.getPlatforms({
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}); });
let systemMessage: string;
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
@ -104,10 +102,6 @@ export class InfoService {
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) { if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
globalPermissions.push(permissions.enableSystemMessage); globalPermissions.push(permissions.enableSystemMessage);
systemMessage = (await this.propertyService.getByKey(
PROPERTY_SYSTEM_MESSAGE
)) as string;
} }
const isUserSignupEnabled = const isUserSignupEnabled =
@ -135,7 +129,6 @@ export class InfoService {
platforms, platforms,
statistics, statistics,
subscriptions, subscriptions,
systemMessage,
tags, tags,
baseCurrency: DEFAULT_CURRENCY, baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies() currencies: this.exchangeRateDataService.getCurrencies()

View File

@ -13,7 +13,6 @@ import {
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsPositive,
IsString, IsString,
Min Min
} from 'class-validator'; } from 'class-validator';
@ -54,7 +53,7 @@ export class CreateOrderDto {
fee: number; fee: number;
@IsNumber() @IsNumber()
@IsPositive() @Min(0)
quantity: number; quantity: number;
@IsString() @IsString()
@ -68,7 +67,7 @@ export class CreateOrderDto {
type: Type; type: Type;
@IsNumber() @IsNumber()
@IsPositive() @Min(0)
unitPrice: number; unitPrice: number;
@IsBoolean() @IsBoolean()

View File

@ -2,6 +2,7 @@ import { OrderWithAccount } from '@ghostfolio/common/types';
export interface Activities { export interface Activities {
activities: Activity[]; activities: Activity[];
count: number;
} }
export interface Activity extends OrderWithAccount { export interface Activity extends OrderWithAccount {

View File

@ -24,7 +24,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Order as OrderModel } from '@prisma/client'; import { Order as OrderModel, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -90,6 +90,8 @@ export class OrderController {
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('take') take?: number @Query('take') take?: number
): Promise<Activities> { ): Promise<Activities> {
@ -103,8 +105,10 @@ export class OrderController {
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;
const activities = await this.orderService.getOrders({ const { activities, count } = await this.orderService.getOrders({
filters, filters,
sortColumn,
sortDirection,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
skip: isNaN(skip) ? undefined : skip, skip: isNaN(skip) ? undefined : skip,
@ -113,7 +117,7 @@ export class OrderController {
withExcludedAccounts: true withExcludedAccounts: true
}); });
return { activities }; return { activities, count };
} }
@Post() @Post()

View File

@ -1,8 +1,8 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';

View File

@ -25,7 +25,7 @@ import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Activity } from './interfaces/activities.interface'; import { Activities, Activity } from './interfaces/activities.interface';
@Injectable() @Injectable()
export class OrderService { export class OrderService {
@ -51,7 +51,7 @@ export class OrderService {
take?: number; take?: number;
cursor?: Prisma.OrderWhereUniqueInput; cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput; where?: Prisma.OrderWhereInput;
orderBy?: Prisma.OrderOrderByWithRelationInput; orderBy?: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput>;
}): Promise<OrderWithAccount[]> { }): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
@ -231,6 +231,8 @@ export class OrderService {
filters, filters,
includeDrafts = false, includeDrafts = false,
skip, skip,
sortColumn,
sortDirection,
take = Number.MAX_SAFE_INTEGER, take = Number.MAX_SAFE_INTEGER,
types, types,
userCurrency, userCurrency,
@ -240,12 +242,17 @@ export class OrderService {
filters?: Filter[]; filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
skip?: number; skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number; take?: number;
types?: TypeOfOrder[]; types?: TypeOfOrder[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<Activity[]> { }): Promise<Activities> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' }
];
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
const { const {
@ -307,6 +314,10 @@ export class OrderService {
}; };
} }
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
}
if (types) { if (types) {
where.OR = types.map((type) => { where.OR = types.map((type) => {
return { return {
@ -317,8 +328,9 @@ export class OrderService {
}); });
} }
return ( const [orders, count] = await Promise.all([
await this.orders({ this.orders({
orderBy,
skip, skip,
take, take,
where, where,
@ -332,10 +344,12 @@ 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 tags: true
}, }
orderBy: { date: 'asc' } }),
}) this.prismaService.order.count({ where })
) ]);
const activities = orders
.filter((order) => { .filter((order) => {
return ( return (
withExcludedAccounts || withExcludedAccounts ||
@ -361,6 +375,8 @@ export class OrderService {
) )
}; };
}); });
return { activities, count };
} }
public async updateOrder({ public async updateOrder({

View File

@ -8,12 +8,10 @@ import {
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsArray, IsArray,
IsBoolean,
IsEnum, IsEnum,
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsPositive,
IsString, IsString,
Min Min
} from 'class-validator'; } from 'class-validator';
@ -56,7 +54,7 @@ export class UpdateOrderDto {
id: string; id: string;
@IsNumber() @IsNumber()
@IsPositive() @Min(0)
quantity: number; quantity: number;
@IsString() @IsString()
@ -70,6 +68,6 @@ export class UpdateOrderDto {
type: Type; type: Type;
@IsNumber() @IsNumber()
@IsPositive() @Min(0)
unitPrice: number; unitPrice: number;
} }

View File

@ -61,6 +61,7 @@ export const CurrentRateServiceMock = {
for (const dataGatheringItem of dataGatheringItems) { for (const dataGatheringItem of dataGatheringItems) {
values.push({ values.push({
date, date,
dataSource: dataGatheringItem.dataSource,
marketPriceInBaseCurrency: mockGetValue( marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol, dataGatheringItem.symbol,
date date
@ -74,6 +75,7 @@ export const CurrentRateServiceMock = {
for (const dataGatheringItem of dataGatheringItems) { for (const dataGatheringItem of dataGatheringItems) {
values.push({ values.push({
date, date,
dataSource: dataGatheringItem.dataSource,
marketPriceInBaseCurrency: mockGetValue( marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol, dataGatheringItem.symbol,
date date

View File

@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
@ -25,30 +26,30 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
getRange: ({ getRange: ({
dateRangeEnd, dateRangeEnd,
dateRangeStart, dateRangeStart,
symbols uniqueAssets
}: { }: {
dateRangeEnd: Date; dateRangeEnd: Date;
dateRangeStart: Date; dateRangeStart: Date;
symbols: string[]; uniqueAssets: UniqueAsset[];
}) => { }) => {
return Promise.resolve<MarketData[]>([ return Promise.resolve<MarketData[]>([
{ {
createdAt: dateRangeStart, createdAt: dateRangeStart,
dataSource: DataSource.YAHOO, dataSource: uniqueAssets[0].dataSource,
date: dateRangeStart, date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d', id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902, marketPrice: 1841.823902,
state: 'CLOSE', state: 'CLOSE',
symbol: symbols[0] symbol: uniqueAssets[0].symbol
}, },
{ {
createdAt: dateRangeEnd, createdAt: dateRangeEnd,
dataSource: DataSource.YAHOO, dataSource: uniqueAssets[0].dataSource,
date: dateRangeEnd, date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56', id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966, marketPrice: 1847.839966,
state: 'CLOSE', state: 'CLOSE',
symbol: symbols[0] symbol: uniqueAssets[0].symbol
} }
]); ]);
} }
@ -134,6 +135,7 @@ describe('CurrentRateService', () => {
errors: [], errors: [],
values: [ values: [
{ {
dataSource: 'YAHOO',
date: undefined, date: undefined,
marketPriceInBaseCurrency: 1841.823902, marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN' symbol: 'AMZN'

View File

@ -2,7 +2,11 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
ResponseError,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns'; import { isBefore, isToday } from 'date-fns';
import { flatten, isEmpty, uniqBy } from 'lodash'; import { flatten, isEmpty, uniqBy } from 'lodash';
@ -52,6 +56,7 @@ export class CurrentRateService {
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) { if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
result.push({ result.push({
dataSource: dataGatheringItem.dataSource,
date: today, date: today,
marketPriceInBaseCurrency: marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
@ -75,27 +80,30 @@ export class CurrentRateService {
); );
} }
const symbols = dataGatheringItems.map((dataGatheringItem) => { const uniqueAssets: UniqueAsset[] = dataGatheringItems.map(
return dataGatheringItem.symbol; ({ dataSource, symbol }) => {
}); return { dataSource, symbol };
}
);
promises.push( promises.push(
this.marketDataService this.marketDataService
.getRange({ .getRange({
dateQuery, dateQuery,
symbols uniqueAssets
}) })
.then((data) => { .then((data) => {
return data.map((marketDataItem) => { return data.map(({ dataSource, date, marketPrice, symbol }) => {
return { return {
date: marketDataItem.date, dataSource,
date,
symbol,
marketPriceInBaseCurrency: marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
marketDataItem.marketPrice, marketPrice,
currencies[marketDataItem.symbol], currencies[symbol],
userCurrency userCurrency
), )
symbol: marketDataItem.symbol
}; };
}); });
}) })
@ -112,7 +120,7 @@ export class CurrentRateService {
}; };
if (!isEmpty(quoteErrors)) { if (!isEmpty(quoteErrors)) {
for (const { symbol } of quoteErrors) { for (const { dataSource, symbol } of quoteErrors) {
try { try {
// If missing quote, fallback to the latest available historical market price // If missing quote, fallback to the latest available historical market price
let value: GetValueObject = response.values.find((currentValue) => { let value: GetValueObject = response.values.find((currentValue) => {
@ -121,6 +129,7 @@ export class CurrentRateService {
if (!value) { if (!value) {
value = { value = {
dataSource,
symbol, symbol,
date: today, date: today,
marketPriceInBaseCurrency: 0 marketPriceInBaseCurrency: 0

View File

@ -1,5 +1,6 @@
export interface GetValueObject { import { UniqueAsset } from '@ghostfolio/common/interfaces';
export interface GetValueObject extends UniqueAsset {
date: Date; date: Date;
marketPriceInBaseCurrency: number; marketPriceInBaseCurrency: number;
symbol: string;
} }

View File

@ -346,14 +346,32 @@ export class PortfolioController {
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
performanceInformation.chart = performanceInformation.chart.map( performanceInformation.chart = performanceInformation.chart.map(
({ date, netPerformanceInPercentage, totalInvestment, value }) => { ({
date,
netPerformanceInPercentage,
netWorth,
totalInvestment,
value
}) => {
return { return {
date, date,
netPerformanceInPercentage, netPerformanceInPercentage,
totalInvestment: new Big(totalInvestment) netWorthInPercentage:
performanceInformation.performance.currentNetWorth === 0
? 0
: new Big(netWorth)
.div(performanceInformation.performance.currentNetWorth)
.toNumber(),
totalInvestment:
performanceInformation.performance.totalInvestment === 0
? 0
: new Big(totalInvestment)
.div(performanceInformation.performance.totalInvestment) .div(performanceInformation.performance.totalInvestment)
.toNumber(), .toNumber(),
valueInPercentage: new Big(value) valueInPercentage:
performanceInformation.performance.currentValue === 0
? 0
: new Big(value)
.div(performanceInformation.performance.currentValue) .div(performanceInformation.performance.currentValue)
.toNumber() .toNumber()
}; };
@ -365,6 +383,7 @@ export class PortfolioController {
[ [
'currentGrossPerformance', 'currentGrossPerformance',
'currentNetPerformance', 'currentNetPerformance',
'currentNetWorth',
'currentValue', 'currentValue',
'totalInvestment' 'totalInvestment'
] ]

View File

@ -1,8 +1,8 @@
import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';

View File

@ -1,3 +1,4 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
@ -67,14 +68,16 @@ import {
isBefore, isBefore,
isSameMonth, isSameMonth,
isSameYear, isSameYear,
isValid,
max, max,
min,
parseISO, parseISO,
set, set,
setDayOfYear, setDayOfYear,
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty, sortBy, uniq, uniqBy } from 'lodash'; import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash';
import { import {
HistoricalDataContainer, HistoricalDataContainer,
@ -91,6 +94,7 @@ const europeMarkets = require('../../assets/countries/europe-markets.json');
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly currentRateService: CurrentRateService, private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -114,8 +118,12 @@ export class PortfolioService {
}): Promise<AccountWithValue[]> { }): Promise<AccountWithValue[]> {
const where: Prisma.AccountWhereInput = { userId: userId }; const where: Prisma.AccountWhereInput = { userId: userId };
if (filters?.[0].id && filters?.[0].type === 'ACCOUNT') { const accountFilter = filters?.find(({ type }) => {
where.id = filters[0].id; return type === 'ACCOUNT';
});
if (accountFilter) {
where.id = accountFilter.id;
} }
const [accounts, details] = await Promise.all([ const [accounts, details] = await Promise.all([
@ -217,7 +225,7 @@ export class PortfolioService {
}): Promise<InvestmentItem[]> { }): Promise<InvestmentItem[]> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const activities = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
filters, filters,
userId, userId,
types: ['DIVIDEND'], types: ['DIVIDEND'],
@ -267,6 +275,13 @@ export class PortfolioService {
includeDrafts: true includeDrafts: true
}); });
if (transactionPoints.length === 0) {
return {
investments: [],
streaks: { currentStreak: 0, longestStreak: 0 }
};
}
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency, currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
@ -274,12 +289,6 @@ export class PortfolioService {
}); });
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return {
investments: [],
streaks: { currentStreak: 0, longestStreak: 0 }
};
}
let investments: InvestmentItem[]; let investments: InvestmentItem[];
@ -367,67 +376,6 @@ export class PortfolioService {
}; };
} }
public async getChart({
dateRange = 'max',
filters,
impersonationId,
userCurrency,
userId,
withExcludedAccounts = false
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
userCurrency: string;
userId: string;
withExcludedAccounts?: boolean;
}): Promise<HistoricalDataContainer> {
userId = await this.getUserId(impersonationId, userId);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
filters,
userId,
withExcludedAccounts
});
const portfolioCalculator = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return {
isAllTimeHigh: false,
isAllTimeLow: false,
items: []
};
}
const endDate = new Date();
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(dateRange, portfolioStart);
const daysInMarket = differenceInDays(new Date(), startDate);
const step = Math.round(
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)
);
const items = await portfolioCalculator.getChartData(
startDate,
endDate,
step
);
return {
items,
isAllTimeHigh: false,
isAllTimeLow: false
};
}
public async getDetails({ public async getDetails({
dateRange = 'max', dateRange = 'max',
filters, filters,
@ -731,13 +679,13 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const orders = ( const { activities } = await this.orderService.getOrders({
await this.orderService.getOrders({
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts: true withExcludedAccounts: true
}) });
).filter(({ SymbolProfile }) => {
const orders = activities.filter(({ SymbolProfile }) => {
return ( return (
SymbolProfile.dataSource === aDataSource && SymbolProfile.dataSource === aDataSource &&
SymbolProfile.symbol === aSymbol SymbolProfile.symbol === aSymbol
@ -879,7 +827,7 @@ export class PortfolioService {
let currentAveragePrice = 0; let currentAveragePrice = 0;
let currentQuantity = 0; let currentQuantity = 0;
const currentSymbol = transactionPoints[j].items.find( const currentSymbol = transactionPoints[j]?.items.find(
({ symbol }) => { ({ symbol }) => {
return symbol === aSymbol; return symbol === aSymbol;
} }
@ -1028,12 +976,6 @@ export class PortfolioService {
userId userId
}); });
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
if (transactionPoints?.length <= 0) { if (transactionPoints?.length <= 0) {
return { return {
hasErrors: false, hasErrors: false,
@ -1041,6 +983,12 @@ export class PortfolioService {
}; };
} }
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.settings.baseCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
@ -1126,6 +1074,31 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const accountBalances = await this.accountBalanceService.getAccountBalances(
{ filters, user, withExcludedAccounts }
);
let accountBalanceItems: HistoricalDataItem[] = Object.values(
// Reduce the array to a map with unique dates as keys
accountBalances.balances.reduce(
(
map: { [date: string]: HistoricalDataItem },
{ date, valueInBaseCurrency }
) => {
const formattedDate = format(date, DATE_FORMAT);
// Store the item in the map, overwriting if the date already exists
map[formattedDate] = {
date: formattedDate,
value: valueInBaseCurrency
};
return map;
},
{}
)
);
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
@ -1139,7 +1112,7 @@ export class PortfolioService {
orders: portfolioOrders orders: portfolioOrders
}); });
if (transactionPoints?.length <= 0) { if (accountBalanceItems?.length <= 0 && transactionPoints?.length <= 0) {
return { return {
chart: [], chart: [],
firstOrderDate: undefined, firstOrderDate: undefined,
@ -1149,6 +1122,7 @@ export class PortfolioService {
currentGrossPerformancePercent: 0, currentGrossPerformancePercent: 0,
currentNetPerformance: 0, currentNetPerformance: 0,
currentNetPerformancePercent: 0, currentNetPerformancePercent: 0,
currentNetWorth: 0,
currentValue: 0, currentValue: 0,
totalInvestment: 0 totalInvestment: 0
} }
@ -1157,7 +1131,15 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = min(
[
parseDate(accountBalanceItems[0]?.date),
parseDate(transactionPoints[0]?.date)
].filter((date) => {
return isValid(date);
})
);
const startDate = this.getStartDate(dateRange, portfolioStart); const startDate = this.getStartDate(dateRange, portfolioStart);
const { const {
currentValue, currentValue,
@ -1175,17 +1157,17 @@ export class PortfolioService {
let currentNetPerformance = netPerformance; let currentNetPerformance = netPerformance;
let currentNetPerformancePercent = netPerformancePercentage; let currentNetPerformancePercent = netPerformancePercentage;
const historicalDataContainer = await this.getChart({ const { items } = await this.getChart({
dateRange, dateRange,
filters,
impersonationId, impersonationId,
portfolioOrders,
transactionPoints,
userCurrency, userCurrency,
userId, userId
withExcludedAccounts
}); });
const itemOfToday = historicalDataContainer.items.find((item) => { const itemOfToday = items.find(({ date }) => {
return item.date === format(new Date(), DATE_FORMAT); return date === format(new Date(), DATE_FORMAT);
}); });
if (itemOfToday) { if (itemOfToday) {
@ -1195,34 +1177,42 @@ export class PortfolioService {
).div(100); ).div(100);
} }
accountBalanceItems = accountBalanceItems.filter(({ date }) => {
return !isBefore(parseDate(date), startDate);
});
const accountBalanceItemOfToday = accountBalanceItems.find(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
});
if (!accountBalanceItemOfToday) {
accountBalanceItems.push({
date: format(new Date(), DATE_FORMAT),
value: last(accountBalanceItems)?.value ?? 0
});
}
const mergedHistoricalDataItems = this.mergeHistoricalDataItems(
accountBalanceItems,
items
);
const currentHistoricalDataItem = last(mergedHistoricalDataItems);
const currentNetWorth = currentHistoricalDataItem?.netWorth ?? 0;
return { return {
errors, errors,
hasErrors, hasErrors,
chart: historicalDataContainer.items.map( chart: mergedHistoricalDataItems,
({ firstOrderDate: parseDate(items[0]?.date),
date,
netPerformance: netPerformanceOfItem,
netPerformanceInPercentage,
totalInvestment: totalInvestmentOfItem,
value
}) => {
return {
date,
netPerformanceInPercentage,
value,
netPerformance: netPerformanceOfItem,
totalInvestment: totalInvestmentOfItem
};
}
),
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date),
performance: { performance: {
currentValue: currentValue.toNumber(), currentNetWorth,
currentGrossPerformance: currentGrossPerformance.toNumber(), currentGrossPerformance: currentGrossPerformance.toNumber(),
currentGrossPerformancePercent: currentGrossPerformancePercent:
currentGrossPerformancePercent.toNumber(), currentGrossPerformancePercent.toNumber(),
currentNetPerformance: currentNetPerformance.toNumber(), currentNetPerformance: currentNetPerformance.toNumber(),
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(), currentNetPerformancePercent: currentNetPerformancePercent.toNumber(),
currentValue: currentValue.toNumber(),
totalInvestment: totalInvestment.toNumber() totalInvestment: totalInvestment.toNumber()
} }
}; };
@ -1376,6 +1366,62 @@ export class PortfolioService {
return cashPositions; return cashPositions;
} }
private async getChart({
dateRange = 'max',
impersonationId,
portfolioOrders,
transactionPoints,
userCurrency,
userId
}: {
dateRange?: DateRange;
impersonationId: string;
portfolioOrders: PortfolioOrder[];
transactionPoints: TransactionPoint[];
userCurrency: string;
userId: string;
}): Promise<HistoricalDataContainer> {
if (transactionPoints.length === 0) {
return {
isAllTimeHigh: false,
isAllTimeLow: false,
items: []
};
}
userId = await this.getUserId(impersonationId, userId);
const portfolioCalculator = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints);
const endDate = new Date();
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(dateRange, portfolioStart);
const daysInMarket = differenceInDays(new Date(), startDate);
const step = Math.round(
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)
);
const items = await portfolioCalculator.getChartData(
startDate,
endDate,
step
);
return {
items,
isAllTimeHigh: false,
isAllTimeLow: false
};
}
private getDividendsByGroup({ private getDividendsByGroup({
dividends, dividends,
groupBy groupBy
@ -1593,18 +1639,18 @@ export class PortfolioService {
userId userId
}); });
const activities = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
userCurrency, userCurrency,
userId userId
}); });
const excludedActivities = ( let { activities: excludedActivities } = await this.orderService.getOrders({
await this.orderService.getOrders({
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts: true withExcludedAccounts: true
}) });
).filter(({ Account: account }) => {
excludedActivities = excludedActivities.filter(({ Account: account }) => {
return account?.isExcluded ?? false; return account?.isExcluded ?? false;
}); });
@ -1784,7 +1830,7 @@ export class PortfolioService {
const userCurrency = const userCurrency =
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
const orders = await this.orderService.getOrders({ const { activities, count } = await this.orderService.getOrders({
filters, filters,
includeDrafts, includeDrafts,
userCurrency, userCurrency,
@ -1793,11 +1839,11 @@ export class PortfolioService {
types: ['BUY', 'SELL'] types: ['BUY', 'SELL']
}); });
if (orders.length <= 0) { if (count <= 0) {
return { transactionPoints: [], orders: [], portfolioOrders: [] }; return { transactionPoints: [], orders: [], portfolioOrders: [] };
} }
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = activities.map((order) => ({
currency: order.SymbolProfile.currency, currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource, dataSource: order.SymbolProfile.dataSource,
date: format(order.date, DATE_FORMAT), date: format(order.date, DATE_FORMAT),
@ -1831,8 +1877,8 @@ export class PortfolioService {
portfolioCalculator.computeTransactionPoints(); portfolioCalculator.computeTransactionPoints();
return { return {
orders,
portfolioOrders, portfolioOrders,
orders: activities,
transactionPoints: portfolioCalculator.getTransactionPoints() transactionPoints: portfolioCalculator.getTransactionPoints()
}; };
} }
@ -1867,7 +1913,8 @@ export class PortfolioService {
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}) { }) {
const ordersOfTypeItemOrLiability = await this.orderService.getOrders({ const { activities: ordersOfTypeItemOrLiability } =
await this.orderService.getOrders({
filters, filters,
userCurrency, userCurrency,
userId, userId,
@ -1892,7 +1939,11 @@ export class PortfolioService {
}); });
} else { } else {
const accountIds = uniq( const accountIds = uniq(
orders.map(({ accountId }) => { orders
.filter(({ accountId }) => {
return accountId;
})
.map(({ accountId }) => {
return accountId; return accountId;
}) })
); );
@ -1995,4 +2046,44 @@ export class PortfolioService {
return { accounts, platforms }; return { accounts, platforms };
} }
private mergeHistoricalDataItems(
accountBalanceItems: HistoricalDataItem[],
performanceChartItems: HistoricalDataItem[]
): HistoricalDataItem[] {
const historicalDataItemsMap: { [date: string]: HistoricalDataItem } = {};
let latestAccountBalance = 0;
for (const item of accountBalanceItems.concat(performanceChartItems)) {
const isAccountBalanceItem = accountBalanceItems.includes(item);
const totalAccountBalance = isAccountBalanceItem
? item.value
: latestAccountBalance;
if (isAccountBalanceItem && performanceChartItems.length > 0) {
latestAccountBalance = item.value;
} else {
historicalDataItemsMap[item.date] = {
...item,
totalAccountBalance,
netWorth:
(isAccountBalanceItem ? 0 : item.value) + totalAccountBalance
};
}
}
// Convert to an array and sort by date in ascending order
const historicalDataItems = Object.keys(historicalDataItemsMap).map(
(date) => {
return historicalDataItemsMap[date];
}
);
historicalDataItems.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
return historicalDataItems;
}
} }

View File

@ -111,14 +111,14 @@ export class SubscriptionService {
aSubscriptions: Subscription[] aSubscriptions: Subscription[]
): UserWithSettings['subscription'] { ): UserWithSettings['subscription'] {
if (aSubscriptions.length > 0) { if (aSubscriptions.length > 0) {
const latestSubscription = aSubscriptions.reduce((a, b) => { const { expiresAt, price } = aSubscriptions.reduce((a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b; return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
}); });
return { return {
expiresAt: latestSubscription.expiresAt, expiresAt,
offer: latestSubscription.price === 0 ? 'default' : 'renewal', offer: price ? 'renewal' : 'default',
type: isBefore(new Date(), latestSubscription.expiresAt) type: isBefore(new Date(), expiresAt)
? SubscriptionType.Premium ? SubscriptionType.Premium
: SubscriptionType.Basic : SubscriptionType.Basic
}; };

View File

@ -40,7 +40,12 @@ export class SymbolService {
const marketData = await this.marketDataService.getRange({ const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) }, dateQuery: { gte: subDays(new Date(), days) },
symbols: [dataGatheringItem.symbol] uniqueAssets: [
{
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
}
]
}); });
historicalData = marketData.map(({ date, marketPrice: value }) => { historicalData = marketData.map(({ date, marketPrice: value }) => {

View File

@ -7,9 +7,14 @@ import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SYSTEM_MESSAGE,
locale locale
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces'; import {
User as IUser,
SystemMessage,
UserSettings
} from '@ghostfolio/common/interfaces';
import { import {
getPermissions, getPermissions,
hasRole, hasRole,
@ -48,6 +53,17 @@ export class UserService {
orderBy: { alias: 'asc' }, orderBy: { alias: 'asc' },
where: { GranteeUser: { id } } where: { GranteeUser: { id } }
}); });
let systemMessage: SystemMessage;
const systemMessageProperty = (await this.propertyService.getByKey(
PROPERTY_SYSTEM_MESSAGE
)) as SystemMessage;
if (systemMessageProperty?.targetGroups?.includes(subscription?.type)) {
systemMessage = systemMessageProperty;
}
let tags = await this.tagService.getByUser(id); let tags = await this.tagService.getByUser(id);
if ( if (
@ -61,6 +77,7 @@ export class UserService {
id, id,
permissions, permissions,
subscription, subscription,
systemMessage,
tags, tags,
access: access.map((accessItem) => { access: access.map((accessItem) => {
return { return {
@ -110,7 +127,9 @@ export class UserService {
updatedAt updatedAt
} = await this.prismaService.user.findUnique({ } = await this.prismaService.user.findUnique({
include: { include: {
Account: true, Account: {
include: { Platform: true }
},
Analytics: true, Analytics: true,
Settings: true, Settings: true,
Subscription: true Subscription: true
@ -179,16 +198,18 @@ export class UserService {
new Date(), new Date(),
user.createdAt user.createdAt
); );
let frequency = 20; let frequency = 15;
if (daysSinceRegistration > 180) { if (daysSinceRegistration > 365) {
frequency = 2;
} else if (daysSinceRegistration > 180) {
frequency = 3; frequency = 3;
} else if (daysSinceRegistration > 60) { } else if (daysSinceRegistration > 60) {
frequency = 5; frequency = 5;
} else if (daysSinceRegistration > 30) { } else if (daysSinceRegistration > 30) {
frequency = 10; frequency = 8;
} else if (daysSinceRegistration > 15) { } else if (daysSinceRegistration > 15) {
frequency = 15; frequency = 12;
} }
if (Analytics?.activityCount % frequency === 1) { if (Analytics?.activityCount % frequency === 1) {
@ -233,8 +254,8 @@ export class UserService {
currentPermissions.push(permissions.impersonateAllUsers); currentPermissions.push(permissions.impersonateAllUsers);
} }
user.Account = sortBy(user.Account, (account) => { user.Account = sortBy(user.Account, ({ name }) => {
return account.name; return name.toLowerCase();
}); });
user.permissions = currentPermissions.sort(); user.permissions = currentPermissions.sort();

View File

@ -54,10 +54,22 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-beanvest</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -70,10 +82,18 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-compound-planning</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-de.fi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -82,6 +102,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-empower</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -90,6 +114,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -102,6 +130,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -110,6 +142,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -118,6 +154,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -142,6 +182,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -170,18 +214,34 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-tiller</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-whal</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ueber-uns</loc> <loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -286,6 +346,14 @@
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc> <loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/blog/2023/11/black-week-2023</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/11/hacktoberfest-2023-debriefing</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/faq</loc> <loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -320,10 +388,22 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-beanvest</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -336,10 +416,18 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-compound-planning</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-de.fi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -348,6 +436,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-empower</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -356,6 +448,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -368,6 +464,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -376,6 +476,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -384,6 +488,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -408,6 +516,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -436,18 +548,34 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-tiller</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-whal</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/es</loc> <loc>https://ghostfol.io/es</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -614,10 +742,22 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-beanvest</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -630,10 +770,18 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capmon</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-compound-planning</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-de.fi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -642,6 +790,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-empower</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -650,6 +802,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -662,6 +818,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -670,6 +830,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -678,6 +842,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -702,6 +870,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -730,18 +902,34 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-tiller</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-whal</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl</loc> <loc>https://ghostfol.io/nl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -754,10 +942,22 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-allvue-systems</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-basil-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-beanvest</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -770,10 +970,18 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-compound-planning</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-de.fi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -782,6 +990,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-empower</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -790,6 +1002,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -802,6 +1018,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -810,6 +1030,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-magnifi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-markets.sh</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -818,6 +1042,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-maybe-finance</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monarch-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -842,6 +1070,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -870,18 +1102,34 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-tiller</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-whal</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-ynab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/functionaliteiten</loc> <loc>https://ghostfol.io/nl/functionaliteiten</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -928,6 +1176,10 @@
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc> <loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/pl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/pt</loc> <loc>https://ghostfol.io/pt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>

View File

@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const HAS_PERMISSION_KEY = 'has_permission';
export function HasPermission(permission: string) {
return SetMetadata(HAS_PERMISSION_KEY, permission);
}

View File

@ -0,0 +1,55 @@
import { HttpException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host';
import { Test, TestingModule } from '@nestjs/testing';
import { HasPermissionGuard } from './has-permission.guard';
describe('HasPermissionGuard', () => {
let guard: HasPermissionGuard;
let reflector: Reflector;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [HasPermissionGuard, Reflector]
}).compile();
guard = module.get<HasPermissionGuard>(HasPermissionGuard);
reflector = module.get<Reflector>(Reflector);
});
function setupReflectorSpy(returnValue: string) {
jest.spyOn(reflector, 'get').mockReturnValue(returnValue);
}
function createMockExecutionContext(permissions: string[]) {
return new ExecutionContextHost([
{
user: {
permissions // Set user permissions based on the argument
}
}
]);
}
it('should deny access if the user does not have any permission', () => {
setupReflectorSpy('required-permission');
const noPermissions = createMockExecutionContext([]);
expect(() => guard.canActivate(noPermissions)).toThrow(HttpException);
});
it('should deny access if the user has the wrong permission', () => {
setupReflectorSpy('required-permission');
const wrongPermission = createMockExecutionContext(['wrong-permission']);
expect(() => guard.canActivate(wrongPermission)).toThrow(HttpException);
});
it('should allow access if the user has the required permission', () => {
setupReflectorSpy('required-permission');
const rightPermission = createMockExecutionContext(['required-permission']);
expect(guard.canActivate(rightPermission)).toBe(true);
});
});

View File

@ -0,0 +1,37 @@
import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator';
import { hasPermission } from '@ghostfolio/common/permissions';
import {
CanActivate,
ExecutionContext,
HttpException,
Injectable
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
export class HasPermissionGuard implements CanActivate {
public constructor(private reflector: Reflector) {}
public canActivate(context: ExecutionContext): boolean {
const requiredPermission = this.reflector.get<string>(
HAS_PERMISSION_KEY,
context.getHandler()
);
if (!requiredPermission) {
return true; // No specific permissions required
}
const { user } = context.switchToHttp().getRequest();
if (!user || !hasPermission(user.permissions, requiredPermission)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return true;
}
}

View File

@ -75,6 +75,14 @@ const locales = {
'/en/blog/2023/09/hacktoberfest-2023': { '/en/blog/2023/09/hacktoberfest-2023': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png', featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 - ${title}` title: `Hacktoberfest 2023 - ${title}`
},
'/en/blog/2023/11/black-week-2023': {
featureGraphicPath: 'assets/images/blog/black-week-2023.jpg',
title: `Black Week 2023 - ${title}`
},
'/en/blog/2023/11/hacktoberfest-2023-debriefing': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 Debriefing - ${title}`
} }
}; };
@ -83,6 +91,9 @@ const isFileRequest = (filename: string) => {
return true; return true;
} else if ( } else if (
filename.includes('auth/ey') || filename.includes('auth/ey') ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-de.fi'
) ||
filename.includes( filename.includes(
'personal-finance-tools/open-source-alternative-to-markets.sh' 'personal-finance-tools/open-source-alternative-to-markets.sh'
) )

View File

@ -1,10 +0,0 @@
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
@Module({
exports: [AccountBalanceService],
imports: [PrismaModule],
providers: [AccountBalanceService]
})
export class AccountBalanceModule {}

View File

@ -1,42 +0,0 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccountBalancesResponse } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { AccountBalance, Prisma } from '@prisma/client';
@Injectable()
export class AccountBalanceService {
public constructor(private readonly prismaService: PrismaService) {}
public async createAccountBalance(
data: Prisma.AccountBalanceCreateInput
): Promise<AccountBalance> {
return this.prismaService.accountBalance.create({
data
});
}
public async getAccountBalances({
accountId,
userId
}: {
accountId: string;
userId: string;
}): Promise<AccountBalancesResponse> {
const balances = await this.prismaService.accountBalance.findMany({
orderBy: {
date: 'asc'
},
select: {
date: true,
id: true,
value: true
},
where: {
accountId,
userId
}
});
return { balances };
}
}

View File

@ -5,6 +5,7 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -105,9 +106,13 @@ export class AlphaVantageService implements DataProviderInterface {
return DataSource.ALPHA_VANTAGE; return DataSource.ALPHA_VANTAGE;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = DEFAULT_REQUEST_TIMEOUT,
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
return {}; return {};
} }

View File

@ -56,7 +56,13 @@ export class CoinGeckoService implements DataProviderInterface {
response.name = name; response.name = name;
} catch (error) { } catch (error) {
Logger.error(error, 'CoinGeckoService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
}
Logger.error(message, 'CoinGeckoService');
} }
return response; return response;
@ -134,13 +140,17 @@ export class CoinGeckoService implements DataProviderInterface {
return DataSource.COINGECKO; return DataSource.COINGECKO;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = DEFAULT_REQUEST_TIMEOUT,
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
const results: { [symbol: string]: IDataProviderResponse } = {}; }: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) { if (symbols.length <= 0) {
return {}; return response;
} }
try { try {
@ -148,10 +158,10 @@ export class CoinGeckoService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, requestTimeout);
const response = await got( const quotes = await got(
`${this.URL}/simple/price?ids=${aSymbols.join( `${this.URL}/simple/price?ids=${symbols.join(
',' ','
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`, )}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
{ {
@ -160,22 +170,26 @@ export class CoinGeckoService implements DataProviderInterface {
} }
).json<any>(); ).json<any>();
for (const symbol in response) { for (const symbol in quotes) {
if (Object.prototype.hasOwnProperty.call(response, symbol)) { response[symbol] = {
results[symbol] = {
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.COINGECKO, dataSource: DataSource.COINGECKO,
marketPrice: response[symbol][DEFAULT_CURRENCY.toLowerCase()], marketPrice: quotes[symbol][DEFAULT_CURRENCY.toLowerCase()],
marketState: 'open' marketState: 'open'
}; };
} }
}
} catch (error) { } catch (error) {
Logger.error(error, 'CoinGeckoService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
} }
return results; Logger.error(message, 'CoinGeckoService');
}
return response;
} }
public getTestSymbol() { public getTestSymbol() {
@ -214,7 +228,13 @@ export class CoinGeckoService implements DataProviderInterface {
}; };
}); });
} catch (error) { } catch (error) {
Logger.error(error, 'CoinGeckoService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
}
Logger.error(message, 'CoinGeckoService');
} }
return { items }; return { items };

View File

@ -2,6 +2,7 @@ import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/in
import { HttpException, Inject, Injectable } from '@nestjs/common'; import { HttpException, Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import ms from 'ms';
@Injectable() @Injectable()
export class DataEnhancerService { export class DataEnhancerService {
@ -24,6 +25,7 @@ export class DataEnhancerService {
try { try {
const assetProfile = await dataEnhancer.enhance({ const assetProfile = await dataEnhancer.enhance({
requestTimeout: ms('30 seconds'),
response: { response: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetSubClass: 'ETF' assetSubClass: 'ETF'

View File

@ -15,9 +15,11 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
) {} ) {}
public async enhance({ public async enhance({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
response, response,
symbol symbol
}: { }: {
requestTimeout?: number;
response: Partial<SymbolProfile>; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<Partial<SymbolProfile>> { }): Promise<Partial<SymbolProfile>> {
@ -45,7 +47,7 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, requestTimeout);
const mappings = await got const mappings = await got
.post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, { .post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {

View File

@ -13,6 +13,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static countriesMapping = { private static countriesMapping = {
'Russian Federation': 'Russia' 'Russian Federation': 'Russia'
}; };
private static holdingsWeightTreshold = 0.85;
private static sectorsMapping = { private static sectorsMapping = {
'Consumer Discretionary': 'Consumer Cyclical', 'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Staples', 'Consumer Defensive': 'Consumer Staples',
@ -21,9 +22,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}; };
public async enhance({ public async enhance({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
response, response,
symbol symbol
}: { }: {
requestTimeout?: number;
response: Partial<SymbolProfile>; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<Partial<SymbolProfile>> { }): Promise<Partial<SymbolProfile>> {
@ -37,7 +40,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, requestTimeout);
const profile = await got( const profile = await got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`, `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`,
@ -111,7 +114,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}); });
}); });
if (holdings?.weight < 0.95) { if (
holdings?.weight < TrackinsightDataEnhancerService.holdingsWeightTreshold
) {
// Skip if data is inaccurate // Skip if data is inaccurate
return response; return response;
} }

View File

@ -1,6 +1,10 @@
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { isCurrency } from '@ghostfolio/common/helper'; import { isCurrency } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
@ -10,6 +14,7 @@ import {
Prisma, Prisma,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { isISIN } from 'class-validator';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
import yahooFinance from 'yahoo-finance2'; import yahooFinance from 'yahoo-finance2';
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface'; import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
@ -71,9 +76,11 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
} }
public async enhance({ public async enhance({
requestTimeout = DEFAULT_REQUEST_TIMEOUT,
response, response,
symbol symbol
}: { }: {
requestTimeout?: number;
response: Partial<SymbolProfile>; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<Partial<SymbolProfile>> { }): Promise<Partial<SymbolProfile>> {
@ -156,7 +163,20 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
const response: Partial<SymbolProfile> = {}; const response: Partial<SymbolProfile> = {};
try { try {
const symbol = this.convertToYahooFinanceSymbol(aSymbol); let symbol = aSymbol;
if (isISIN(symbol)) {
try {
const { quotes } = await yahooFinance.search(symbol);
if (quotes.length === 1) {
symbol = quotes[0].symbol;
}
} catch {}
} else {
symbol = this.convertToYahooFinanceSymbol(symbol);
}
const assetProfile = await yahooFinance.quoteSummary(symbol, { const assetProfile = await yahooFinance.quoteSummary(symbol, {
modules: ['price', 'summaryProfile', 'topHoldings'] modules: ['price', 'summaryProfile', 'topHoldings']
}); });
@ -176,7 +196,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
shortName: assetProfile.price.shortName, shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol symbol: assetProfile.price.symbol
}); });
response.symbol = aSymbol; response.symbol = assetProfile.price.symbol;
if (assetSubClass === AssetSubClass.MUTUALFUND) { if (assetSubClass === AssetSubClass.MUTUALFUND) {
response.sectors = []; response.sectors = [];

View File

@ -17,6 +17,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { format, isValid } from 'date-fns'; import { format, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber } from 'lodash'; import { groupBy, isEmpty, isNumber } from 'lodash';
import ms from 'ms';
@Injectable() @Injectable()
export class DataProviderService { export class DataProviderService {
@ -52,6 +53,7 @@ export class DataProviderService {
symbol symbol
} }
], ],
requestTimeout: ms('30 seconds'),
useCache: false useCache: false
}); });
@ -236,9 +238,11 @@ export class DataProviderService {
public async getQuotes({ public async getQuotes({
items, items,
requestTimeout,
useCache = true useCache = true
}: { }: {
items: UniqueAsset[]; items: UniqueAsset[];
requestTimeout?: number;
useCache?: boolean; useCache?: boolean;
}): Promise<{ }): Promise<{
[symbol: string]: IDataProviderResponse; [symbol: string]: IDataProviderResponse;
@ -311,7 +315,9 @@ export class DataProviderService {
i + maximumNumberOfSymbolsPerRequest i + maximumNumberOfSymbolsPerRequest
); );
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk)); const promise = Promise.resolve(
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk })
);
promises.push( promises.push(
promise.then(async (result) => { promise.then(async (result) => {
@ -340,7 +346,7 @@ export class DataProviderService {
); );
try { try {
this.marketDataService.updateMany({ await this.marketDataService.updateMany({
data: Object.keys(response) data: Object.keys(response)
.filter((symbol) => { .filter((symbol) => {
return ( return (

View File

@ -131,28 +131,34 @@ export class EodHistoricalDataService implements DataProviderInterface {
return DataSource.EOD_HISTORICAL_DATA; return DataSource.EOD_HISTORICAL_DATA;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = DEFAULT_REQUEST_TIMEOUT,
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
const symbols = aSymbols.map((symbol) => { }: {
return this.convertToEodSymbol(symbol); requestTimeout?: number;
}); symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
let response: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) { if (symbols.length <= 0) {
return {}; return response;
} }
const eodHistoricalDataSymbols = symbols.map((symbol) => {
return this.convertToEodSymbol(symbol);
});
try { try {
const abortController = new AbortController(); const abortController = new AbortController();
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, requestTimeout);
const realTimeResponse = await got( const realTimeResponse = await got(
`${this.URL}/real-time/${symbols[0]}?api_token=${ `${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?api_token=${
this.apiKey this.apiKey
}&fmt=json&s=${symbols.join(',')}`, }&fmt=json&s=${eodHistoricalDataSymbols.join(',')}`,
{ {
// @ts-ignore // @ts-ignore
signal: abortController.signal signal: abortController.signal
@ -160,10 +166,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
).json<any>(); ).json<any>();
const quotes = const quotes =
symbols.length === 1 ? [realTimeResponse] : realTimeResponse; eodHistoricalDataSymbols.length === 1
? [realTimeResponse]
: realTimeResponse;
const searchResponse = await Promise.all( const searchResponse = await Promise.all(
symbols eodHistoricalDataSymbols
.filter((symbol) => { .filter((symbol) => {
return !symbol.endsWith('.FOREX'); return !symbol.endsWith('.FOREX');
}) })
@ -176,7 +184,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
return items[0]; return items[0];
}); });
const response = quotes.reduce( response = quotes.reduce(
( (
result: { [symbol: string]: IDataProviderResponse }, result: { [symbol: string]: IDataProviderResponse },
{ close, code, timestamp } { close, code, timestamp }
@ -221,7 +229,13 @@ export class EodHistoricalDataService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, 'EodHistoricalDataService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
}
Logger.error(message, 'EodHistoricalDataService');
} }
return {}; return {};
@ -374,7 +388,13 @@ export class EodHistoricalDataService implements DataProviderInterface {
} }
); );
} catch (error) { } catch (error) {
Logger.error(error, 'EodHistoricalDataService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
}
Logger.error(message, 'EodHistoricalDataService');
} }
return searchResult; return searchResult;

View File

@ -113,13 +113,17 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return DataSource.FINANCIAL_MODELING_PREP; return DataSource.FINANCIAL_MODELING_PREP;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = DEFAULT_REQUEST_TIMEOUT,
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
const results: { [symbol: string]: IDataProviderResponse } = {}; }: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) { if (symbols.length <= 0) {
return {}; return response;
} }
try { try {
@ -127,18 +131,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, requestTimeout);
const response = await got( const quotes = await got(
`${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`, `${this.URL}/quote/${symbols.join(',')}?apikey=${this.apiKey}`,
{ {
// @ts-ignore // @ts-ignore
signal: abortController.signal signal: abortController.signal
} }
).json<any>(); ).json<any>();
for (const { price, symbol } of response) { for (const { price, symbol } of quotes) {
results[symbol] = { response[symbol] = {
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.FINANCIAL_MODELING_PREP, dataSource: DataSource.FINANCIAL_MODELING_PREP,
@ -147,10 +151,16 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}; };
} }
} catch (error) { } catch (error) {
Logger.error(error, 'FinancialModelingPrepService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
} }
return results; Logger.error(message, 'FinancialModelingPrepService');
}
return response;
} }
public getTestSymbol() { public getTestSymbol() {
@ -192,7 +202,13 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}; };
}); });
} catch (error) { } catch (error) {
Logger.error(error, 'FinancialModelingPrepService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
}
Logger.error(message, 'FinancialModelingPrepService');
} }
return { items }; return { items };

View File

@ -7,6 +7,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -99,18 +100,22 @@ export class GoogleSheetsService implements DataProviderInterface {
return DataSource.GOOGLE_SHEETS; return DataSource.GOOGLE_SHEETS;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = DEFAULT_REQUEST_TIMEOUT,
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
if (aSymbols.length <= 0) { }: {
return {}; requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) {
return response;
} }
try { try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols.map((symbol) => { symbols.map((symbol) => {
return { return {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()
@ -129,7 +134,7 @@ export class GoogleSheetsService implements DataProviderInterface {
const marketPrice = parseFloat(row['marketPrice']); const marketPrice = parseFloat(row['marketPrice']);
const symbol = row['symbol']; const symbol = row['symbol'];
if (aSymbols.includes(symbol)) { if (symbols.includes(symbol)) {
response[symbol] = { response[symbol] = {
marketPrice, marketPrice,
currency: symbolProfiles.find((symbolProfile) => { currency: symbolProfiles.find((symbolProfile) => {

View File

@ -2,9 +2,11 @@ import { SymbolProfile } from '@prisma/client';
export interface DataEnhancerInterface { export interface DataEnhancerInterface {
enhance({ enhance({
requestTimeout,
response, response,
symbol symbol
}: { }: {
requestTimeout?: number;
response: Partial<SymbolProfile>; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<Partial<SymbolProfile>>; }): Promise<Partial<SymbolProfile>>;

View File

@ -36,9 +36,13 @@ export interface DataProviderInterface {
getName(): DataSource; getName(): DataSource;
getQuotes( getQuotes({
aSymbols: string[] requestTimeout,
): Promise<{ [symbol: string]: IDataProviderResponse }>; symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }>;
getTestSymbol(): string; getTestSymbol(): string;

View File

@ -133,18 +133,22 @@ export class ManualService implements DataProviderInterface {
return DataSource.MANUAL; return DataSource.MANUAL;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = DEFAULT_REQUEST_TIMEOUT,
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
}: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {}; const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) { if (symbols.length <= 0) {
return response; return response;
} }
try { try {
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols.map((symbol) => { symbols.map((symbol) => {
return { symbol, dataSource: this.getName() }; return { symbol, dataSource: this.getName() };
}) })
); );
@ -154,10 +158,10 @@ export class ManualService implements DataProviderInterface {
orderBy: { orderBy: {
date: 'desc' date: 'desc'
}, },
take: aSymbols.length, take: symbols.length,
where: { where: {
symbol: { symbol: {
in: aSymbols in: symbols
} }
} }
}); });

View File

@ -87,15 +87,19 @@ export class RapidApiService implements DataProviderInterface {
return DataSource.RAPID_API; return DataSource.RAPID_API;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = DEFAULT_REQUEST_TIMEOUT,
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
if (aSymbols.length <= 0) { }: {
requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (symbols.length <= 0) {
return {}; return {};
} }
try { try {
const symbol = aSymbols[0]; const symbol = symbols[0];
if (symbol === ghostfolioFearAndGreedIndexSymbol) { if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
@ -159,7 +163,13 @@ export class RapidApiService implements DataProviderInterface {
return fgi; return fgi;
} catch (error) { } catch (error) {
Logger.error(error, 'RapidApiService'); let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`;
}
Logger.error(message, 'RapidApiService');
return undefined; return undefined;
} }

View File

@ -6,7 +6,10 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -30,7 +33,7 @@ export class YahooFinanceService implements DataProviderInterface {
public async getAssetProfile( public async getAssetProfile(
aSymbol: string aSymbol: string
): Promise<Partial<SymbolProfile>> { ): Promise<Partial<SymbolProfile>> {
const { assetClass, assetSubClass, currency, name } = const { assetClass, assetSubClass, currency, name, symbol } =
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol); await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol);
return { return {
@ -38,8 +41,8 @@ export class YahooFinanceService implements DataProviderInterface {
assetSubClass, assetSubClass,
currency, currency,
name, name,
dataSource: this.getName(), symbol,
symbol: aSymbol dataSource: this.getName()
}; };
} }
@ -156,20 +159,24 @@ export class YahooFinanceService implements DataProviderInterface {
return DataSource.YAHOO; return DataSource.YAHOO;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] requestTimeout = DEFAULT_REQUEST_TIMEOUT,
): Promise<{ [symbol: string]: IDataProviderResponse }> { symbols
if (aSymbols.length <= 0) { }: {
return {}; requestTimeout?: number;
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) {
return response;
} }
const yahooFinanceSymbols = aSymbols.map((symbol) => const yahooFinanceSymbols = symbols.map((symbol) =>
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol) this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol)
); );
try { try {
const response: { [symbol: string]: IDataProviderResponse } = {};
let quotes: Pick< let quotes: Pick<
Quote, Quote,
'currency' | 'marketState' | 'regularMarketPrice' | 'symbol' 'currency' | 'marketState' | 'regularMarketPrice' | 'symbol'

View File

@ -95,6 +95,30 @@ export class ExchangeRateDataService {
const [currency1, currency2] = symbol.match(/.{1,3}/g); const [currency1, currency2] = symbol.match(/.{1,3}/g);
const [date] = Object.keys(result[symbol]); const [date] = Object.keys(result[symbol]);
// Add derived currencies
if (currency2 === 'GBP') {
resultExtended[`${currency1}GBp`] = {
[date]: {
marketPrice:
result[`${currency1}${currency2}`][date].marketPrice * 100
}
};
} else if (currency2 === 'ILS') {
resultExtended[`${currency1}ILA`] = {
[date]: {
marketPrice:
result[`${currency1}${currency2}`][date].marketPrice * 100
}
};
} else if (currency2 === 'ZAR') {
resultExtended[`${currency1}ZAc`] = {
[date]: {
marketPrice:
result[`${currency1}${currency2}`][date].marketPrice * 100
}
};
}
// Calculate the opposite direction // Calculate the opposite direction
resultExtended[`${currency2}${currency1}`] = { resultExtended[`${currency2}${currency1}`] = {
[date]: { [date]: {

View File

@ -59,12 +59,12 @@ export class MarketDataService {
public async getRange({ public async getRange({
dateQuery, dateQuery,
symbols uniqueAssets
}: { }: {
dateQuery: DateQuery; dateQuery: DateQuery;
symbols: string[]; uniqueAssets: UniqueAsset[];
}): Promise<MarketData[]> { }): Promise<MarketData[]> {
return await this.prismaService.marketData.findMany({ return this.prismaService.marketData.findMany({
orderBy: [ orderBy: [
{ {
date: 'asc' date: 'asc'
@ -74,24 +74,33 @@ export class MarketDataService {
} }
], ],
where: { where: {
dataSource: {
in: uniqueAssets.map(({ dataSource }) => {
return dataSource;
})
},
date: dateQuery, date: dateQuery,
symbol: { symbol: {
in: symbols in: uniqueAssets.map(({ symbol }) => {
return symbol;
})
} }
} }
}); });
} }
public async marketDataItems(params: { public async marketDataItems(params: {
select?: Prisma.MarketDataSelectScalar;
skip?: number; skip?: number;
take?: number; take?: number;
cursor?: Prisma.MarketDataWhereUniqueInput; cursor?: Prisma.MarketDataWhereUniqueInput;
where?: Prisma.MarketDataWhereInput; where?: Prisma.MarketDataWhereInput;
orderBy?: Prisma.MarketDataOrderByWithRelationInput; orderBy?: Prisma.MarketDataOrderByWithRelationInput;
}): Promise<MarketData[]> { }): Promise<MarketData[]> {
const { skip, take, cursor, where, orderBy } = params; const { select, skip, take, cursor, where, orderBy } = params;
return this.prismaService.marketData.findMany({ return this.prismaService.marketData.findMany({
select,
cursor, cursor,
orderBy, orderBy,
skip, skip,

View File

@ -57,7 +57,7 @@ export class TwitterBotService {
symbolItem.marketPrice symbolItem.marketPrice
}/100)`; }/100)`;
const benchmarkListing = await this.getBenchmarkListing(3); const benchmarkListing = await this.getBenchmarkListing();
if (benchmarkListing?.length > 1) { if (benchmarkListing?.length > 1) {
status += '\n\n'; status += '\n\n';
@ -78,29 +78,22 @@ export class TwitterBotService {
} }
} }
private async getBenchmarkListing(aMax: number) { private async getBenchmarkListing() {
const benchmarks = await this.benchmarkService.getBenchmarks({ const benchmarks = await this.benchmarkService.getBenchmarks({
enableSharing: true,
useCache: false useCache: false
}); });
const benchmarkListing: string[] = []; return benchmarks
.map(({ marketCondition, name, performances }) => {
for (const [index, benchmark] of benchmarks.entries()) { return `${name} ${(
if (index > aMax - 1) { performances.allTimeHigh.performancePercent * 100
break;
}
benchmarkListing.push(
`${benchmark.name} ${(
benchmark.performances.allTimeHigh.performancePercent * 100
).toFixed(1)}%${ ).toFixed(1)}%${
benchmark.marketCondition !== 'NEUTRAL_MARKET' marketCondition !== 'NEUTRAL_MARKET'
? ' ' + resolveMarketCondition(benchmark.marketCondition).emoji ? ' ' + resolveMarketCondition(marketCondition).emoji
: '' : ''
}` }`;
); })
} .join('\n');
return benchmarkListing.join('\n');
} }
} }

View File

@ -60,6 +60,10 @@
"baseHref": "/nl/", "baseHref": "/nl/",
"localize": ["nl"] "localize": ["nl"]
}, },
"development-pl": {
"baseHref": "/pl/",
"localize": ["pl"]
},
"development-pt": { "development-pt": {
"baseHref": "/pt/", "baseHref": "/pt/",
"localize": ["pt"] "localize": ["pt"]
@ -148,8 +152,8 @@
"serve": { "serve": {
"executor": "@nx/angular:webpack-dev-server", "executor": "@nx/angular:webpack-dev-server",
"options": { "options": {
"browserTarget": "client:build", "proxyConfig": "apps/client/proxy.conf.json",
"proxyConfig": "apps/client/proxy.conf.json" "browserTarget": "client:build"
}, },
"configurations": { "configurations": {
"development-de": { "development-de": {
@ -170,6 +174,9 @@
"development-nl": { "development-nl": {
"browserTarget": "client:build:development-nl" "browserTarget": "client:build:development-nl"
}, },
"development-pl": {
"browserTarget": "client:build:development-pl"
},
"development-pt": { "development-pt": {
"browserTarget": "client:build:development-pt" "browserTarget": "client:build:development-pt"
}, },
@ -193,6 +200,7 @@
"messages.fr.xlf", "messages.fr.xlf",
"messages.it.xlf", "messages.it.xlf",
"messages.nl.xlf", "messages.nl.xlf",
"messages.pl.xlf",
"messages.pt.xlf", "messages.pt.xlf",
"messages.tr.xlf" "messages.tr.xlf"
] ]
@ -207,8 +215,7 @@
"test": { "test": {
"executor": "@nx/jest:jest", "executor": "@nx/jest:jest",
"options": { "options": {
"jestConfig": "apps/client/jest.config.ts", "jestConfig": "apps/client/jest.config.ts"
"passWithNoTests": true
}, },
"outputs": ["{workspaceRoot}/coverage/apps/client"] "outputs": ["{workspaceRoot}/coverage/apps/client"]
} }
@ -235,6 +242,10 @@
"baseHref": "/nl/", "baseHref": "/nl/",
"translation": "apps/client/src/locales/messages.nl.xlf" "translation": "apps/client/src/locales/messages.nl.xlf"
}, },
"pl": {
"baseHref": "/pl/",
"translation": "apps/client/src/locales/messages.pl.xlf"
},
"pt": { "pt": {
"baseHref": "/pt/", "baseHref": "/pt/",
"translation": "apps/client/src/locales/messages.pt.xlf" "translation": "apps/client/src/locales/messages.pt.xlf"

View File

@ -1,4 +1,3 @@
import { Platform } from '@angular/cdk/platform';
import { Inject, forwardRef } from '@angular/core'; import { Inject, forwardRef } from '@angular/core';
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core'; import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
@ -7,10 +6,9 @@ import { addYears, format, getYear, parse } from 'date-fns';
export class CustomDateAdapter extends NativeDateAdapter { export class CustomDateAdapter extends NativeDateAdapter {
public constructor( public constructor(
@Inject(MAT_DATE_LOCALE) public locale: string, @Inject(MAT_DATE_LOCALE) public locale: string,
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string, @Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string
platform: Platform
) { ) {
super(matDateLocale, platform); super(matDateLocale);
} }
/** /**

View File

@ -1,6 +1,6 @@
<header> <header>
<div <div
*ngIf="canCreateAccount || (info?.systemMessage && user)" *ngIf="canCreateAccount || user?.systemMessage"
class="info-message-container" class="info-message-container"
> >
<div class="info-message-inner-container position-fixed w-100"> <div class="info-message-inner-container position-fixed w-100">
@ -19,11 +19,11 @@
</div></a </div></a
> >
<div <div
*ngIf="!canCreateAccount && info?.systemMessage && user" *ngIf="!canCreateAccount && user?.systemMessage"
class="cursor-pointer d-inline-block info-message text-truncate" class="cursor-pointer d-inline-block info-message text-truncate"
(click)="onShowSystemMessage()" (click)="onClickSystemMessage()"
> >
{{ info.systemMessage }} {{ user.systemMessage.message }}
</div> </div>
</div> </div>
</div> </div>
@ -127,8 +127,11 @@
class="align-items-baseline d-flex" class="align-items-baseline d-flex"
href="https://twitter.com/ghostfolio_" href="https://twitter.com/ghostfolio_"
target="_blank" target="_blank"
title="Follow Ghostfolio on Twitter" title="Follow Ghostfolio on X (formerly Twitter)"
>Twitter<ion-icon class="ml-1" name="open-outline"></ion-icon >X (formerly Twitter)<ion-icon
class="ml-1"
name="open-outline"
></ion-icon
></a> ></a>
</li> </li>
<li>&nbsp;</li> <li>&nbsp;</li>
@ -150,6 +153,11 @@
<li> <li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a> <a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li> </li>
<!--
<li>
<a href="../pl" title="Ghostfolio in Polski">Polski</a>
</li>
-->
<li> <li>
<a href="../pt" title="Ghostfolio in Português">Português</a> <a href="../pt" title="Ghostfolio in Português">Português</a>
</li> </li>

View File

@ -155,10 +155,7 @@ export class AppComponent implements OnDestroy, OnInit {
); );
this.hasInfoMessage = this.hasInfoMessage =
hasPermission( this.canCreateAccount || !!this.user?.systemMessage;
this.user?.permissions,
permissions.createUserAccount
) || !!this.info.systemMessage;
this.initializeTheme(this.user?.settings.colorScheme); this.initializeTheme(this.user?.settings.colorScheme);
@ -166,12 +163,16 @@ export class AppComponent implements OnDestroy, OnInit {
}); });
} }
public onCreateAccount() { public onClickSystemMessage() {
this.tokenStorageService.signOut(); if (this.user.systemMessage.routerLink) {
this.router.navigate(this.user.systemMessage.routerLink);
} else {
alert(this.user.systemMessage.message);
}
} }
public onShowSystemMessage() { public onCreateAccount() {
alert(this.info.systemMessage); this.tokenStorageService.signOut();
} }
public onSignOut() { public onSignOut() {

View File

@ -37,7 +37,7 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th> <th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell> <td *matCellDef="let element" class="px-1 text-center" mat-cell>

View File

@ -11,7 +11,11 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper'; import { downloadAsFile } from '@ghostfolio/common/helper';
import { HistoricalDataItem, User } from '@ghostfolio/common/interfaces'; import {
AccountBalancesResponse,
HistoricalDataItem,
User
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import Big from 'big.js'; import Big from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
@ -20,6 +24,7 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { AccountDetailDialogParams } from './interfaces/interfaces'; import { AccountDetailDialogParams } from './interfaces/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@Component({ @Component({
host: { class: 'd-flex flex-column h-100' }, host: { class: 'd-flex flex-column h-100' },
@ -29,14 +34,17 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./account-detail-dialog.component.scss'] styleUrls: ['./account-detail-dialog.component.scss']
}) })
export class AccountDetailDialog implements OnDestroy, OnInit { export class AccountDetailDialog implements OnDestroy, OnInit {
public accountBalances: AccountBalancesResponse['balances'];
public activities: OrderWithAccount[];
public balance: number; public balance: number;
public currency: string; public currency: string;
public equity: number; public equity: number;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToDeleteAccountBalance: boolean;
public historicalDataItems: HistoricalDataItem[]; public historicalDataItems: HistoricalDataItem[];
public isLoadingActivities: boolean;
public isLoadingChart: boolean; public isLoadingChart: boolean;
public name: string; public name: string;
public orders: OrderWithAccount[];
public platformName: string; public platformName: string;
public transactionCount: number; public transactionCount: number;
public user: User; public user: User;
@ -58,13 +66,18 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToDeleteAccountBalance = hasPermission(
this.user.permissions,
permissions.deleteAccountBalance
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
} }
public ngOnInit() { public ngOnInit() {
this.isLoadingChart = true; this.isLoadingActivities = true;
this.dataService this.dataService
.fetchAccount(this.data.accountId) .fetchAccount(this.data.accountId)
@ -103,37 +116,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => { .subscribe(({ activities }) => {
this.orders = activities; this.activities = activities;
this.changeDetectorRef.markForCheck(); this.isLoadingActivities = false;
});
this.dataService
.fetchPortfolioPerformance({
filters: [
{
id: this.data.accountId,
type: 'ACCOUNT'
}
],
range: 'max',
withExcludedAccounts: true
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => {
this.historicalDataItems = chart.map(
({ date, value, valueInPercentage }) => {
return {
date,
value:
this.hasImpersonationId || this.user.settings.isRestrictedView
? valueInPercentage
: value
};
}
);
this.isLoadingChart = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -144,17 +129,32 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.hasImpersonationId = !!impersonationId;
}); });
this.fetchAccountBalances();
this.fetchPortfolioPerformance();
} }
public onClose() { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
public onDeleteAccountBalance(aId: string) {
this.dataService
.deleteAccountBalance(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchAccountBalances();
this.fetchPortfolioPerformance();
}
});
}
public onExport() { public onExport() {
this.dataService this.dataService
.fetchExport( .fetchExport(
this.orders.map((order) => { this.activities.map(({ id }) => {
return order.id; return id;
}) })
) )
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -172,6 +172,51 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}); });
} }
private fetchAccountBalances() {
this.dataService
.fetchAccountBalances(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ balances }) => {
this.accountBalances = balances;
this.changeDetectorRef.markForCheck();
});
}
private fetchPortfolioPerformance() {
this.isLoadingChart = true;
this.dataService
.fetchPortfolioPerformance({
filters: [
{
id: this.data.accountId,
type: 'ACCOUNT'
}
],
range: 'max',
withExcludedAccounts: true
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => {
this.historicalDataItems = chart.map(
({ date, netWorth, netWorthInPercentage }) => {
return {
date,
value:
this.hasImpersonationId || this.user.settings.isRestrictedView
? netWorthInPercentage
: netWorth
};
}
);
this.isLoadingChart = false;
this.changeDetectorRef.markForCheck();
});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -31,7 +31,7 @@
></gf-investment-chart> ></gf-investment-chart>
</div> </div>
<div class="row"> <div class="mb-3 row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
i18n i18n
@ -64,11 +64,15 @@
</div> </div>
</div> </div>
<div class="row" [ngClass]="{ 'd-none': !orders?.length }"> <mat-tab-group
<div class="col mb-3"> animationDuration="0"
<div class="h5 mb-0" i18n>Activities</div> [mat-stretch-tabs]="false"
[ngClass]="{ 'd-none': isLoadingActivities }"
>
<mat-tab>
<ng-template i18n mat-tab-label>Activities</ng-template>
<gf-activities-table <gf-activities-table
[activities]="orders" [activities]="activities"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
@ -79,8 +83,18 @@
[showActions]="false" [showActions]="false"
(export)="onExport()" (export)="onExport()"
></gf-activities-table> ></gf-activities-table>
</div> </mat-tab>
</div> <mat-tab>
<ng-template i18n mat-tab-label>Cash Balances</ng-template>
<gf-account-balances
[accountBalances]="accountBalances"
[accountId]="data.accountId"
[locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccountBalance && !user.settings.isRestrictedView"
(accountBalanceDeleted)="onDeleteAccountBalance($event)"
></gf-account-balances>
</mat-tab>
</mat-tab-group>
</div> </div>
</div> </div>

View File

@ -2,9 +2,11 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatTabsModule } from '@angular/material/tabs';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module'; import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module'; import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -15,6 +17,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
declarations: [AccountDetailDialog], declarations: [AccountDetailDialog],
imports: [ imports: [
CommonModule, CommonModule,
GfAccountBalancesModule,
GfActivitiesTableModule, GfActivitiesTableModule,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,
@ -22,6 +25,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatTabsModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -241,7 +241,7 @@
></td> ></td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th> <th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell> <td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button <button
@ -254,16 +254,20 @@
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateAccount(element)"> <button mat-menu-item (click)="onUpdateAccount(element)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon> <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span> <span i18n>Edit</span>
</span>
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.isDefault || element.transactionCount > 0" [disabled]="element.isDefault || element.transactionCount > 0"
(click)="onDeleteAccount(element.id)" (click)="onDeleteAccount(element.id)"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span> <span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -120,7 +120,7 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell> <th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"

View File

@ -9,7 +9,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort, Sort } from '@angular/material/sort'; import { MatSort, Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
@ -19,7 +19,8 @@ import { getDateFormatString } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces'; import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { AssetSubClass, DataSource, Prisma } from '@prisma/client'; import { AssetSubClass, DataSource } from '@prisma/client';
import { isUUID } from 'class-validator';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
@ -83,7 +84,7 @@ export class AdminMarketDataComponent
public defaultDateFormat: string; public defaultDateFormat: string;
public deviceType: string; public deviceType: string;
public displayedColumns = [ public displayedColumns = [
'symbol', 'nameWithSymbol',
'dataSource', 'dataSource',
'assetClass', 'assetClass',
'assetSubClass', 'assetSubClass',
@ -97,6 +98,7 @@ export class AdminMarketDataComponent
]; ];
public filters$ = new Subject<Filter[]>(); public filters$ = new Subject<Filter[]>();
public isLoading = false; public isLoading = false;
public isUUID = isUUID;
public placeholder = ''; public placeholder = '';
public pageSize = DEFAULT_PAGE_SIZE; public pageSize = DEFAULT_PAGE_SIZE;
public totalItems = 0; public totalItems = 0;
@ -158,7 +160,7 @@ export class AdminMarketDataComponent
this.loadData({ this.loadData({
sortColumn, sortColumn,
sortDirection: <Prisma.SortOrder>direction, sortDirection: direction,
pageIndex: this.paginator.pageIndex pageIndex: this.paginator.pageIndex
}); });
} }
@ -173,7 +175,7 @@ export class AdminMarketDataComponent
this.loadData({ this.loadData({
pageIndex: page.pageIndex, pageIndex: page.pageIndex,
sortColumn: this.sort.active, sortColumn: this.sort.active,
sortDirection: <Prisma.SortOrder>this.sort.direction sortDirection: this.sort.direction
}); });
} }
@ -260,7 +262,7 @@ export class AdminMarketDataComponent
}: { }: {
pageIndex: number; pageIndex: number;
sortColumn?: string; sortColumn?: string;
sortDirection?: Prisma.SortOrder; sortDirection?: SortDirection;
} = { pageIndex: 0 } } = { pageIndex: 0 }
) { ) {
this.isLoading = true; this.isLoading = true;

View File

@ -28,6 +28,24 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="nameWithSymbol">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="symbol"
>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
<div class="text-truncate">{{ element.name }}</div>
<div *ngIf="!isUUID(element.symbol)">
<small class="text-muted">{{ element.symbol | gfSymbol }}</small>
</div>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="dataSource"> <ng-container matColumnDef="dataSource">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<ng-container i18n>Data Source</ng-container> <ng-container i18n>Data Source</ng-container>
@ -111,7 +129,7 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell> <th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
@ -143,12 +161,24 @@
<ion-icon name="ellipsis-horizontal"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before"> <mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button
mat-menu-item
(click)="onOpenAssetProfileDialog({ dataSource: element.dataSource, symbol: element.symbol })"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</span>
</button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.activitiesCount !== 0" [disabled]="element.activitiesCount !== 0"
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})" (click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
> >
<ng-container i18n>Delete</ng-container> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -6,6 +6,7 @@ import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort'; import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module'; import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -20,6 +21,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
GfActivitiesFilterModule, GfActivitiesFilterModule,
GfAssetProfileDialogModule, GfAssetProfileDialogModule,
GfCreateAssetProfileDialogModule, GfCreateAssetProfileDialogModule,
GfSymbolModule,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatPaginatorModule, MatPaginatorModule,

View File

@ -8,6 +8,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormControl, Validators } from '@angular/forms'; import { FormBuilder, FormControl, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
@ -25,8 +26,8 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { parse as csvToJson } from 'papaparse'; import { parse as csvToJson } from 'papaparse';
import { Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
import { AssetProfileDialogParams } from './interfaces/interfaces'; import { AssetProfileDialogParams } from './interfaces/interfaces';
@ -50,6 +51,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetClass: new FormControl<AssetClass>(undefined), assetClass: new FormControl<AssetClass>(undefined),
assetSubClass: new FormControl<AssetSubClass>(undefined), assetSubClass: new FormControl<AssetSubClass>(undefined),
comment: '', comment: '',
historicalData: this.formBuilder.group({
csvString: ''
}),
name: ['', Validators.required], name: ['', Validators.required],
scraperConfiguration: '', scraperConfiguration: '',
symbolMapping: '' symbolMapping: ''
@ -59,7 +63,6 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public countries: { public countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public historicalDataAsCsvString: string;
public isBenchmark = false; public isBenchmark = false;
public marketDataDetails: MarketData[] = []; public marketDataDetails: MarketData[] = [];
public sectors: { public sectors: {
@ -78,7 +81,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams, @Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<AssetProfileDialog>, public dialogRef: MatDialogRef<AssetProfileDialog>,
private formBuilder: FormBuilder private formBuilder: FormBuilder,
private snackBar: MatSnackBar
) {} ) {}
public ngOnInit(): void { public ngOnInit(): void {
@ -88,9 +92,6 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
public initialize() { public initialize() {
this.historicalDataAsCsvString =
AssetProfileDialog.HISTORICAL_DATA_TEMPLATE;
this.adminService this.adminService
.fetchAdminMarketDataBySymbol({ .fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
@ -128,10 +129,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
this.assetProfileForm.setValue({ this.assetProfileForm.setValue({
name: this.assetProfile.name, assetClass: this.assetProfile.assetClass ?? null,
assetClass: this.assetProfile.assetClass, assetSubClass: this.assetProfile.assetSubClass ?? null,
assetSubClass: this.assetProfile.assetSubClass,
comment: this.assetProfile?.comment ?? '', comment: this.assetProfile?.comment ?? '',
historicalData: {
csvString: AssetProfileDialog.HISTORICAL_DATA_TEMPLATE
},
name: this.assetProfile.name ?? this.assetProfile.symbol,
scraperConfiguration: JSON.stringify( scraperConfiguration: JSON.stringify(
this.assetProfile?.scraperConfiguration ?? {} this.assetProfile?.scraperConfiguration ?? {}
), ),
@ -163,11 +167,16 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
public onImportHistoricalData() { public onImportHistoricalData() {
const marketData = csvToJson(this.historicalDataAsCsvString, { try {
const marketData = csvToJson(
this.assetProfileForm.controls['historicalData'].controls['csvString']
.value,
{
dynamicTyping: true, dynamicTyping: true,
header: true, header: true,
skipEmptyLines: true skipEmptyLines: true
}).data; }
).data;
this.adminService this.adminService
.postMarketData({ .postMarketData({
@ -179,10 +188,25 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}, },
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(
catchError(({ error, message }) => {
this.snackBar.open(`${error}: ${message[0]}`, undefined, {
duration: 3000
});
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => { .subscribe(() => {
this.initialize(); this.initialize();
}); });
} catch {
this.snackBar.open(
$localize`Oops! Could not parse historical data.`,
undefined,
{ duration: 3000 }
);
}
} }
public onMarketDataChanged(withRefresh: boolean = false) { public onMarketDataChanged(withRefresh: boolean = false) {

View File

@ -52,7 +52,7 @@
(marketDataChanged)="onMarketDataChanged($event)" (marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail> ></gf-admin-market-data-detail>
<div class="mt-3"> <div class="mt-3" formGroupName="historicalData">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label> <mat-label>
<ng-container i18n>Historical Data</ng-container> (CSV) <ng-container i18n>Historical Data</ng-container> (CSV)
@ -60,11 +60,9 @@
<textarea <textarea
cdkAutosizeMaxRows="5" cdkAutosizeMaxRows="5"
cdkTextareaAutosize cdkTextareaAutosize
formControlName="csvString"
matInput matInput
placeholder="e.g. 20230601;1.61"
type="text" type="text"
[ngModelOptions]="{standalone: true}"
[(ngModel)]="historicalDataAsCsvString"
(keyup.enter)="$event.stopPropagation()" (keyup.enter)="$event.stopPropagation()"
></textarea> ></textarea>
</mat-form-field> </mat-form-field>
@ -75,6 +73,7 @@
color="accent" color="accent"
mat-flat-button mat-flat-button
type="button" type="button"
[disabled]="!assetProfileForm.controls['historicalData']?.controls['csvString'].touched || assetProfileForm.controls['historicalData']?.controls['csvString']?.value === ''"
(click)="onImportHistoricalData()" (click)="onImportHistoricalData()"
> >
<ng-container i18n>Import</ng-container> <ng-container i18n>Import</ng-container>
@ -179,13 +178,13 @@
</ng-container> </ng-container>
</div> </div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3"> <div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Name</mat-label> <mat-label i18n>Name</mat-label>
<input formControlName="name" matInput type="text" /> <input formControlName="name" matInput type="text" />
</mat-form-field> </mat-form-field>
</div> </div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3"> <div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Class</mat-label> <mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass"> <mat-select formControlName="assetClass">
<mat-option [value]="null"></mat-option> <mat-option [value]="null"></mat-option>
@ -198,7 +197,7 @@
</mat-form-field> </mat-form-field>
</div> </div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3"> <div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Sub Class</mat-label> <mat-label i18n>Asset Sub Class</mat-label>
<mat-select formControlName="assetSubClass"> <mat-select formControlName="assetSubClass">
<mat-option [value]="null"></mat-option> <mat-option [value]="null"></mat-option>

View File

@ -8,6 +8,7 @@ import { MatDialogModule } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module'; import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module'; import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -28,6 +29,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
MatInputModule, MatInputModule,
MatMenuModule, MatMenuModule,
MatSelectModule, MatSelectModule,
MatSnackBarModule,
ReactiveFormsModule, ReactiveFormsModule,
TextFieldModule TextFieldModule
], ],

View File

@ -12,7 +12,12 @@ import {
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
ghostfolioPrefix ghostfolioPrefix
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces'; import {
Coupon,
InfoItem,
SystemMessage,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import {
differenceInSeconds, differenceInSeconds,
@ -39,6 +44,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public hasPermissionToToggleReadOnlyMode: boolean; public hasPermissionToToggleReadOnlyMode: boolean;
public info: InfoItem; public info: InfoItem;
public permissions = permissions; public permissions = permissions;
public systemMessage: SystemMessage;
public transactionCount: number; public transactionCount: number;
public userCount: number; public userCount: number;
public user: User; public user: User;
@ -149,8 +155,14 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
public onDeleteSystemMessage() { public onDeleteSystemMessage() {
const confirmation = confirm(
$localize`Do you really want to delete this system message?`
);
if (confirmation === true) {
this.putAdminSetting({ key: PROPERTY_SYSTEM_MESSAGE, value: undefined }); this.putAdminSetting({ key: PROPERTY_SYSTEM_MESSAGE, value: undefined });
} }
}
public onFlushCache() { public onFlushCache() {
const confirmation = confirm( const confirmation = confirm(
@ -184,12 +196,21 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
public onSetSystemMessage() { public onSetSystemMessage() {
const systemMessage = prompt($localize`Please set your system message:`); const systemMessage = prompt(
$localize`Please set your system message:`,
JSON.stringify(
this.systemMessage ??
<SystemMessage>{
message: '⚒️ Scheduled maintenance in progress...',
targetGroups: ['Basic', 'Premium']
}
)
);
if (systemMessage) { if (systemMessage) {
this.putAdminSetting({ this.putAdminSetting({
key: PROPERTY_SYSTEM_MESSAGE, key: PROPERTY_SYSTEM_MESSAGE,
value: systemMessage value: JSON.parse(systemMessage)
}); });
} }
} }
@ -208,6 +229,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? []; this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[]; this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
this.exchangeRates = exchangeRates; this.exchangeRates = exchangeRates;
this.systemMessage = settings[
PROPERTY_SYSTEM_MESSAGE
] as SystemMessage;
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
this.userCount = userCount; this.userCount = userCount;
this.version = version; this.version = version;

View File

@ -38,7 +38,7 @@
<div class="w-50"> <div class="w-50">
<table> <table>
<tr *ngFor="let exchangeRate of exchangeRates"> <tr *ngFor="let exchangeRate of exchangeRates">
<td class="d-flex"> <td>
<gf-value <gf-value
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]="1" [value]="1"
@ -46,8 +46,9 @@
</td> </td>
<td class="pl-1">{{ exchangeRate.label1 }}</td> <td class="pl-1">{{ exchangeRate.label1 }}</td>
<td class="px-1">=</td> <td class="px-1">=</td>
<td class="d-flex justify-content-end"> <td align="right">
<gf-value <gf-value
class="d-inline-block"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[precision]="4" [precision]="4"
[value]="exchangeRate.value" [value]="exchangeRate.value"
@ -56,13 +57,49 @@
<td class="pl-1">{{ exchangeRate.label2 }}</td> <td class="pl-1">{{ exchangeRate.label2 }}</td>
<td> <td>
<button <button
*ngIf="customCurrencies.includes(exchangeRate.label2)" class="mx-1 no-min-width px-2"
class="h-100 mx-1 no-min-width px-2"
mat-button mat-button
[matMenuTriggerFor]="exchangeRateActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu
#exchangeRateActionsMenu="matMenu"
class="h-100 mx-1 no-min-width px-2"
xPosition="before"
>
<a
mat-menu-item
[queryParams]="{
assetProfileDialog: true,
dataSource: exchangeRate.dataSource,
symbol: exchangeRate.symbol
}"
[routerLink]="['/admin', 'market-data']"
>
<span class="align-items-center d-flex">
<ion-icon
class="mr-2"
name="create-outline"
></ion-icon>
<span i18n>Edit</span>
</span>
</a>
<button
*ngIf="customCurrencies.includes(exchangeRate.label2)"
mat-menu-item
(click)="onDeleteCurrency(exchangeRate.label2)" (click)="onDeleteCurrency(exchangeRate.label2)"
> >
<ion-icon name="trash-outline"></ion-icon> <span class="align-items-center d-flex">
<ion-icon
class="mr-2"
name="trash-outline"
></ion-icon>
<span i18n>Delete</span>
</span>
</button> </button>
</mat-menu>
</td> </td>
</tr> </tr>
</table> </table>
@ -103,8 +140,8 @@
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3"> <div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
<div class="w-50" i18n>System Message</div> <div class="w-50" i18n>System Message</div>
<div class="w-50"> <div class="w-50">
<div *ngIf="info?.systemMessage"> <div *ngIf="systemMessage" class="align-items-center d-flex">
<span>{{ info.systemMessage }}</span> <div class="text-truncate">{{ systemMessage | json }}</div>
<button <button
class="h-100 mx-1 no-min-width px-2" class="h-100 mx-1 no-min-width px-2"
mat-button mat-button
@ -115,6 +152,7 @@
</div> </div>
<button <button
*ngIf="!info?.systemMessage" *ngIf="!info?.systemMessage"
class="mt-2"
color="accent" color="accent"
mat-flat-button mat-flat-button
(click)="onSetSystemMessage()" (click)="onSetSystemMessage()"
@ -136,17 +174,34 @@
<table> <table>
<tr *ngFor="let coupon of coupons"> <tr *ngFor="let coupon of coupons">
<td class="text-monospace">{{ coupon.code }}</td> <td class="text-monospace">{{ coupon.code }}</td>
<td class="d-flex justify-content-end pl-2"> <td class="pl-2 text-right">{{ coupon.duration }}</td>
{{ coupon.duration }}
</td>
<td> <td>
<button <button
class="h-100 mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
[matMenuTriggerFor]="couponActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu
#couponActionsMenu="matMenu"
class="h-100 mx-1 no-min-width px-2"
xPosition="before"
>
<button
mat-menu-item
(click)="onDeleteCoupon(coupon.code)" (click)="onDeleteCoupon(coupon.code)"
> >
<ion-icon name="trash-outline"></ion-icon> <span class="align-items-center d-flex">
<ion-icon
class="mr-2"
name="trash-outline"
></ion-icon>
<span i18n>Delete</span>
</span>
</button> </button>
</mat-menu>
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -3,8 +3,10 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { RouterModule } from '@angular/router';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -19,9 +21,11 @@ import { AdminOverviewComponent } from './admin-overview.component';
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
MatMenuModule,
MatSelectModule, MatSelectModule,
MatSlideToggleModule, MatSlideToggleModule,
ReactiveFormsModule ReactiveFormsModule,
RouterModule
], ],
providers: [CacheService], providers: [CacheService],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -68,7 +68,7 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions" stickyEnd>
<th <th
*matHeaderCellDef *matHeaderCellDef
class="px-1 text-center" class="px-1 text-center"
@ -86,12 +86,16 @@
</button> </button>
<mat-menu #platformMenu="matMenu" xPosition="before"> <mat-menu #platformMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdatePlatform(element)"> <button mat-menu-item (click)="onUpdatePlatform(element)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon> <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span> <span i18n>Edit</span>
</span>
</button> </button>
<button mat-menu-item (click)="onDeletePlatform(element.id)"> <button mat-menu-item (click)="onDeletePlatform(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span> <span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -48,7 +48,7 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions" stickyEnd>
<th <th
*matHeaderCellDef *matHeaderCellDef
class="px-1 text-center" class="px-1 text-center"
@ -66,12 +66,16 @@
</button> </button>
<mat-menu #tagMenu="matMenu" xPosition="before"> <mat-menu #tagMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateTag(element)"> <button mat-menu-item (click)="onUpdateTag(element)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon> <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span> <span i18n>Edit</span>
</span>
</button> </button>
<button mat-menu-item (click)="onDeleteTag(element.id)"> <button mat-menu-item (click)="onDeleteTag(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span> <span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -178,7 +178,7 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions" stickyEnd>
<th <th
*matHeaderCellDef *matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2" class="mat-mdc-header-cell px-1 py-2"
@ -203,16 +203,20 @@
mat-menu-item mat-menu-item
(click)="onImpersonateUser(element.id)" (click)="onImpersonateUser(element.id)"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="contract-outline"></ion-icon> <ion-icon class="mr-2" name="contract-outline"></ion-icon>
<span i18n>Impersonate User</span> <span i18n>Impersonate User</span>
</span>
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.id === user?.id" [disabled]="element.id === user?.id"
(click)="onDeleteUser(element.id)" (click)="onDeleteUser(element.id)"
> >
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete User</span> <span i18n>Delete User</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -6,6 +6,7 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper'; import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
import { translate } from '@ghostfolio/ui/i18n';
@Component({ @Component({
selector: 'gf-fear-and-greed-index', selector: 'gf-fear-and-greed-index',
@ -24,9 +25,9 @@ export class FearAndGreedIndexComponent implements OnChanges, OnInit {
public ngOnInit() {} public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
const { emoji, text } = resolveFearAndGreedIndex(this.fearAndGreedIndex); const { emoji, key } = resolveFearAndGreedIndex(this.fearAndGreedIndex);
this.fearAndGreedIndexEmoji = emoji; this.fearAndGreedIndexEmoji = emoji;
this.fearAndGreedIndexText = text; this.fearAndGreedIndexText = translate(key);
} }
} }

View File

@ -15,7 +15,7 @@ import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { import {
STAY_SIGNED_IN, KEY_STAY_SIGNED_IN,
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
@ -196,7 +196,7 @@ export class HeaderComponent implements OnChanges {
public setToken(aToken: string) { public setToken(aToken: string) {
this.tokenStorageService.saveToken( this.tokenStorageService.saveToken(
aToken, aToken,
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true' this.settingsStorageService.getSetting(KEY_STAY_SIGNED_IN) === 'true'
); );
this.router.navigate(['/']); this.router.navigate(['/']);

View File

@ -31,6 +31,7 @@
<gf-benchmark <gf-benchmark
[benchmarks]="benchmarks" [benchmarks]="benchmarks"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[user]="user"
></gf-benchmark> ></gf-benchmark>
<ngx-skeleton-loader <ngx-skeleton-loader
*ngIf="isLoading" *ngIf="isLoading"

View File

@ -33,6 +33,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public isLoadingPerformance = true; public isLoadingPerformance = true;
public performance: PortfolioPerformance; public performance: PortfolioPerformance;
public showDetails = false; public showDetails = false;
public unit: string;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -76,6 +77,8 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
!this.hasImpersonationId && !this.hasImpersonationId &&
!this.user.settings.isRestrictedView && !this.user.settings.isRestrictedView &&
this.user.settings.viewMode !== 'ZEN'; this.user.settings.viewMode !== 'ZEN';
this.unit = this.showDetails ? this.user.settings.baseCurrency : '%';
} }
public onChangeDateRange(dateRange: DateRange) { public onChangeDateRange(dateRange: DateRange) {

View File

@ -86,7 +86,6 @@
<div class="col"> <div class="col">
<gf-portfolio-performance <gf-portfolio-performance
class="pb-4" class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[errors]="errors" [errors]="errors"
[isAllTimeHigh]="isAllTimeHigh" [isAllTimeHigh]="isAllTimeHigh"
@ -95,6 +94,7 @@
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[performance]="performance" [performance]="performance"
[showDetails]="showDetails" [showDetails]="showDetails"
[unit]="unit"
></gf-portfolio-performance> ></gf-portfolio-performance>
<div *ngIf="showDetails" class="text-center"> <div *ngIf="showDetails" class="text-center">
<gf-toggle <gf-toggle

View File

@ -4,7 +4,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service'; import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
import { import {
STAY_SIGNED_IN, KEY_STAY_SIGNED_IN,
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
@ -31,7 +31,7 @@ export class LoginWithAccessTokenDialog {
public onChangeStaySignedIn(aValue: MatCheckboxChange) { public onChangeStaySignedIn(aValue: MatCheckboxChange) {
this.settingsStorageService.setSetting( this.settingsStorageService.setSetting(
STAY_SIGNED_IN, KEY_STAY_SIGNED_IN,
aValue.checked?.toString() aValue.checked?.toString()
); );
} }

View File

@ -35,19 +35,9 @@
<span #value id="value"></span> <span #value id="value"></span>
</div> </div>
<div class="flex-grow-1 px-1"> <div class="flex-grow-1 px-1">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '1.3rem',
width: '2.5rem'
}"
></ngx-skeleton-loader>
<div *ngIf="!isLoading">
{{ unit }} {{ unit }}
</div> </div>
</div> </div>
</div>
<div *ngIf="showDetails" class="row"> <div *ngIf="showDetails" class="row">
<div class="d-flex col justify-content-end"> <div class="d-flex col justify-content-end">
<gf-value <gf-value

View File

@ -25,7 +25,6 @@ import { isNumber } from 'lodash';
styleUrls: ['./portfolio-performance.component.scss'] styleUrls: ['./portfolio-performance.component.scss']
}) })
export class PortfolioPerformanceComponent implements OnChanges, OnInit { export class PortfolioPerformanceComponent implements OnChanges, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() errors: ResponseError['errors']; @Input() errors: ResponseError['errors'];
@Input() isAllTimeHigh: boolean; @Input() isAllTimeHigh: boolean;
@ -34,11 +33,10 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
@Input() locale: string; @Input() locale: string;
@Input() performance: PortfolioPerformance; @Input() performance: PortfolioPerformance;
@Input() showDetails: boolean; @Input() showDetails: boolean;
@Input() unit: string;
@ViewChild('value') value: ElementRef; @ViewChild('value') value: ElementRef;
public unit: string;
public constructor() {} public constructor() {}
public ngOnInit() {} public ngOnInit() {}
@ -50,8 +48,6 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
} }
} else { } else {
if (isNumber(this.performance?.currentValue)) { if (isNumber(this.performance?.currentValue)) {
this.unit = this.baseCurrency;
new CountUp('value', this.performance?.currentValue, { new CountUp('value', this.performance?.currentValue, {
decimal: getNumberFormatDecimal(this.locale), decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: decimalPlaces:
@ -63,8 +59,6 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
separator: getNumberFormatGroup(this.locale) separator: getNumberFormatGroup(this.locale)
}).start(); }).start();
} else if (this.performance?.currentValue === null) { } else if (this.performance?.currentValue === null) {
this.unit = '%';
new CountUp( new CountUp(
'value', 'value',
this.performance?.currentNetPerformancePercent * 100, this.performance?.currentNetPerformancePercent * 100,

View File

@ -13,6 +13,7 @@
<div class="d-flex mr-2"> <div class="d-flex mr-2">
<gf-trend-indicator <gf-trend-indicator
class="d-flex" class="d-flex"
size="large"
[isLoading]="isLoading" [isLoading]="isLoading"
[marketState]="position?.marketState" [marketState]="position?.marketState"
[range]="range" [range]="range"

View File

@ -1,5 +1,6 @@
:host { :host {
display: block; align-items: center;
display: flex;
img { img {
border-radius: 0.2rem; border-radius: 0.2rem;

View File

@ -1,6 +1,5 @@
<div class="container"> <div class="align-items-center container d-flex h-100 justify-content-center">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Membership</h1> <div class="row w-100">
<div class="row">
<div class="col"> <div class="col">
<div class="align-items-center d-flex flex-column"> <div class="align-items-center d-flex flex-column">
<gf-membership-card <gf-membership-card
@ -34,7 +33,7 @@
>&nbsp;<span i18n>per year</span> >&nbsp;<span i18n>per year</span>
</div> </div>
</ng-container> </ng-container>
<div class="align-items-center d-flex justfiy-content-center mt-4"> <div class="align-items-center d-flex justify-content-center mt-4">
<a <a
*ngIf="!user?.subscription?.expiresAt" *ngIf="!user?.subscription?.expiresAt"
class="mx-1" class="mx-1"

View File

@ -1,6 +1,7 @@
:host { :host {
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: block; display: block;
height: 100%;
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {

View File

@ -8,7 +8,8 @@ import {
import { MatSlideToggleChange } from '@angular/material/slide-toggle'; import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { import {
STAY_SIGNED_IN, KEY_STAY_SIGNED_IN,
KEY_TOKEN,
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -44,6 +45,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
'fr', 'fr',
'it', 'it',
'nl', 'nl',
'pl',
'pt', 'pt',
'tr' 'tr'
]; ];
@ -240,7 +242,8 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
}) })
) )
.subscribe(() => { .subscribe(() => {
this.settingsStorageService.removeSetting(STAY_SIGNED_IN); this.settingsStorageService.removeSetting(KEY_STAY_SIGNED_IN);
this.settingsStorageService.removeSetting(KEY_TOKEN);
this.update(); this.update();
}); });

View File

@ -74,6 +74,10 @@
>Nederlands (<ng-container i18n>Community</ng-container >Nederlands (<ng-container i18n>Community</ng-container
>)</mat-option >)</mat-option
> >
<mat-option value="pl"
>Polski (<ng-container i18n>Community</ng-container
>)</mat-option
>
<mat-option value="pt" <mat-option value="pt"
>Português (<ng-container i18n>Community</ng-container >Português (<ng-container i18n>Community</ng-container
>)</mat-option >)</mat-option

View File

@ -52,15 +52,15 @@
title="Join the Ghostfolio Slack community" title="Join the Ghostfolio Slack community"
>Slack</a >Slack</a
> >
community, tweet to community, post to
<a <a
href="https://twitter.com/ghostfolio_" href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter" title="Post to Ghostfolio on X (formerly Twitter)"
>@ghostfolio_</a >&#64;ghostfolio_</a
><ng-container *ngIf="user?.subscription?.type === 'Premium'" ><ng-container *ngIf="user?.subscription?.type === 'Premium'"
>, send an e-mail to >, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail" <a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a >hi&#64;ghostfol.io</a
></ng-container ></ng-container
> >
or start a discussion at or start a discussion at
@ -70,14 +70,14 @@
>GitHub</a >GitHub</a
>. >.
</p> </p>
<p class="text-center"> <p class="align-items-center d-flex justify-content-center">
<a <a
class="mx-2" class="mx-2"
href="https://twitter.com/ghostfolio_" href="https://twitter.com/ghostfolio_"
mat-icon-button mat-icon-button
title="Follow Ghostfolio on Twitter" title="Follow Ghostfolio on X (formerly Twitter)"
> >
<ion-icon name="logo-twitter"></ion-icon> <span class="line-height-1 text-center w-100">𝕏</span>
</a> </a>
<a <a
*ngIf="user?.subscription?.type === 'Premium'" *ngIf="user?.subscription?.type === 'Premium'"

View File

@ -10,9 +10,17 @@
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>From</mat-label> <mat-label i18n>From</mat-label>
<mat-select formControlName="fromAccount"> <mat-select formControlName="fromAccount">
<mat-option *ngFor="let account of accounts" [value]="account.id" <mat-option *ngFor="let account of accounts" [value]="account.id">
>{{ account.name }}</mat-option <div class="d-flex">
> <gf-symbol-icon
*ngIf="account.Platform?.url"
class="mr-1"
[tooltip]="account.Platform?.name"
[url]="account.Platform?.url"
></gf-symbol-icon
><span>{{ account.name }}</span>
</div>
</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
@ -20,9 +28,17 @@
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>To</mat-label> <mat-label i18n>To</mat-label>
<mat-select formControlName="toAccount"> <mat-select formControlName="toAccount">
<mat-option *ngFor="let account of accounts" [value]="account.id" <mat-option *ngFor="let account of accounts" [value]="account.id">
>{{ account.name }}</mat-option <div class="d-flex">
> <gf-symbol-icon
*ngIf="account.Platform?.url"
class="mr-1"
[tooltip]="account.Platform?.name"
[url]="account.Platform?.url"
></gf-symbol-icon
><span>{{ account.name }}</span>
</div>
</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>

View File

@ -6,6 +6,7 @@ import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { TransferBalanceDialog } from './transfer-balance-dialog.component'; import { TransferBalanceDialog } from './transfer-balance-dialog.component';
@ -13,6 +14,7 @@ import { TransferBalanceDialog } from './transfer-balance-dialog.component';
declarations: [TransferBalanceDialog], declarations: [TransferBalanceDialog],
imports: [ imports: [
CommonModule, CommonModule,
GfSymbolIconModule,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,

View File

@ -1,7 +1,7 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { import {
STAY_SIGNED_IN, KEY_STAY_SIGNED_IN,
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
@ -31,7 +31,7 @@ export class AuthPageComponent implements OnDestroy, OnInit {
this.tokenStorageService.saveToken( this.tokenStorageService.saveToken(
jwt, jwt,
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true' this.settingsStorageService.getSetting(KEY_STAY_SIGNED_IN) === 'true'
); );
this.router.navigate(['/']); this.router.navigate(['/']);

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