Compare commits

..

79 Commits

Author SHA1 Message Date
f438458687 Release 1.235.0 (#1708) 2023-02-16 17:20:22 +01:00
7125b12631 Feature/improve styles on about page (#1707)
* Improve styles

* Update changelog
2023-02-16 17:17:30 +01:00
0cbf275a2e Feature/eliminate ghostfolio scraper api service (#1706)
* Eliminate GhostfolioScraperApiService

* Update changelog
2023-02-16 16:25:23 +01:00
0ec50819f5 Release 1.234.0 (#1703) 2023-02-15 10:52:02 +01:00
c9abe818bc Revert import (#1702) 2023-02-15 10:50:19 +01:00
bfa32537a8 Feature/improve usability of import activities action (#1695)
* Improve usability of import activities action

* Update changelog
2023-02-15 10:07:25 +01:00
cef15afab8 Add styling (#1701) 2023-02-15 10:01:35 +01:00
1b9587c454 Update default coupon duration (#1700) 2023-02-15 10:00:04 +01:00
de76b0d8c3 Feature/add data import and export to pricing page (#1697)
* Add data import and export

* Update changelog
2023-02-15 09:52:09 +01:00
e62989c981 Feature/copy logic of ghostfolio scraper api service to manual service (#1691)
* Copy logic of GhostfolioScraperApiService to ManualService

* Update changelog
2023-02-15 09:50:31 +01:00
d6b71e6314 Bugfix/fix links in subscription interstitial dialog (#1696)
* Fix links

* Update changelog
2023-02-14 18:25:12 +01:00
8c59bfd6d7 Feature/upgrade prisma to version 4.10.1 (#1688)
* Upgrade prisma to version 4.10.1

* Update changelog
2023-02-14 11:35:04 +01:00
f32df73256 Feature/migrate pages to angular material 15 (#1689)
* Migrate to Angular Material 15

* Update changelog
2023-02-14 10:04:22 +01:00
9d03a8002c Feature/improve content of faq and landing page (#1687)
* Conditionally show content

* Update changelog
2023-02-13 09:41:25 +01:00
3c36ca29af Feature/upgrade ionicons to version 6.1.2 (#1676)
* Upgrade ionicons to version 6.1.2

* Update changelog
2023-02-12 10:08:04 +01:00
efed7e3c2b Modify default exposed port (#1681)
* Modify default exposed port

* Update changelog
2023-02-11 10:37:44 +01:00
b09d3cea95 Fix landing page by setting a default value for countriesOfSubscribers
* Set default value for countriesOfSubscribers

* Update changelog
2023-02-11 09:57:27 +01:00
eabd2f3934 Add url (#1683) 2023-02-11 09:55:03 +01:00
cc184c2827 Feature/upgrade prettier to version 2.8.4 (#1675)
* Upgrade prettier to version 2.8.4

* Update changelog
2023-02-10 09:27:26 +01:00
436f791fa4 Feature/upgrade chart.js to version 4.2.0 (#1567)
* Upgrade chart.js to version 4.2.0

* Update changelog
2023-02-09 21:22:55 +01:00
e935a57dec Release 1.233.0 (#1678) 2023-02-09 20:30:53 +01:00
203909d917 Feature/upgrade eslint dependencies (#1674)
* Upgrade eslint dependencies

* Update changelog
2023-02-09 10:22:50 +01:00
eed4f57f30 Clean up (#1669) 2023-02-09 09:59:29 +01:00
7878036bac Feature/remove google play badge from landing page (#1672)
* Remove Google Play badge

* Update changelog
2023-02-08 14:17:49 +01:00
75d140b436 Harmonize file name (#1662) 2023-02-07 08:44:22 +01:00
a79f31b006 Feature/add accounts import export (#1635)
* Add accounts to activities export

* Add logic for importing accounts

* Update changelog
2023-02-06 21:59:59 +01:00
45cfd61dbb Feature/improve styling in admin control panel (#1665)
* Improve styling

* Update changelog
2023-02-06 11:35:56 +01:00
7fcfca952e Release 1.232.0 (#1664) 2023-02-05 19:46:18 +01:00
279f16cc67 Feature/extract locales 20230205 (#1663)
* Extract locales

* Update changelog
2023-02-05 19:44:33 +01:00
e7b1d8a5d3 Feature/upgrade ngx markdown to version 15.1.0 (#1657)
* Upgrade ngx-markdown to version 15.1.0

* Update changelog
2023-02-05 18:57:35 +01:00
1b2f8e5586 Feature/extend analytics by country (#1661)
* Extend analytics by country

* Fix Upgrade Plan button of subscription interstitial

* Update changelog
2023-02-05 18:57:12 +01:00
e4468252c6 Feature/upgrade ng extract i18n merge to version 2.5.0 (#1656)
* Upgrade ng-extract-i18n-merge to version 2.5.0

* Update changelog
2023-02-05 11:44:06 +01:00
ad3ebd42bb Feature/migrate mat suffix to angular material 15 (#1655)
* Migrate matSuffix to @angular/material 15

* Update changelog
2023-02-05 09:49:37 +01:00
55b03733f4 Release 1.231.0 (#1654) 2023-02-04 11:28:48 +01:00
0000317041 Upgrade Nx and Angular (#1646)
* Upgrade Nx and Angular

* Update changelog

* Feature/eliminate angular material css vars (#1648)

* Eliminate angular-material-css-vars

* Update changelog
2023-02-04 11:26:06 +01:00
e5f2a3865d Update types of node and papaparse (#1650) 2023-02-03 13:27:59 +01:00
c61561664f Relax validation for REDIS_HOST (#1652)
* Relax validation for REDIS_HOST

* Update changelog
2023-02-02 13:39:13 +01:00
a7d8a63ab8 Remove year (#1649) 2023-02-01 08:58:17 +01:00
5c51c1e825 Reorder features (#1644) 2023-01-30 20:22:03 +01:00
3a67bf9bb4 Add /de/pricing and /de/blog (#1645) 2023-01-30 20:21:37 +01:00
f7597c213d Feature/add dividend and fees to position detail dialog (#1643)
* Add dividend and fees to position detail dialog

* Update changelog
2023-01-30 20:21:16 +01:00
2e7f46ad78 Feature/allow account for activity of type item (#1641)
* Support linking wealth items to account

* Update changelog
2023-01-30 20:00:07 +01:00
cfffb99f52 Feature/extract locales 20230129 (#1642)
* Improve translations

* Update changelog
2023-01-30 19:45:27 +01:00
69ac3408f1 Release 1.230.0 (#1639) 2023-01-29 10:00:28 +01:00
e1806b4bd8 Feature/add sourceforge logo to landing page (#1638)
* Add SourceForge

* Update changelog
2023-01-29 09:58:53 +01:00
6aae0cc1e4 Bugfix/fix issue with value in value redaction interceptor (#1627)
* Fix issue with value in value redaction interceptor

* Fix format of world map

* Update changelog
2023-01-29 09:58:05 +01:00
5d8a50a80d Feature/add interstitial for subscription (#1637)
* Add interstitial

* Improve pricing page

* Update changelog
2023-01-28 09:42:15 +01:00
662231e830 Feature/upgrade prisma to version 4.9.0 (#1630)
* Upgrade prisma to version 4.9.0

* Update changelog
2023-01-28 09:41:33 +01:00
4d84459b5b Clean up imports (#1632) 2023-01-27 08:49:31 +01:00
efba7429c1 Bugfix/fix click of unknown accounts (#1629)
* Check for unknown key

* Update changelog
2023-01-25 12:00:52 +01:00
9cae5a3e79 Feature/improve sackgeld.com blog post (#1628)
* Improve blog post and add quote

* Update changelog
2023-01-24 08:11:21 +01:00
c2ed0a436f Downgrade to Node.js 16 (for development) (#1633) 2023-01-23 21:52:46 +01:00
8486c02575 Update Node to version 18.x (#1595)
* Update Node to version 18.x

* Add .nvmrc

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-01-23 08:28:19 +01:00
5122ef3456 Release 1.229.0 (#1626) 2023-01-21 16:10:04 +01:00
579b86665e Feature/add sackgeld.com blog post (#1625)
* Add blog post: Ghostfolio auf Sackgeld.com vorgestellt

* Update changelog
2023-01-21 16:07:32 +01:00
52b3ad6dc3 Feature/refactor value redaction interceptor (#1624)
* Reuse redactAttributes()

* Update changelog
2023-01-21 11:46:56 +01:00
bf9b60aa74 Feature/add sackgeld.com to as seen in section (#1622)
* Add Sackgeld.com

* Update changelog
2023-01-21 11:31:52 +01:00
6cd51fb044 Feature/hide irrelevant errors in client (#1623)
* Hide irrelevant errors in client

* Update changelog
2023-01-21 11:30:53 +01:00
271001f523 Feature/remove toggle on allocations page (#1620)
* Rename allocationCurrent, remove allocationInvestment

* Update changelog
2023-01-21 09:52:58 +01:00
a7e513a6d1 Bugfix/fix filtered value for emergency fund (#1619)
* Fix filtered value

* Update changelog
2023-01-20 20:51:08 +01:00
b5f256be95 Remove mail address (#1621) 2023-01-20 20:50:32 +01:00
a834ef6b4c Release 1.228.1 (#1617) 2023-01-18 22:01:59 +01:00
e5bd0d1bfa Release 1.228.0 (#1616) 2023-01-18 21:39:11 +01:00
7fa6eda45d Feature/remove emergency fund as asset class (#1615)
* Remove emergency fund as asset class

* Update changelog
2023-01-18 21:37:22 +01:00
f47e4d3b04 Clean up imports (#1613) 2023-01-17 20:10:08 +01:00
0300c6f3b7 Feature/extend hints on account page (#1609)
* Extend hints

* Update changelog
2023-01-17 20:09:50 +01:00
4865c45fd4 Feature/reduce data gathering interval to every four hours (#1611)
* Reduce execution interval

* Update changelog
2023-01-17 10:04:03 +01:00
2beceb36cf Overriding tooltip title for graphs where grouping is defined (#1605)
* Overriding tooltip title for graphs where grouping is defined

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-01-16 10:46:48 +01:00
cd64601482 Release 1.227.1 (#1608) 2023-01-14 19:12:18 +01:00
efac39eb51 Feature/extract locales 20230114 (#1607)
* Extract locales

* Update changelog
2023-01-14 19:11:14 +01:00
4da8a547ca Bugfix/fix create activity dialog caused by missing tags (#1606)
* Add guard

* Update changelog
2023-01-14 19:10:28 +01:00
9e8a9e4670 Release 1.227.0 (#1603) 2023-01-14 13:37:06 +01:00
bb99141e9c Fix group by month/year not working for YTD (#1598)
* Set time to 00:00:00 when getting current timestamp

* Update changelog
2023-01-14 13:30:51 +01:00
d147c2313f Feature/support assets in emergency fund (#1601)
* Support assets in emergency fund

* Update changelog
2023-01-14 13:13:26 +01:00
0878941c4f Feature/support translated tags (#1600)
* Support translated tags

* Update changelog
2023-01-14 12:36:30 +01:00
69a9e77820 Feature/improve logo alignment (#1599)
* Improve logo alignment

* Update changelog
2023-01-14 12:22:52 +01:00
104cca069f New Translations (#1597) 2023-01-13 16:19:48 +01:00
7ad58b1a62 Add i18n (#1594) 2023-01-13 08:58:13 +01:00
e88dbb0181 Fix wording (#1593) 2023-01-12 20:14:00 +01:00
215 changed files with 10818 additions and 4671 deletions

View File

@ -10,7 +10,7 @@ jobs:
strategy:
matrix:
node_version:
- 16
- 18
steps:
- name: Checkout code
uses: actions/checkout@v3

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v16

View File

@ -5,6 +5,165 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.235.0 - 2023-02-16
### Changed
- Improved the styles on the about page
- Eliminated the `GhostfolioScraperApiService`
## 1.234.0 - 2023-02-15
### Added
- Added the data import and export feature to the pricing page
### Changed
- Copy the logic of `GhostfolioScraperApiService` to `ManualService`
- Improved the content of the landing page
- Improved the content of the Frequently Asked Questions (FAQ) page
- Improved the usability of the _Import Activities..._ action
- Eliminated the permission `enableImport`
- Set the exposed port as an environment variable (`PORT`) in `Dockerfile`
- Migrated the style of `AboutPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `BlogPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `ChangelogPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `ResourcesPageModule` to `@angular/material` `15` (mdc)
- Upgraded `chart.js` from version `4.0.1` to `4.2.0`
- Upgraded `ionicons` from version `6.0.4` to `6.1.2`
- Upgraded `prettier` from version `2.8.1` to `2.8.4`
- Upgraded `prisma` from version `4.9.0` to `4.10.1`
### Fixed
- Fixed an issue on the landing page caused by the global heat map of subscribers
- Fixed the links in the interstitial for the subscription
### Todo
- Remove the environment variable `ENABLE_FEATURE_IMPORT`
- Rename the `dataSource` from `GHOSTFOLIO` to `MANUAL`
- Eliminate `GhostfolioScraperApiService`
## 1.233.0 - 2023-02-09
### Added
- Added support to export accounts
- Added suport to import accounts
### Changed
- Improved the styling in the admin control panel
- Removed the _Google Play_ badge from the landing page
- Upgraded `eslint` dependencies
## 1.232.0 - 2023-02-05
### Changed
- Improved the language localization for German (`de`)
- Migrated the style of `ActivitiesPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `GfCreateOrUpdateActivityDialogModule` to `@angular/material` `15` (mdc)
- Migrated the style of `GfMarketDataDetailDialogModule` to `@angular/material` `15` (mdc)
- Upgraded `ng-extract-i18n-merge` from version `2.1.2` to `2.5.0`
- Upgraded `ngx-markdown` from version `14.0.1` to `15.1.0`
### Fixed
- Fixed the `Upgrade Plan` button of the interstitial for the subscription
## 1.231.0 - 2023-02-04
### Added
- Added the dividend and fees to the position detail dialog
- Added support to link a (wealth) item to an account
### Changed
- Relaxed the validation rule of the _Redis_ host environment variable (`REDIS_HOST`)
- Improved the language localization for German (`de`)
- Eliminated `angular-material-css-vars`
- Upgraded `angular` from version `14.2.0` to `15.1.2`
- Upgraded `Nx` from version `15.0.13` to `15.6.3`
## 1.230.0 - 2023-01-29
### Added
- Added an interstitial for the subscription
- Added _SourceForge_ to the _As seen in_ section on the landing page
- Added a quote to the blog post _Ghostfolio auf Sackgeld.com vorgestellt_
### Changed
- Improved the unit format (`%`) in the global heat map component of the public page
- Improved the pricing page
- Upgraded `Node.js` from version `16` to `18` (`Dockerfile`)
- Upgraded `prisma` from version `4.8.0` to `4.9.0`
### Fixed
- Fixed the click of unknown accounts in the portfolio proportion chart component
- Fixed an issue with `value` in the value redaction interceptor for the impersonation mode
## 1.229.0 - 2023-01-21
### Added
- Added a blog post: _Ghostfolio auf Sackgeld.com vorgestellt_
- Added _Sackgeld.com_ to the _As seen in_ section on the landing page
### Changed
- Removed the toggle _Original Shares_ vs. _Current Shares_ on the allocations page
- Hid error messages related to no current investment in the client
- Refactored the value redaction interceptor for the impersonation mode
### Fixed
- Fixed the value of the active (emergency fund) filter in percentage on the allocations page
## 1.228.1 - 2023-01-18
### Added
- Extended the hints in user settings
### Changed
- Improved the date formatting in the tooltip of the dividend timeline grouped by month / year
- Improved the date formatting in the tooltip of the investment timeline grouped by month / year
- Reduced the execution interval of the data gathering to every 4 hours
- Removed emergency fund as an asset class
## 1.227.1 - 2023-01-14
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed the create or edit activity dialog
## 1.227.0 - 2023-01-14
### Added
- Added support for assets other than cash in emergency fund (affecting buying power)
- Added support for translated tags
### Changed
- Improved the logo alignment
### Fixed
- Fixed the grouping by month / year of the dividend and investment timeline
## 1.226.0 - 2023-01-11
### Added

View File

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:16-slim as builder
FROM --platform=$BUILDPLATFORM node:18-slim as builder
# Build application and add additional files
WORKDIR /ghostfolio
@ -50,12 +50,12 @@ COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings
# Image to run, copy everything needed from builder
FROM node:16-slim
FROM node:18-slim
RUN apt update && apt install -y \
openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
WORKDIR /ghostfolio/apps/api
EXPOSE 3333
EXPOSE ${PORT:-3333}
CMD [ "yarn", "start:prod" ]

View File

@ -40,7 +40,7 @@ Ghostfolio is for you if you are...
- 🧘 into minimalism
- 🧺 caring about diversifying your financial resources
- 🆓 interested in financial independence
- 🙅 saying no to spreadsheets in 2023
- 🙅 saying no to spreadsheets
- 😎 still reading this list
## Features
@ -148,7 +148,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 16+)
- [Node.js](https://nodejs.org/en/download) (version 16)
- [Yarn](https://yarnpkg.com/en/docs/install)
- A local copy of this Git repository (clone)
@ -175,7 +175,7 @@ Run `yarn start:server`
### Start Client
Run `yarn start:client`
Run `yarn start:client` and open http://localhost:4200/en in your browser
### Start _Storybook_
@ -269,7 +269,7 @@ You can get the _Bearer Token_ via `GET http://localhost:3333/api/v1/auth/anonym
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).

View File

@ -1,9 +1,5 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import {
nullifyValuesInObject,
nullifyValuesInObjects
} from '@ghostfolio/api/helper/object.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Accounts } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -22,7 +18,8 @@ import {
Param,
Post,
Put,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -39,8 +36,7 @@ export class AccountController {
private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@ -85,6 +81,7 @@ export class AccountController {
@Get()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAllAccounts(
@Headers('impersonation-id') impersonationId
): Promise<Accounts> {
@ -94,39 +91,15 @@ export class AccountController {
this.request.user.id
);
let accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations({
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'balanceInBaseCurrency',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value',
'valueInBaseCurrency'
])
};
}
return accountsWithAggregations;
return this.portfolioService.getAccountsWithAggregations({
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById(
@Headers('impersonation-id') impersonationId,
@Param('id') id: string
@ -137,35 +110,13 @@ export class AccountController {
this.request.user.id
);
let accountsWithAggregations =
const accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations({
filters: [{ id, type: 'ACCOUNT' }],
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'balanceInBaseCurrency',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value',
'valueInBaseCurrency'
])
};
}
return accountsWithAggregations.accounts[0];
}

View File

@ -17,6 +17,10 @@ export class CreateAccountDto {
@IsString()
currency: string;
@IsOptional()
@IsString()
id?: string;
@IsBoolean()
@IsOptional()
isExcluded?: boolean;

View File

@ -244,6 +244,7 @@ export class AdminService {
Analytics: {
select: {
activityCount: true,
country: true,
updatedAt: true
}
},
@ -277,6 +278,7 @@ export class AdminService {
id,
subscription,
accountCount: _count.Account || 0,
country: Analytics.country,
lastActivity: Analytics.updatedAt,
transactionCount: _count.Order || 0
};

View File

@ -1,24 +1,23 @@
import { join } from 'path';
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
import { ConfigurationModule } from '../services/configuration.module';
import { CronService } from '../services/cron.service';
import { DataGatheringModule } from '../services/data-gathering.module';
import { DataProviderModule } from '../services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '../services/exchange-rate-data.module';
import { PrismaModule } from '../services/prisma.module';
import { TwitterBotModule } from '../services/twitter-bot/twitter-bot.module';
import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
@ -30,6 +29,7 @@ import { InfoModule } from './info/info.module';
import { LogoModule } from './logo/logo.module';
import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module';
@ -45,7 +45,7 @@ import { UserModule } from './user/user.module';
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT, 10),
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
password: process.env.REDIS_PASSWORD
}
}),

View File

@ -61,8 +61,10 @@ export class AuthService {
// Create new user if not found
user = await this.userService.createUser({
provider,
thirdPartyId: principalId
data: {
provider,
thirdPartyId: principalId
}
});
}
@ -96,8 +98,10 @@ export class AuthService {
// Create new user if not found
user = await this.userService.createUser({
provider,
thirdPartyId
data: {
provider,
thirdPartyId
}
});
}

View File

@ -14,6 +14,22 @@ export class ExportService {
activityIds?: string[];
userId: string;
}): Promise<Export> {
const accounts = await this.prismaService.account.findMany({
orderBy: {
name: 'asc'
},
select: {
accountType: true,
balance: true,
currency: true,
id: true,
isExcluded: true,
name: true,
platformId: true
},
where: { userId }
});
let activities = await this.prismaService.order.findMany({
orderBy: { date: 'desc' },
select: {
@ -38,6 +54,7 @@ export class ExportService {
return {
meta: { date: new Date().toISOString(), version: environment.version },
accounts,
activities: activities.map(
({
accountId,

View File

@ -83,6 +83,13 @@ export class FrontendMiddleware implements NestMiddleware {
) {
featureGraphicPath = 'assets/images/blog/20221226.jpg';
title = `The importance of tracking your personal finances - ${title}`;
} else if (
request.path.startsWith(
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt'
)
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
}
if (

View File

@ -1,8 +1,15 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator';
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
export class ImportDataDto {
@IsOptional()
@IsArray()
@Type(() => CreateAccountDto)
@ValidateNested({ each: true })
accounts: CreateAccountDto[];
@IsArray()
@Type(() => CreateOrderDto)
@ValidateNested({ each: true })

View File

@ -2,6 +2,7 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -20,7 +21,6 @@ import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isEmpty } from 'lodash';
import { ImportDataDto } from './import-data.dto';
import { ImportService } from './import.service';
@ -39,7 +39,13 @@ export class ImportController {
@Body() importData: ImportDataDto,
@Query('dryRun') isDryRun?: boolean
): Promise<ImportResponse> {
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
if (
!hasPermission(
this.request.user.permissions,
permissions.createAccount
) ||
!hasPermission(this.request.user.permissions, permissions.createOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -61,9 +67,10 @@ export class ImportController {
try {
const activities = await this.importService.import({
maxActivitiesToImport,
isDryRun,
maxActivitiesToImport,
userCurrency,
accountsDto: importData.accounts ?? [],
activitiesDto: importData.activities,
userId: this.request.user.id
});

View File

@ -1,4 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
@ -100,18 +101,75 @@ export class ImportService {
}
public async import({
accountsDto,
activitiesDto,
isDryRun = false,
maxActivitiesToImport,
userCurrency,
userId
}: {
accountsDto: Partial<CreateAccountDto>[];
activitiesDto: Partial<CreateOrderDto>[];
isDryRun?: boolean;
maxActivitiesToImport: number;
userCurrency: string;
userId: string;
}): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {};
if (!isDryRun && accountsDto?.length) {
const existingAccounts = await this.accountService.accounts({
where: {
id: {
in: accountsDto.map(({ id }) => {
return id;
})
}
}
});
for (const account of accountsDto) {
// Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find(
(existingAccount) => existingAccount.id === account.id
);
// If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== userId) {
let oldAccountId: string;
const platformId = account.platformId;
delete account.platformId;
if (accountWithSameId) {
oldAccountId = account.id;
delete account.id;
}
const newAccountObject = {
...account,
User: { connect: { id: userId } }
};
if (platformId) {
Object.assign(newAccountObject, {
Platform: { connect: { id: platformId } }
});
}
const newAccount = await this.accountService.createAccount(
newAccountObject,
userId
);
// Store the new to old account ID mappings for updating activities
if (accountWithSameId && oldAccountId) {
accountIdMapping[oldAccountId] = newAccount.id;
}
}
}
}
for (const activity of activitiesDto) {
if (!activity.dataSource) {
if (activity.type === 'ITEM') {
@ -120,6 +178,13 @@ export class ImportService {
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
}
}
// If a new account is created, then update the accountId in all activities
if (!isDryRun) {
if (Object.keys(accountIdMapping).includes(activity.accountId)) {
activity.accountId = accountIdMapping[activity.accountId];
}
}
}
const assetProfiles = await this.validateActivities({
@ -128,12 +193,18 @@ export class ImportService {
userId
});
const accountIds = (await this.accountService.getAccounts(userId)).map(
const accounts = (await this.accountService.getAccounts(userId)).map(
(account) => {
return account.id;
return { id: account.id, name: account.name };
}
);
if (isDryRun) {
accountsDto.forEach(({ id, name }) => {
accounts.push({ id, name });
});
}
const activities: Activity[] = [];
for (const {
@ -149,11 +220,15 @@ export class ImportService {
unitPrice
} of activitiesDto) {
const date = parseISO(<string>(<unknown>dateString));
const validatedAccountId = accountIds.includes(accountId)
? accountId
: undefined;
const validatedAccount = accounts.find(({ id }) => {
return id === accountId;
});
let order: OrderWithAccount;
let order:
| OrderWithAccount
| (Omit<OrderWithAccount, 'Account'> & {
Account?: { id: string; name: string };
});
if (isDryRun) {
order = {
@ -164,7 +239,7 @@ export class ImportService {
type,
unitPrice,
userId,
accountId: validatedAccountId,
accountId: validatedAccount?.id,
accountUserId: undefined,
createdAt: new Date(),
id: uuidv4(),
@ -187,6 +262,7 @@ export class ImportService {
url: null,
...assetProfiles[symbol]
},
Account: validatedAccount,
symbolProfileId: undefined,
updatedAt: new Date()
};
@ -199,7 +275,7 @@ export class ImportService {
type,
unitPrice,
userId,
accountId: validatedAccountId,
accountId: validatedAccount?.id,
SymbolProfile: {
connectOrCreate: {
create: {
@ -221,6 +297,7 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber();
//@ts-ignore
activities.push({
...order,
value,

View File

@ -72,10 +72,6 @@ export class InfoService {
globalPermissions.push(permissions.enableFearAndGreedIndex);
}
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE

View File

@ -76,23 +76,21 @@ export class OrderService {
userId: string;
}
): Promise<Order> {
const defaultAccount = (
await this.accountService.getAccounts(data.userId)
).find((account) => {
return account.isDefault === true;
});
let Account;
if (data.accountId) {
Account = {
connect: {
id_userId: {
userId: data.userId,
id: data.accountId
}
}
};
}
const tags = data.tags ?? [];
let Account = {
connect: {
id_userId: {
userId: data.userId,
id: data.accountId ?? defaultAccount?.id
}
}
};
if (data.type === 'ITEM') {
const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass;
@ -101,7 +99,6 @@ export class OrderService {
const id = uuidv4();
const name = data.SymbolProfile.connectOrCreate.create.symbol;
Account = undefined;
data.id = id;
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;

View File

@ -7,6 +7,8 @@ import { Tag } from '@prisma/client';
export interface PortfolioPositionDetail {
averagePrice: number;
dividendInBaseCurrency: number;
feeInBaseCurrency: number;
firstBuyDate: string;
grossPerformance: number;
grossPerformancePercent: number;

View File

@ -82,6 +82,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
fee: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),

View File

@ -71,6 +71,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('136.6'),
currency: 'CHF',
dataSource: 'YAHOO',
fee: new Big('1.55'),
firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),

View File

@ -82,6 +82,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('320.43'),
currency: 'CHF',
dataSource: 'YAHOO',
fee: new Big('0'),
firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'),

View File

@ -82,6 +82,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('75.80'),
currency: 'CHF',
dataSource: 'YAHOO',
fee: new Big('4.25'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),

View File

@ -102,6 +102,7 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
fee: new Big('0'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),

View File

@ -431,6 +431,7 @@ export class PortfolioCalculator {
: item.investment.div(item.quantity),
currency: item.currency,
dataSource: item.dataSource,
fee: item.fee,
firstBuyDate: item.firstBuyDate,
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
grossPerformancePercentage: !hasErrors
@ -447,7 +448,7 @@ export class PortfolioCalculator {
transactionCount: item.transactionCount
});
if (hasErrors) {
if (hasErrors && item.investment.gt(0)) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
}
}

View File

@ -131,7 +131,8 @@ export class PortfolioController {
portfolioPosition.investment / totalInvestment;
portfolioPosition.netPerformance = null;
portfolioPosition.quantity = null;
portfolioPosition.value = portfolioPosition.value / totalValue;
portfolioPosition.valueInPercentage =
portfolioPosition.value / totalValue;
}
for (const [name, { current, original }] of Object.entries(accounts)) {
@ -322,7 +323,7 @@ export class PortfolioController {
totalInvestment: new Big(totalInvestment)
.div(performanceInformation.performance.totalInvestment)
.toNumber(),
value: new Big(value)
valueInPercentage: new Big(value)
.div(performanceInformation.performance.currentValue)
.toNumber()
};
@ -356,6 +357,7 @@ export class PortfolioController {
@Get('positions')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers('impersonation-id') impersonationId: string,
@ -370,27 +372,11 @@ export class PortfolioController {
filterByTags
});
const result = await this.portfolioService.getPositions({
return this.portfolioService.getPositions({
dateRange,
filters,
impersonationId
});
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
result.positions = result.positions.map((position) => {
return nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'netPerformance',
'quantity'
]);
});
}
return result;
}
@Get('public/:accessId')
@ -441,7 +427,7 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPublicDetails.holdings[symbol] = {
allocationCurrent: portfolioPosition.value / totalValue,
allocationInPercentage: portfolioPosition.value / totalValue,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource,
@ -452,7 +438,7 @@ export class PortfolioController {
sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol,
url: portfolioPosition.url,
value: portfolioPosition.value / totalValue
valueInPercentage: portfolioPosition.value / totalValue
};
}
@ -460,6 +446,7 @@ export class PortfolioController {
}
@Get('position/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'))
@ -468,27 +455,13 @@ export class PortfolioController {
@Param('dataSource') dataSource,
@Param('symbol') symbol
): Promise<PortfolioPositionDetail> {
let position = await this.portfolioService.getPosition(
const position = await this.portfolioService.getPosition(
dataSource,
impersonationId,
symbol
);
if (position) {
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
position = nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'netPerformance',
'orders',
'quantity',
'value'
]);
}
return position;
}

View File

@ -1,5 +1,6 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
@ -19,11 +20,11 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
ASSET_SUB_CLASS_EMERGENCY_FUND,
EMERGENCY_FUND_TAG_ID,
MAX_CHART_ITEMS,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
EnhancedSymbolProfile,
@ -536,12 +537,9 @@ export class PortfolioService {
holdings[item.symbol] = {
markets,
allocationCurrent: filteredValueInBaseCurrency.eq(0)
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0
: value.div(filteredValueInBaseCurrency).toNumber(),
allocationInvestment: item.investment
.div(totalInvestmentInBaseCurrency)
.toNumber(),
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries,
@ -574,9 +572,7 @@ export class PortfolioService {
) {
const cashPositions = await this.getCashPositions({
cashDetails,
emergencyFund,
userCurrency,
investment: totalInvestmentInBaseCurrency,
value: filteredValueInBaseCurrency
});
@ -594,10 +590,51 @@ export class PortfolioService {
withExcludedAccounts
});
if (
filters?.length === 1 &&
filters[0].id === EMERGENCY_FUND_TAG_ID &&
filters[0].type === 'TAG'
) {
const cashPositions = await this.getCashPositions({
cashDetails,
userCurrency,
value: filteredValueInBaseCurrency
});
const emergencyFundInCash = emergencyFund
.minus(
this.getEmergencyFundPositionsValueInBaseCurrency({
activities: orders
})
)
.toNumber();
filteredValueInBaseCurrency = emergencyFund;
accounts[UNKNOWN_KEY] = {
balance: 0,
currency: userCurrency,
current: emergencyFundInCash,
name: UNKNOWN_KEY,
original: emergencyFundInCash
};
holdings[userCurrency] = {
...cashPositions[userCurrency],
investment: emergencyFundInCash,
value: emergencyFundInCash
};
}
const summary = await this.getSummary({
impersonationId,
userCurrency,
userId
userId,
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency:
this.getEmergencyFundPositionsValueInBaseCurrency({
activities: orders
})
});
return {
@ -641,6 +678,8 @@ export class PortfolioService {
return {
tags,
averagePrice: undefined,
dividendInBaseCurrency: undefined,
feeInBaseCurrency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
@ -707,12 +746,23 @@ export class PortfolioService {
averagePrice,
currency,
dataSource,
fee,
firstBuyDate,
marketPrice,
quantity,
transactionCount
} = position;
const dividendInBaseCurrency = getSum(
orders
.filter(({ type }) => {
return type === 'DIVIDEND';
})
.map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency);
})
);
// Convert investment, gross and net performance to currency of user
const investment = this.exchangeRateDataService.toCurrency(
position.investment?.toNumber(),
@ -746,8 +796,8 @@ export class PortfolioService {
historicalDataArray.push({
averagePrice: orders[0].unitPrice,
date: firstBuyDate,
quantity: orders[0].quantity,
value: orders[0].unitPrice
marketPrice: orders[0].unitPrice,
quantity: orders[0].quantity
});
}
@ -776,9 +826,9 @@ export class PortfolioService {
historicalDataArray.push({
date,
marketPrice,
averagePrice: currentAveragePrice,
quantity: currentQuantity,
value: marketPrice
quantity: currentQuantity
});
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
@ -799,6 +849,12 @@ export class PortfolioService {
tags,
transactionCount,
averagePrice: averagePrice.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee.toNumber(),
SymbolProfile.currency,
userCurrency
),
grossPerformancePercent:
position.grossPerformancePercentage?.toNumber(),
historicalData: historicalDataArray,
@ -855,6 +911,8 @@ export class PortfolioService {
SymbolProfile,
tags,
averagePrice: 0,
dividendInBaseCurrency: 0,
feeInBaseCurrency: 0,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
@ -1000,29 +1058,21 @@ export class PortfolioService {
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
);
const {
currentValue,
errors,
grossPerformance,
grossPerformancePercentage,
hasErrors,
netPerformance,
netPerformancePercentage,
totalInvestment
} = await portfolioCalculator.getCurrentPositions(startDate);
const hasErrors = currentPositions.hasErrors;
const currentValue = currentPositions.currentValue.toNumber();
const currentGrossPerformance = currentPositions.grossPerformance;
const currentGrossPerformancePercent =
currentPositions.grossPerformancePercentage;
let currentNetPerformance = currentPositions.netPerformance;
let currentNetPerformancePercent =
currentPositions.netPerformancePercentage;
const totalInvestment = currentPositions.totalInvestment;
// if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
// // If algebraic sign is different, harmonize it
// currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
// }
// if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
// // If algebraic sign is different, harmonize it
// currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
// }
const currentGrossPerformance = grossPerformance;
const currentGrossPerformancePercent = grossPerformancePercentage;
let currentNetPerformance = netPerformance;
let currentNetPerformancePercent = netPerformancePercentage;
const historicalDataContainer = await this.getChart({
dateRange,
@ -1044,28 +1094,28 @@ export class PortfolioService {
}
return {
errors,
hasErrors,
chart: historicalDataContainer.items.map(
({
date,
netPerformance,
netPerformance: netPerformanceOfItem,
netPerformanceInPercentage,
totalInvestment,
totalInvestment: totalInvestmentOfItem,
value
}) => {
return {
date,
netPerformance,
netPerformanceInPercentage,
totalInvestment,
value
value,
netPerformance: netPerformanceOfItem,
totalInvestment: totalInvestmentOfItem
};
}
),
errors: currentPositions.errors,
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date),
hasErrors: currentPositions.hasErrors || hasErrors,
performance: {
currentValue,
currentValue: currentValue.toNumber(),
currentGrossPerformance: currentGrossPerformance.toNumber(),
currentGrossPerformancePercent:
currentGrossPerformancePercent.toNumber(),
@ -1167,7 +1217,7 @@ export class PortfolioService {
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(),
this.getFees({ orders, userCurrency }).toNumber()
this.getFees({ userCurrency, activities: orders }).toNumber()
)
],
<UserSettings>this.request.user.Settings.settings
@ -1178,16 +1228,12 @@ export class PortfolioService {
private async getCashPositions({
cashDetails,
emergencyFund,
investment,
userCurrency,
value
}: {
cashDetails: CashDetails;
emergencyFund: Big;
investment: Big;
value: Big;
userCurrency: string;
value: Big;
}) {
const cashPositions: PortfolioDetails['holdings'] = {
[userCurrency]: this.getInitialCashPosition({
@ -1218,62 +1264,38 @@ export class PortfolioService {
}
}
if (emergencyFund.gt(0)) {
cashPositions[ASSET_SUB_CLASS_EMERGENCY_FUND] = {
...cashPositions[userCurrency],
assetSubClass: ASSET_SUB_CLASS_EMERGENCY_FUND,
investment: emergencyFund.toNumber(),
name: ASSET_SUB_CLASS_EMERGENCY_FUND,
symbol: ASSET_SUB_CLASS_EMERGENCY_FUND,
value: emergencyFund.toNumber()
};
cashPositions[userCurrency].investment = new Big(
cashPositions[userCurrency].investment
)
.minus(emergencyFund)
.toNumber();
cashPositions[userCurrency].value = new Big(
cashPositions[userCurrency].value
)
.minus(emergencyFund)
.toNumber();
}
for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency
cashPositions[symbol].allocationCurrent = value.gt(0)
cashPositions[symbol].allocationInPercentage = value.gt(0)
? new Big(cashPositions[symbol].value).div(value).toNumber()
: 0;
cashPositions[symbol].allocationInvestment = investment.gt(0)
? new Big(cashPositions[symbol].investment).div(investment).toNumber()
: 0;
}
return cashPositions;
}
private getDividend({
activities,
date = new Date(0),
orders,
userCurrency
}: {
activities: OrderWithAccount[];
date?: Date;
orders: OrderWithAccount[];
userCurrency: string;
}) {
return orders
.filter((order) => {
// Filter out all orders before given date and type dividend
return activities
.filter((activity) => {
// Filter out all activities before given date and type dividend
return (
isBefore(date, new Date(order.date)) &&
order.type === TypeOfOrder.DIVIDEND
isBefore(date, new Date(activity.date)) &&
activity.type === TypeOfOrder.DIVIDEND
);
})
.map((order) => {
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.SymbolProfile.currency,
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
);
})
@ -1345,24 +1367,56 @@ export class PortfolioService {
return dividendsByGroup;
}
private getEmergencyFundPositionsValueInBaseCurrency({
activities
}: {
activities: Activity[];
}) {
const emergencyFundOrders = activities.filter((activity) => {
return (
activity.tags?.some(({ id }) => {
return id === EMERGENCY_FUND_TAG_ID;
}) ?? false
);
});
let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0);
for (const order of emergencyFundOrders) {
if (order.type === 'BUY') {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.plus(
order.valueInBaseCurrency
);
} else if (order.type === 'SELL') {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.minus(
order.valueInBaseCurrency
);
}
}
return valueInBaseCurrencyOfEmergencyFundPositions.toNumber();
}
private getFees({
activities,
date = new Date(0),
orders,
userCurrency
}: {
activities: OrderWithAccount[];
date?: Date;
orders: OrderWithAccount[];
userCurrency: string;
}) {
return orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(date, new Date(order.date));
return activities
.filter((activity) => {
// Filter out all activities before given date
return isBefore(date, new Date(activity.date));
})
.map((order) => {
.map(({ fee, SymbolProfile }) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.SymbolProfile.currency,
fee,
SymbolProfile.currency,
userCurrency
);
})
@ -1381,8 +1435,7 @@ export class PortfolioService {
}): PortfolioPosition {
return {
currency,
allocationCurrent: 0,
allocationInvestment: 0,
allocationInPercentage: 0,
assetClass: AssetClass.CASH,
assetSubClass: AssetClass.CASH,
countries: [],
@ -1429,26 +1482,42 @@ export class PortfolioService {
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) {
case '1d':
portfolioStart = max([portfolioStart, subDays(new Date(), 1)]);
portfolioStart = max([
portfolioStart,
subDays(new Date().setHours(0, 0, 0, 0), 1)
]);
break;
case 'ytd':
portfolioStart = max([portfolioStart, setDayOfYear(new Date(), 1)]);
portfolioStart = max([
portfolioStart,
setDayOfYear(new Date().setHours(0, 0, 0, 0), 1)
]);
break;
case '1y':
portfolioStart = max([portfolioStart, subYears(new Date(), 1)]);
portfolioStart = max([
portfolioStart,
subYears(new Date().setHours(0, 0, 0, 0), 1)
]);
break;
case '5y':
portfolioStart = max([portfolioStart, subYears(new Date(), 5)]);
portfolioStart = max([
portfolioStart,
subYears(new Date().setHours(0, 0, 0, 0), 5)
]);
break;
}
return portfolioStart;
}
private async getSummary({
balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency,
impersonationId,
userCurrency,
userId
}: {
balanceInBaseCurrency: number;
emergencyFundPositionsValueInBaseCurrency: number;
impersonationId: string;
userCurrency: string;
userId: string;
@ -1461,11 +1530,7 @@ export class PortfolioService {
userId
});
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
userId,
currency: userCurrency
});
const orders = await this.orderService.getOrders({
const activities = await this.orderService.getOrders({
userCurrency,
userId
});
@ -1480,18 +1545,24 @@ export class PortfolioService {
return account?.isExcluded ?? false;
});
const dividend = this.getDividend({ orders, userCurrency }).toNumber();
const dividend = this.getDividend({
activities,
userCurrency
}).toNumber();
const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
);
const fees = this.getFees({ orders, userCurrency }).toNumber();
const firstOrderDate = orders[0]?.date;
const items = this.getItems(orders).toNumber();
const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date;
const items = this.getItems(activities).toNumber();
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber();
const cash = new Big(balanceInBaseCurrency)
.minus(emergencyFund)
.plus(emergencyFundPositionsValueInBaseCurrency)
.toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell);
const totalOfExcludedActivities = new Big(
this.getTotalByType(excludedActivities, userCurrency, 'BUY')
@ -1547,8 +1618,8 @@ export class PortfolioService {
totalSell,
committedFunds: committedFunds.toNumber(),
emergencyFund: emergencyFund.toNumber(),
ordersCount: orders.filter((order) => {
return order.type === 'BUY' || order.type === 'SELL';
ordersCount: activities.filter(({ type }) => {
return type === 'BUY' || type === 'SELL';
}).length
};
}
@ -1565,7 +1636,7 @@ export class PortfolioService {
withExcludedAccounts?: boolean;
}): Promise<{
transactionPoints: TransactionPoint[];
orders: OrderWithAccount[];
orders: Activity[];
portfolioOrders: PortfolioOrder[];
}> {
const userCurrency =
@ -1638,6 +1709,14 @@ export class PortfolioService {
userId: string;
withExcludedAccounts?: boolean;
}) {
const ordersOfTypeItem = await this.orderService.getOrders({
filters,
userCurrency,
userId,
withExcludedAccounts,
types: ['ITEM']
});
const accounts: PortfolioDetails['accounts'] = {};
let currentAccounts: (Account & {
@ -1668,10 +1747,18 @@ export class PortfolioService {
});
for (const account of currentAccounts) {
const ordersByAccount = orders.filter(({ accountId }) => {
let ordersByAccount = orders.filter(({ accountId }) => {
return accountId === account.id;
});
const ordersOfTypeItemByAccount = ordersOfTypeItem.filter(
({ accountId }) => {
return accountId === account.id;
}
);
ordersByAccount = ordersByAccount.concat(ordersOfTypeItemByAccount);
accounts[account.id] = {
balance: account.balance,
currency: account.currency,
@ -1691,7 +1778,9 @@ export class PortfolioService {
for (const order of ordersByAccount) {
let currentValueOfSymbolInBaseCurrency =
order.quantity *
portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ?? 0;
(portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ??
order.unitPrice ??
0);
let originalValueOfSymbolInBaseCurrency =
this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,

View File

@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsOptional()
country?: string;
}

View File

@ -1,6 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config';
import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -24,6 +22,7 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { size } from 'lodash';
import { CreateUserDto } from './create-user.dto';
import { UserItem } from './interfaces/user-item.interface';
import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UserService } from './user.service';
@ -31,7 +30,6 @@ import { UserService } from './user.service';
@Controller('user')
export class UserController {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly jwtService: JwtService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser,
@ -68,7 +66,7 @@ export class UserController {
}
@Post()
public async signupUser(): Promise<UserItem> {
public async signupUser(@Body() data: CreateUserDto): Promise<UserItem> {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
@ -82,7 +80,8 @@ export class UserController {
const hasAdmin = await this.userService.hasAdmin();
const { accessToken, id, role } = await this.userService.createUser({
role: hasAdmin ? 'USER' : 'ADMIN'
country: data.country,
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
});
return {

View File

@ -18,6 +18,8 @@ import { Injectable } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client';
import { sortBy } from 'lodash';
import { CreateUserDto } from './create-user.dto';
const crypto = require('crypto');
@Injectable()
@ -97,6 +99,7 @@ export class UserService {
const {
accessToken,
Account,
Analytics,
authChallenge,
createdAt,
id,
@ -107,7 +110,12 @@ export class UserService {
thirdPartyId,
updatedAt
} = await this.prismaService.user.findUnique({
include: { Account: true, Settings: true, Subscription: true },
include: {
Account: true,
Analytics: true,
Settings: true,
Subscription: true
},
where: userWhereUniqueInput
});
@ -121,7 +129,8 @@ export class UserService {
role,
Settings,
thirdPartyId,
updatedAt
updatedAt,
activityCount: Analytics?.activityCount
};
if (user?.Settings) {
@ -154,15 +163,22 @@ export class UserService {
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
}
let currentPermissions = getPermissions(user.role);
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription =
this.subscriptionService.getSubscription(Subscription);
}
let currentPermissions = getPermissions(user.role);
if (
Analytics?.activityCount % 25 === 0 &&
user.subscription?.type === 'Basic'
) {
currentPermissions.push(permissions.enableSubscriptionInterstitial);
}
if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch);
if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch);
}
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
@ -217,7 +233,10 @@ export class UserService {
return hash.digest('hex');
}
public async createUser(data: Prisma.UserCreateInput): Promise<User> {
public async createUser({
country,
data
}: CreateUserDto & { data: Prisma.UserCreateInput }): Promise<User> {
if (!data?.provider) {
data.provider = 'ANONYMOUS';
}
@ -242,6 +261,15 @@ export class UserService {
}
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
await this.prismaService.analytics.create({
data: {
country,
User: { connect: { id: user.id } }
}
});
}
if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken(
user.id,

View File

@ -1,4 +1,4 @@
import { cloneDeep, isObject } from 'lodash';
import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
for (const key in aObject) {
@ -43,15 +43,23 @@ export function redactAttributes({
for (const option of options) {
if (redactedObject.hasOwnProperty(option.attribute)) {
redactedObject[option.attribute] =
option.valueMap[redactedObject[option.attribute]] ??
option.valueMap['*'] ??
redactedObject[option.attribute];
if (option.valueMap['*'] || option.valueMap['*'] === null) {
redactedObject[option.attribute] = option.valueMap['*'];
} else if (option.valueMap[redactedObject[option.attribute]]) {
redactedObject[option.attribute] =
option.valueMap[redactedObject[option.attribute]];
}
} else {
// If the attribute is not present on the current object,
// check if it exists on any nested objects
for (const property in redactedObject) {
if (typeof redactedObject[property] === 'object') {
if (isArray(redactedObject[property])) {
redactedObject[property] = redactedObject[property].map(
(currentObject) => {
return redactAttributes({ options, object: currentObject });
}
);
} else if (isObject(redactedObject[property])) {
// Recursively call the function on the nested object
redactedObject[property] = redactAttributes({
options,

View File

@ -1,5 +1,5 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import {
CallHandler,
ExecutionContext,
@ -28,59 +28,36 @@ export class RedactValuesInResponseInterceptor<T>
hasImpersonationId ||
this.userService.isRestrictedView(request.user)
) {
if (data.accounts) {
for (const accountId of Object.keys(data.accounts)) {
if (data.accounts[accountId]?.balance !== undefined) {
data.accounts[accountId].balance = null;
}
}
}
if (data.activities) {
data.activities = data.activities.map((activity: Activity) => {
if (activity.Account?.balance !== undefined) {
activity.Account.balance = null;
}
if (activity.comment !== undefined) {
activity.comment = null;
}
if (activity.fee !== undefined) {
activity.fee = null;
}
if (activity.feeInBaseCurrency !== undefined) {
activity.feeInBaseCurrency = null;
}
if (activity.quantity !== undefined) {
activity.quantity = null;
}
if (activity.unitPrice !== undefined) {
activity.unitPrice = null;
}
if (activity.value !== undefined) {
activity.value = null;
}
if (activity.valueInBaseCurrency !== undefined) {
activity.valueInBaseCurrency = null;
}
return activity;
});
}
if (data.filteredValueInBaseCurrency) {
data.filteredValueInBaseCurrency = null;
}
if (data.totalValueInBaseCurrency) {
data.totalValueInBaseCurrency = null;
}
data = redactAttributes({
object: data,
options: [
'balance',
'balanceInBaseCurrency',
'comment',
'convertedBalance',
'dividendInBaseCurrency',
'fee',
'feeInBaseCurrency',
'filteredValueInBaseCurrency',
'grossPerformance',
'investment',
'netPerformance',
'quantity',
'symbolMapping',
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice',
'value',
'valueInBaseCurrency'
].map((attribute) => {
return {
attribute,
valueMap: {
'*': null
}
};
})
});
}
return data;

View File

@ -19,12 +19,11 @@ export class ConfigurationService {
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({
default: [DataSource.GHOSTFOLIO, DataSource.MANUAL, DataSource.YAHOO]
default: [DataSource.MANUAL, DataSource.YAHOO]
}),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_IMPORT: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
@ -42,7 +41,7 @@ export class ConfigurationService {
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
PORT: port({ default: 3333 }),
RAPID_API_API_KEY: str({ default: '' }),
REDIS_HOST: host({ default: 'localhost' }),
REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }),

View File

@ -19,8 +19,8 @@ export class CronService {
private readonly twitterBotService: TwitterBotService
) {}
@Cron(CronExpression.EVERY_HOUR)
public async runEveryHour() {
@Cron(CronExpression.EVERY_4_HOURS)
public async runEveryFourHours() {
await this.dataGatheringService.gather7Days();
}

View File

@ -278,7 +278,6 @@ export class DataGatheringService {
return symbolProfiles
.filter(({ dataSource }) => {
return (
dataSource !== DataSource.GHOSTFOLIO &&
dataSource !== DataSource.MANUAL &&
dataSource !== DataSource.RAPID_API
);

View File

@ -2,7 +2,6 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration.modu
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
@ -24,7 +23,6 @@ import { DataProviderService } from './data-provider.service';
AlphaVantageService,
DataProviderService,
EodHistoricalDataService,
GhostfolioScraperApiService,
GoogleSheetsService,
ManualService,
RapidApiService,
@ -33,7 +31,6 @@ import { DataProviderService } from './data-provider.service';
inject: [
AlphaVantageService,
EodHistoricalDataService,
GhostfolioScraperApiService,
GoogleSheetsService,
ManualService,
RapidApiService,
@ -43,7 +40,6 @@ import { DataProviderService } from './data-provider.service';
useFactory: (
alphaVantageService,
eodHistoricalDataService,
ghostfolioScraperApiService,
googleSheetsService,
manualService,
rapidApiService,
@ -51,7 +47,6 @@ import { DataProviderService } from './data-provider.service';
) => [
alphaVantageService,
eodHistoricalDataService,
ghostfolioScraperApiService,
googleSheetsService,
manualService,
rapidApiService,
@ -59,10 +54,6 @@ import { DataProviderService } from './data-provider.service';
]
}
],
exports: [
DataProviderService,
GhostfolioScraperApiService,
YahooFinanceService
]
exports: [DataProviderService, YahooFinanceService]
})
export class DataProviderModule {}

View File

@ -1,194 +0,0 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATE_FORMAT,
extractNumberFromString,
getYesterday
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import * as cheerio from 'cheerio';
import { addDays, format, isBefore } from 'date-fns';
@Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface {
public constructor(
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public canHandle(symbol: string) {
return true;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
};
}
public async getDividends({
from,
granularity = 'day',
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
return {};
}
public async getHistorical(
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
try {
const symbol = aSymbol;
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration;
if (defaultMarketPrice) {
const historical: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[symbol]: {}
};
let date = from;
while (isBefore(date, to)) {
historical[symbol][format(date, DATE_FORMAT)] = {
marketPrice: defaultMarketPrice
};
date = addDays(date, 1);
}
return historical;
} else if (selector === undefined || url === undefined) {
return {};
}
const get = bent(url, 'GET', 'string', 200, {});
const html = await get();
const $ = cheerio.load(html);
const value = extractNumberFromString($(selector).text());
return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: value
}
}
};
} catch (error) {
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getName(): DataSource {
return DataSource.GHOSTFOLIO;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) {
return response;
}
try {
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
const marketData = await this.prismaService.marketData.findMany({
distinct: ['symbol'],
orderBy: {
date: 'desc'
},
take: aSymbols.length,
where: {
symbol: {
in: aSymbols
}
}
});
for (const symbolProfile of symbolProfiles) {
response[symbolProfile.symbol] = {
currency: symbolProfile.currency,
dataSource: this.getName(),
marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
}).marketPrice,
marketState: 'delayed'
};
}
return response;
} catch (error) {
Logger.error(error, 'GhostfolioScraperApiService');
}
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
}
}

View File

@ -6,9 +6,17 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATE_FORMAT,
extractNumberFromString,
getYesterday
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import * as cheerio from 'cheerio';
import { addDays, format, isBefore } from 'date-fns';
@Injectable()
export class ManualService implements DataProviderInterface {
@ -18,7 +26,7 @@ export class ManualService implements DataProviderInterface {
) {}
public canHandle(symbol: string) {
return false;
return true;
}
public async getAssetProfile(
@ -51,7 +59,57 @@ export class ManualService implements DataProviderInterface {
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
return {};
try {
const symbol = aSymbol;
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration ?? {};
if (defaultMarketPrice) {
const historical: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[symbol]: {}
};
let date = from;
while (isBefore(date, to)) {
historical[symbol][format(date, DATE_FORMAT)] = {
marketPrice: defaultMarketPrice
};
date = addDays(date, 1);
}
return historical;
} else if (selector === undefined || url === undefined) {
return {};
}
const get = bent(url, 'GET', 'string', 200, {});
const html = await get();
const $ = cheerio.load(html);
const value = extractNumberFromString($(selector).text());
return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: value
}
}
};
} catch (error) {
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getName(): DataSource {
@ -88,10 +146,9 @@ export class ManualService implements DataProviderInterface {
response[symbolProfile.symbol] = {
currency: symbolProfile.currency,
dataSource: this.getName(),
marketPrice:
marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
})?.marketPrice ?? 0,
marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
})?.marketPrice,
marketState: 'delayed'
};
}

View File

@ -10,7 +10,6 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_IMPORT: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean;

View File

@ -1,18 +0,0 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.io/guide/browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
last 1 Chrome version
last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR
not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line.
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.

View File

@ -65,7 +65,10 @@
"output": "./../assets/"
}
],
"styles": ["apps/client/src/styles.scss"],
"styles": [
"apps/client/src/styles/theme.scss",
"apps/client/src/styles.scss"
],
"scripts": ["node_modules/marked/marked.min.js"],
"vendorChunk": true,
"extractLicenses": false,

View File

@ -116,6 +116,13 @@ const routes: Routes = [
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule)
},
{
path: 'blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt',
loadChildren: () =>
import(
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
},
{
path: 'demo',
loadChildren: () =>
@ -215,9 +222,8 @@ const routes: Routes = [
// Preload all lazy loaded modules with the attribute preload === true
{
anchorScrolling: 'enabled',
preloadingStrategy: ModulePreloadService,
preloadingStrategy: ModulePreloadService
// enableTracing: true // <-- debugging purposes only
relativeLinkResolution: 'legacy'
}
)
],

View File

@ -1,26 +1,17 @@
import { DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import { Title } from '@angular/platform-browser';
import {
ActivatedRoute,
NavigationEnd,
PRIMARY_OUTLET,
Router
} from '@angular/router';
import {
primaryColorHex,
secondaryColorHex,
warnColorHex
} from '@ghostfolio/common/config';
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ColorScheme } from '@ghostfolio/common/types';
import { MaterialCssVarsService } from 'angular-material-css-vars';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
@ -52,7 +43,7 @@ export class AppComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private materialCssVarsService: MaterialCssVarsService,
@Inject(DOCUMENT) private document: Document,
private router: Router,
private title: Title,
private tokenStorageService: TokenStorageService,
@ -126,16 +117,20 @@ export class AppComponent implements OnDestroy, OnInit {
? userPreferredColorScheme === 'DARK'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
this.materialCssVarsService.setDarkTheme(isDarkTheme);
this.toggleThemeStyleClass(isDarkTheme);
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
if (!this.user?.settings.colorScheme) {
this.materialCssVarsService.setDarkTheme(event.matches);
this.toggleThemeStyleClass(event.matches);
}
});
}
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
this.materialCssVarsService.setAccentColor(secondaryColorHex);
this.materialCssVarsService.setWarnColor(warnColorHex);
private toggleThemeStyleClass(isDarkTheme: boolean) {
if (isDarkTheme) {
this.document.body.classList.add('is-dark-theme');
} else {
this.document.body.classList.remove('is-dark-theme');
}
}
}

View File

@ -1,20 +1,19 @@
import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
import {
DateAdapter,
MAT_DATE_FORMATS,
MAT_DATE_LOCALE,
MatNativeDateModule
} from '@angular/material/core';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatLegacyAutocompleteModule as MatAutocompleteModule } from '@angular/material/legacy-autocomplete';
import { MatLegacyChipsModule as MatChipsModule } from '@angular/material/legacy-chips';
import { MatLegacySnackBarModule as MatSnackBarModule } from '@angular/material/legacy-snack-bar';
import { MatLegacyTooltipModule as MatTooltipModule } from '@angular/material/legacy-tooltip';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ServiceWorkerModule } from '@angular/service-worker';
import { MaterialCssVarsModule } from 'angular-material-css-vars';
import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe';
@ -25,6 +24,7 @@ import { DateFormats } from './adapter/date-formats';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { GfHeaderModule } from './components/header/header.module';
import { GfSubscriptionInterstitialDialogModule } from './components/subscription-interstitial-dialog/subscription-interstitial-dialog.module';
import { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
import { LanguageService } from './core/language.service';
@ -40,15 +40,11 @@ export function NgxStripeFactory(): string {
BrowserAnimationsModule,
BrowserModule,
GfHeaderModule,
GfSubscriptionInterstitialDialogModule,
HttpClientModule,
MarkdownModule.forRoot(),
MatAutocompleteModule,
MatChipsModule,
MaterialCssVarsModule.forRoot({
darkThemeClass: 'is-dark-theme',
isAutoContrast: true,
lightThemeClass: 'is-light-theme'
}),
MatNativeDateModule,
MatSnackBarModule,
MatTooltipModule,

View File

@ -7,7 +7,7 @@ import {
OnInit,
Output
} from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { MatLegacyTableDataSource as MatTableDataSource } from '@angular/material/legacy-table';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Access } from '@ghostfolio/common/interfaces';

View File

@ -1,8 +1,8 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { MatLegacyTableModule as MatTableModule } from '@angular/material/legacy-table';
import { AccessTableComponent } from './access-table.component';

View File

@ -6,7 +6,10 @@ import {
OnDestroy,
OnInit
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import {
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
MatLegacyDialogRef as MatDialogRef
} from '@angular/material/legacy-dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper';

View File

@ -40,7 +40,6 @@
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="true"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showActions]="false"

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';

View File

@ -9,8 +9,8 @@ import {
Output,
ViewChild
} from '@angular/core';
import { MatLegacyTableDataSource as MatTableDataSource } from '@angular/material/legacy-table';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import { Account as AccountModel } from '@prisma/client';
import { get } from 'lodash';

View File

@ -1,10 +1,9 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { MatLegacyTableModule as MatTableModule } from '@angular/material/legacy-table';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -20,7 +19,6 @@ import { AccountsTableComponent } from './accounts-table.component';
GfSymbolIconModule,
GfValueModule,
MatButtonModule,
MatInputModule,
MatMenuModule,
MatSortModule,
MatTableModule,

View File

@ -1,9 +1,9 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
import { AdminJobsComponent } from './admin-jobs.component';

View File

@ -7,7 +7,7 @@ import {
OnInit,
Output
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DATE_FORMAT,

View File

@ -6,7 +6,10 @@ import {
OnDestroy
} from '@angular/core';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import {
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
MatLegacyDialogRef as MatDialogRef
} from '@angular/material/legacy-dialog';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { Subject, takeUntil } from 'rxjs';
@ -36,7 +39,7 @@ export class MarketDataDetailDialog implements OnDestroy {
this.dateAdapter.setLocale(this.locale);
}
public onCancel(): void {
public onCancel() {
this.dialogRef.close({ withRefresh: false });
}

View File

@ -1,6 +1,6 @@
<form class="d-flex flex-column h-100">
<h1 i18n mat-dialog-title>Details for {{ data.symbol }}</h1>
<div class="flex-grow-1" mat-dialog-content>
<div class="flex-grow-1 pt-3" mat-dialog-content>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Date</mat-label>
@ -11,7 +11,7 @@
[matDatepicker]="date"
[(ngModel)]="data.date"
/>
<mat-datepicker-toggle matSuffix [for]="date">
<mat-datepicker-toggle class="mr-2" matSuffix [for]="date">
<ion-icon
class="text-muted"
matDatepickerToggleIcon
@ -21,7 +21,7 @@
<mat-datepicker #date disabled="true"></mat-datepicker>
</mat-form-field>
</div>
<div>
<div class="align-items-start d-flex">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Market Price</mat-label>
<input
@ -30,16 +30,16 @@
type="number"
[(ngModel)]="data.marketPrice"
/>
<span class="ml-2" matSuffix>{{ data.currency }}</span>
<button
mat-icon-button
matSuffix
title="Fetch market price"
(click)="onFetchSymbolForDate()"
>
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
</button>
<span class="ml-2" matTextSuffix>{{ data.currency }}</span>
</mat-form-field>
<button
class="apply-current-market-price ml-2 no-min-width"
mat-button
title="Fetch market price"
(click)="onFetchSymbolForDate()"
>
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
</button>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>

View File

@ -1,9 +1,9 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';

View File

@ -4,19 +4,9 @@
.mat-dialog-content {
max-height: unset;
.mat-form-field-appearance-outline {
::ng-deep {
.mat-form-field-suffix {
top: -0.3rem;
}
.mat-form-field-wrapper {
padding-bottom: 0;
}
}
ion-icon {
font-size: 130%;
.mat-mdc-button {
&.apply-current-market-price {
height: 56px;
}
}
}

View File

@ -6,9 +6,9 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
import { MatLegacyTableDataSource as MatTableDataSource } from '@angular/material/legacy-table';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';

View File

@ -1,13 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { MatLegacyTableModule as MatTableModule } from '@angular/material/legacy-table';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { AdminMarketDataComponent } from './admin-market-data.component';
import { GfAssetProfileDialogModule } from './asset-profile-dialog/assset-profile-dialog.module';
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
@NgModule({
declarations: [AdminMarketDataComponent],

View File

@ -7,7 +7,10 @@ import {
OnInit
} from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import {
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
MatLegacyDialogRef as MatDialogRef
} from '@angular/material/legacy-dialog';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import {

View File

@ -2,10 +2,10 @@ import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
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 { GfValueModule } from '@ghostfolio/ui/value';

View File

@ -1,15 +1,15 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { MatLegacySlideToggleChange as MatSlideToggleChange } from '@angular/material/legacy-slide-toggle';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
ghostfolioPrefix,
PROPERTY_COUPONS,
PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED,
PROPERTY_SYSTEM_MESSAGE
PROPERTY_SYSTEM_MESSAGE,
ghostfolioPrefix
} from '@ghostfolio/common/config';
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -29,7 +29,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-overview.html'
})
export class AdminOverviewComponent implements OnDestroy, OnInit {
public couponDuration: StringValue = '30 days';
public couponDuration: StringValue = '14 days';
public coupons: Coupon[];
public customCurrencies: string[];
public exchangeRates: { label1: string; label2: string; value: number }[];

View File

@ -151,16 +151,23 @@
>
<div class="w-50" i18n>Coupons</div>
<div class="w-50">
<div *ngFor="let coupon of coupons">
<span>{{ coupon.code }} ({{ coupon.duration }})</span>
<button
class="mini-icon mx-1 no-min-width px-2"
mat-button
(click)="onDeleteCoupon(coupon.code)"
>
<ion-icon name="trash-outline"></ion-icon>
</button>
</div>
<table>
<tr *ngFor="let coupon of coupons">
<td class="text-monospace">{{ coupon.code }}</td>
<td class="d-flex justify-content-end pl-2">
{{ coupon.duration }}
</td>
<td>
<button
class="mini-icon mx-1 no-min-width px-2"
mat-button
(click)="onDeleteCoupon(coupon.code)"
>
<ion-icon name="trash-outline"></ion-icon>
</button>
</td>
</tr>
</table>
<div class="mt-2">
<form #couponForm="ngForm" class="align-items-center d-flex">
<mat-form-field

View File

@ -1,10 +1,10 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
import { MatLegacySlideToggleModule as MatSlideToggleModule } from '@angular/material/legacy-slide-toggle';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { GfValueModule } from '@ghostfolio/ui/value';

View File

@ -1,7 +1,9 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { AdminData, User } from '@ghostfolio/common/interfaces';
import { getEmojiFlag } from '@ghostfolio/common/helper';
import { AdminData, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
differenceInSeconds,
formatDistanceToNowStrict,
@ -16,6 +18,9 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html'
})
export class AdminUsersComponent implements OnDestroy, OnInit {
public getEmojiFlag = getEmojiFlag;
public hasPermissionForSubscription: boolean;
public info: InfoItem;
public user: User;
public users: AdminData['users'];
@ -26,6 +31,13 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
private dataService: DataService,
private userService: UserService
) {
this.info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {

View File

@ -7,7 +7,13 @@
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right">#</th>
<th class="mat-header-cell px-1 py-2" i18n>User</th>
<th class="mat-header-cell px-1 py-2 text-right">
<th
*ngIf="hasPermissionForSubscription"
class="mat-header-cell px-1 py-2"
>
<ng-container i18n>Country</ng-container>
</th>
<th class="mat-header-cell px-1 py-2">
<ng-container i18n>Registration</ng-container>
</th>
<th class="mat-header-cell px-1 py-2 text-right">
@ -16,7 +22,10 @@
<th class="mat-header-cell px-1 py-2 text-right">
<ng-container i18n>Activities</ng-container>
</th>
<th class="mat-header-cell px-1 py-2 text-right">
<th
*ngIf="hasPermissionForSubscription"
class="mat-header-cell px-1 py-2 text-right"
>
<ng-container i18n>Engagement per Day</ng-container>
</th>
<th class="mat-header-cell px-1 py-2" i18n>Last Request</th>
@ -28,10 +37,10 @@
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-cell px-1 py-2">
<div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block"
<span class="d-none d-sm-inline-block text-monospace"
>{{ userItem.id }}</span
>
<span class="d-inline-block d-sm-none"
<span class="d-inline-block d-sm-none text-monospace"
>{{ (userItem.id | slice:0:5) + '...' }}</span
>
<gf-premium-indicator
@ -41,7 +50,15 @@
></gf-premium-indicator>
</div>
</td>
<td class="mat-cell px-1 py-2 text-right">
<td
*ngIf="hasPermissionForSubscription"
class="mat-cell px-1 py-2"
>
<span class="h5" [title]="userItem.country"
>{{ getEmojiFlag(userItem.country) }}</span
>
</td>
<td class="mat-cell px-1 py-2">
{{ formatDistanceToNow(userItem.createdAt) }}
</td>
<td class="mat-cell px-1 py-2 text-right">
@ -58,7 +75,10 @@
[value]="userItem.transactionCount"
></gf-value>
</td>
<td class="mat-cell px-1 py-2 text-right">
<td
*ngIf="hasPermissionForSubscription"
class="mat-cell px-1 py-2 text-right"
>
<gf-value
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';

View File

@ -27,6 +27,7 @@ import { ColorScheme } from '@ghostfolio/common/types';
import { SymbolProfile } from '@prisma/client';
import {
Chart,
ChartData,
LineController,
LineElement,
LinearScale,
@ -57,7 +58,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@ViewChild('chartCanvas') chartCanvas;
public chart: Chart<any>;
public chart: Chart<'line'>;
public constructor() {
Chart.register(
@ -89,14 +90,14 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
}
private initialize() {
const data = {
const data: ChartData<'line'> = {
datasets: [
{
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
data: this.performanceDataItems.map(({ date, value }) => {
return { x: parseDate(date), y: value };
return { x: parseDate(date).getTime(), y: value };
}),
label: $localize`Portfolio`
},
@ -105,7 +106,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: 2,
data: this.benchmarkDataItems.map(({ date, value }) => {
return { x: parseDate(date), y: value };
return { x: parseDate(date).getTime(), y: value };
}),
label: $localize`Benchmark`
}

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select';
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { DialogFooterComponent } from './dialog-footer.component';

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { DialogHeaderComponent } from './dialog-header.component';

View File

@ -6,7 +6,7 @@ import {
OnChanges,
Output
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
import { Router } from '@angular/router';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';

View File

@ -1,5 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { RouterModule } from '@angular/router';
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';

View File

@ -110,13 +110,12 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
range: this.user?.settings?.dateRange
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.errors = response.errors;
this.hasError = response.hasErrors;
this.performance = response.performance;
.subscribe(({ chart, errors, performance }) => {
this.errors = errors;
this.performance = performance;
this.isLoadingPerformance = false;
this.historicalDataItems = response.chart.map(
this.historicalDataItems = chart.map(
({ date, netPerformanceInPercentage }) => {
return {
date,

View File

@ -37,7 +37,6 @@
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[errors]="errors"
[hasError]="hasError"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance"

View File

@ -1,9 +1,9 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import {
MatSnackBar,
MatSnackBarRef,
TextOnlySnackBar
} from '@angular/material/snack-bar';
MatLegacySnackBar as MatSnackBar,
MatLegacySnackBarRef as MatSnackBarRef,
LegacyTextOnlySnackBar as TextOnlySnackBar
} from '@angular/material/legacy-snack-bar';
import { Router } from '@angular/router';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { RouterModule } from '@angular/router';
import { GfPortfolioSummaryModule } from '@ghostfolio/client/components/portfolio-summary/portfolio-summary.module';

View File

@ -11,7 +11,8 @@ import {
import {
getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin
getVerticalHoverLinePlugin,
transformTickToAbbreviation
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import {
@ -19,8 +20,7 @@ import {
getBackgroundColor,
getDateFormatString,
getTextColor,
parseDate,
transformTickToAbbreviation
parseDate
} from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
@ -29,6 +29,7 @@ import {
BarController,
BarElement,
Chart,
ChartData,
LineController,
LineElement,
LinearScale,
@ -62,7 +63,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
@ViewChild('chartCanvas') chartCanvas;
public chart: Chart<any>;
public chart: Chart<'bar' | 'line'>;
private investments: InvestmentItem[];
private values: LineChartItem[];
@ -136,10 +137,13 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
date,
investment: last(this.investments).investment
});
this.values.push({ date, value: last(this.values).value });
this.values.push({
date,
value: last(this.values).value
});
}
const data = {
const chartData: ChartData<'line'> = {
labels: this.historicalDataItems.map(({ date }) => {
return parseDate(date);
}),
@ -150,7 +154,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
borderWidth: this.groupBy ? 0 : 1,
data: this.investments.map(({ date, investment }) => {
return {
x: parseDate(date),
x: parseDate(date).getTime(),
y: this.isInPercent ? investment * 100 : investment
};
}),
@ -170,7 +174,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
borderWidth: 2,
data: this.values.map(({ date, value }) => {
return {
x: parseDate(date),
x: parseDate(date).getTime(),
y: this.isInPercent ? value * 100 : value
};
}),
@ -191,12 +195,15 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = data;
this.chart.data = chartData;
this.chart.options.plugins.tooltip = <unknown>(
this.getTooltipPluginConfiguration()
);
this.chart.options.scales.x.min = this.daysInMarket
? subDays(new Date(), this.daysInMarket).toISOString()
? subDays(
new Date().setHours(0, 0, 0, 0),
this.daysInMarket
).toISOString()
: undefined;
if (
@ -210,7 +217,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
data,
data: chartData,
options: {
animation: false,
elements: {
@ -325,6 +332,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
...getTooltipOptions({
colorScheme: this.colorScheme,
currency: this.isInPercent ? undefined : this.currency,
groupBy: this.groupBy,
locale: this.isInPercent ? undefined : this.locale,
unit: this.isInPercent ? '%' : undefined
}),

View File

@ -1,6 +1,9 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatLegacyCheckboxChange as MatCheckboxChange } from '@angular/material/legacy-checkbox';
import {
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
MatLegacyDialogRef as MatDialogRef
} from '@angular/material/legacy-dialog';
import { Router } from '@angular/router';
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
import {

View File

@ -2,11 +2,11 @@ import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyCheckboxModule as MatCheckboxModule } from '@angular/material/legacy-checkbox';
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
import { MatLegacyFormFieldModule as MatFormFieldModule } from '@angular/material/legacy-form-field';
import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component';

View File

@ -3,14 +3,14 @@
<div
class="flex-grow-1 status text-muted text-right"
[title]="
hasError && !isLoading
errors?.length > 0 && !isLoading
? 'Sorry! Our data provider partner is experiencing the hiccups.'
: ''
"
(click)="errors?.length > 0 && onShowErrors()"
>
<ion-icon
*ngIf="hasError && !isLoading"
*ngIf="errors?.length > 0 && !isLoading"
name="alert-circle-outline"
></ion-icon>
</div>

View File

@ -28,7 +28,6 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() errors: ResponseError['errors'];
@Input() hasError: boolean;
@Input() isAllTimeHigh: boolean;
@Input() isAllTimeLow: boolean;
@Input() isLoading: boolean;

View File

@ -6,7 +6,10 @@ import {
OnDestroy,
OnInit
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import {
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
MatLegacyDialogRef as MatDialogRef
} from '@angular/material/legacy-dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import {
@ -37,6 +40,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public countries: {
[code: string]: { name: string; value: number };
};
public dividendInBaseCurrency: number;
public feeInBaseCurrency: number;
public firstBuyDate: string;
public grossPerformance: number;
public grossPerformancePercent: number;
@ -78,6 +83,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
.subscribe(
({
averagePrice,
dividendInBaseCurrency,
feeInBaseCurrency,
firstBuyDate,
grossPerformance,
grossPerformancePercent,
@ -98,7 +105,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.averagePrice = averagePrice;
this.benchmarkDataItems = [];
this.countries = {};
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.dividendInBaseCurrency = dividendInBaseCurrency;
this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate;
this.grossPerformance = grossPerformance;
this.grossPerformancePercent = grossPerformancePercent;
@ -111,7 +119,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
return {
date: historicalDataItem.date,
value: historicalDataItem.value
value: historicalDataItem.marketPrice
};
}
);
@ -123,9 +131,15 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.netPerformancePercent = netPerformancePercent;
this.orders = orders;
this.quantity = quantity;
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.sectors = {};
this.SymbolProfile = SymbolProfile;
this.tags = tags;
this.tags = tags.map(({ id, name }) => {
return {
id,
name: translate(name)
};
});
this.transactionCount = transactionCount;
this.value = value;

View File

@ -119,6 +119,26 @@
>Investment</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[currency]="data.baseCurrency"
[locale]="data.locale"
[value]="dividendInBaseCurrency"
>Dividend</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[currency]="data.baseCurrency"
[locale]="data.locale"
[value]="feeInBaseCurrency"
>Fees</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
@ -219,7 +239,6 @@
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="true"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
[showActions]="false"

View File

@ -1,8 +1,8 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyChipsModule as MatChipsModule } from '@angular/material/legacy-chips';
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatDialogModule } from '@angular/material/dialog';
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfTrendIndicatorModule } from '@ghostfolio/ui/trend-indicator';

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfPositionModule } from '../position/position.module';

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { GfRuleModule } from '@ghostfolio/client/components/rule/rule.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';

View File

@ -0,0 +1 @@
export interface SubscriptionInterstitialDialogParams {}

View File

@ -0,0 +1,25 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import {
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
MatLegacyDialogRef as MatDialogRef
} from '@angular/material/legacy-dialog';
import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column flex-grow-1 h-100' },
selector: 'gf-subscription-interstitial-dialog',
styleUrls: ['./subscription-interstitial-dialog.scss'],
templateUrl: 'subscription-interstitial-dialog.html'
})
export class SubscriptionInterstitialDialog {
public constructor(
@Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams,
public dialogRef: MatDialogRef<SubscriptionInterstitialDialog>
) {}
public closeDialog() {
this.dialogRef.close({});
}
}

View File

@ -0,0 +1,50 @@
<h1 class="align-items-center d-flex" mat-dialog-title>
<span>Ghostfolio Premium</span>
<gf-premium-indicator
class="ml-1"
[enableLink]="false"
></gf-premium-indicator>
</h1>
<div class="flex-grow-1" mat-dialog-content>
<p class="h5" i18n>
Are you an ambitious investor who needs the full picture?
</p>
<p i18n>
By upgrading to Ghostfolio Premium, you will get these additional features:
</p>
<ul class="list-unstyled mb-3">
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>Portfolio Summary</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>Performance Benchmarks</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>Allocations</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>FIRE Calculator</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<span i18n>and more Features...</span>
</li>
</ul>
<p>Refine your personal investment strategy now.</p>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="closeDialog()">Skip</button>
<a
color="primary"
mat-flat-button
[routerLink]="['/pricing']"
(click)="closeDialog()"
>
<span i18n>Upgrade Plan</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a>
</div>

View File

@ -0,0 +1,21 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
import { RouterModule } from '@angular/router';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { SubscriptionInterstitialDialog } from './subscription-interstitial-dialog.component';
@NgModule({
declarations: [SubscriptionInterstitialDialog],
imports: [
CommonModule,
GfPremiumIndicatorModule,
MatButtonModule,
MatDialogModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfSubscriptionInterstitialDialogModule {}

View File

@ -0,0 +1,11 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
ion-icon[name='checkmark-circle-outline'] {
color: rgba(var(--palette-accent-500), 1);
}
}
}

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { MatRadioModule } from '@angular/material/radio';
import { MatLegacyRadioModule as MatRadioModule } from '@angular/material/legacy-radio';
import { ToggleComponent } from './toggle.component';

View File

@ -8,10 +8,10 @@ import {
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import {
MatSnackBar,
MatSnackBarRef,
TextOnlySnackBar
} from '@angular/material/snack-bar';
MatLegacySnackBar as MatSnackBar,
MatLegacySnackBarRef as MatSnackBarRef,
LegacyTextOnlySnackBar as TextOnlySnackBar
} from '@angular/material/legacy-snack-bar';
import { Router } from '@angular/router';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';

View File

@ -42,9 +42,11 @@
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
><ng-container *ngIf="hasPermissionForSubscription"
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
></ng-container
>
or open an issue at
<a
@ -60,7 +62,7 @@
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter" size="large"></ion-icon>
<ion-icon name="logo-twitter"></ion-icon>
</a>
<a
class="mx-2"
@ -68,7 +70,7 @@
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail" size="large"></ion-icon>
<ion-icon name="mail"></ion-icon>
</a>
<a
class="mx-2"
@ -76,7 +78,7 @@
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
<ion-icon name="logo-slack"></ion-icon>
</a>
<a
class="mx-2"
@ -84,7 +86,7 @@
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
<ion-icon name="logo-github"></ion-icon>
</a>
</p>
<div
@ -117,7 +119,7 @@
<div *ngIf="hasPermissionForStatistics" class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center">Ghostfolio in Numbers</h3>
<mat-card>
<mat-card appearance="outlined">
<mat-card-content>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
@ -189,7 +191,7 @@
<div class="row">
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/faq']"
@ -201,7 +203,7 @@
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
>
<a
class="py-2 w-100"
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/about', 'changelog']"
@ -210,7 +212,7 @@
</div>
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/about', 'privacy-policy']"
@ -219,7 +221,7 @@
</div>
<div *ngIf="hasPermissionForBlog" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/blog']"

View File

@ -2,7 +2,7 @@
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Changelog</h3>
<mat-card class="changelog">
<mat-card appearance="outlined" class="changelog">
<mat-card-content>
<markdown [src]="'../assets/CHANGELOG.md'"></markdown>
</mat-card-content>
@ -13,7 +13,7 @@
<div class="row">
<div class="col">
<h3 class="mb-3 text-center" i18n>License</h3>
<mat-card>
<mat-card appearance="outlined">
<mat-card-content>
<markdown [src]="'../assets/LICENSE'"></markdown>
</mat-card-content>

View File

@ -2,20 +2,18 @@
color: rgb(var(--dark-primary-text));
display: block;
.mat-card {
&.changelog {
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
}
.mat-mdc-card {
&.changelog {
::ng-deep {
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
markdown {
h1,
p {

View File

@ -5,16 +5,16 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog';
import {
MatSlideToggle,
MatSlideToggleChange
} from '@angular/material/slide-toggle';
MatLegacySlideToggle as MatSlideToggle,
MatLegacySlideToggleChange as MatSlideToggleChange
} from '@angular/material/legacy-slide-toggle';
import {
MatSnackBar,
MatSnackBarRef,
TextOnlySnackBar
} from '@angular/material/snack-bar';
MatLegacySnackBar as MatSnackBar,
MatLegacySnackBarRef as MatSnackBarRef,
LegacyTextOnlySnackBar as TextOnlySnackBar
} from '@angular/material/legacy-snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service';

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