Compare commits

...

62 Commits

Author SHA1 Message Date
25b3de5828 Release 2.60.0 (#3086) 2024-03-02 14:44:00 +01:00
40b454d2f3 Feature/refresh cryptocurrencies list 20240302 (#3085)
* Update cryptocurrencies.json

* Add UNI7083

* Update changelog
2024-03-02 14:42:40 +01:00
5596e5f03b Feature/integrate wealth items into transaction point concept (#3084)
* Integrate (wealth) items into transaction point concept

* Update changelog
2024-03-02 14:29:03 +01:00
66992ef915 Bugfix/change show condition of button to fetch current market price (#3079)
* Change show condition of button to fetch current market price

* Update changelog
2024-03-02 12:59:54 +01:00
7f67430685 Bugfix/readd value in base currency to activity (#3078)
* Readd valueInBaseCurrency

* Update changelog
2024-03-02 10:03:10 +01:00
8a49a04324 Feature/improve usability of benchmarks in markets overview (#3077)
* Improve icons, localize label

* Update changelog
2024-03-02 09:48:53 +01:00
5d7c19b0ed Fix typo (#3076) 2024-03-02 09:42:41 +01:00
cde74b6c62 Release 2.59.0 (#3069) 2024-02-29 21:06:18 +01:00
633c65e33c Feature/extend self hosting faq (#3068)
* Extend self-hosting FAQ

* Update changelog
2024-02-29 21:04:47 +01:00
d1617f2d87 Feature/add index for is excluded to account database table (#3067)
* Add index for isExcluded to account database table

* Update changelog
2024-02-29 20:50:44 +01:00
68e558f198 Feature/Improve activities import by ISIN number (#3051)
* Improve activities import by ISIN number

* Update changelog
2024-02-29 20:45:40 +01:00
12ca01c862 Update OSS friends (#3066) 2024-02-29 20:26:13 +01:00
2115745471 Bugfix/fix issue with exchange rate calculation of wealth items in accounts (#3065)
* Fix exchange rate calculatio of wealth items in accounts

* Update changelog
2024-02-29 20:14:52 +01:00
2cabd21315 Release 2.58.0 (#3061) 2024-02-27 20:59:44 +01:00
3615e2f057 Feature/improve handling of activities without account (#3060)
* Improve handling of activities without account

* Update changelog
2024-02-27 20:58:28 +01:00
d3679d41b3 Bugfix/fix query to filter activities of excluded accounts (#3059)
* Fix query to filter activities of excluded accounts

* Update changelog
2024-02-27 20:58:04 +01:00
f2d431a6b8 Bugfix/improve asset profile validation in activities import (#3057)
* Improve asset profile validation

* Update changelog
2024-02-27 20:42:23 +01:00
2bc8bebfb8 Clean up dist folders (#3053) 2024-02-26 20:17:18 +01:00
5b20ba3382 Release 2.57.0 (#3054) 2024-02-25 19:14:28 +01:00
15cc294581 Feature/move break down of performance from experimental to general availability (#3047)
* Move break down of performance to general availability

* Update changelog
2024-02-25 19:12:30 +01:00
b060b81204 Fix debugging with VS Code due to missing Source Map (#3050)
Fixes #2801
2024-02-25 19:10:17 +01:00
a8d557eb1b Disable parallel execution of commands causing race condition between mkdir and cp (#3052) 2024-02-25 19:03:28 +01:00
6ae3a47b54 Bugfix/change top and bottom performers to performance with currency effect (#3046)
* Change to performance with currency effect

* Update changelog
2024-02-25 13:43:49 +01:00
88c19eb45e Feature/restructure copy assets nx target (#3045)
* Restructure copy-assets Nx target

* Update changelog
2024-02-25 11:45:00 +01:00
7728706bc8 Release 2.56.0 (#3043) 2024-02-24 20:00:03 +01:00
2e9d40c201 Feature/switch to performance calculations with currency effects (#3039)
* Switch to performance calculations with currency effects

* Improve value redaction in portfolio details endpoint

* Update changelog
2024-02-24 19:58:13 +01:00
c002e37285 Feature/add missing default currency to prepare currencies function (#3042)
* Add missing default currency

* Update changelog
2024-02-24 19:45:51 +01:00
6be38a1c19 Feature/remove is default flag from account database schema (#3041)
* Remove isDefault flag from Account database schema

* Update changelog
2024-02-24 19:44:56 +01:00
a3178fb213 Feature/expose redis database via environment variable (#3036)
* Expose Redis database via environment variable

* Update changelog
2024-02-24 11:56:12 +01:00
e7158f6e16 Feature/upgrade prisma to version 5.10.2 (#3038)
* Upgrade prisma to version 5.10.2

* Update changelog
2024-02-24 11:09:13 +01:00
dbea0456bc Update changelog (#3035) 2024-02-23 19:58:33 +01:00
fefee11301 Release 2.55.0 (#3034) 2024-02-22 20:26:39 +01:00
40836b745b Feature/improve validation for currency in endpoints (#3030)
* Improve validation for currency

* Update changelog
2024-02-22 20:25:22 +01:00
07eabac059 Feature/add missing database indexes part 2 (#3033)
* Add missing database indexes (for orderBy and where clauses)

* Update changelog
2024-02-22 20:21:50 +01:00
48b412cfb8 Feature/harmonize setting of default locale (#3032)
* Harmonize setting of default locale

* Update changelog
2024-02-22 20:10:27 +01:00
b62488628c Prettify markup (#3029) 2024-02-21 09:58:15 +01:00
982c71c728 Feature/set angular parser in prettierrc (#3028)
* Set parser to angular

* Update changelog
2024-02-20 19:54:03 +01:00
5aa16a3779 Release 2.54.0 (#3027) 2024-02-19 19:47:37 +01:00
93de25e5b6 Feature/add missing database indexes (#3026)
* Add missing database indexes

* Update changelog
2024-02-19 19:45:52 +01:00
9acdb41aa2 Refactor params to object (#2987) 2024-02-19 19:32:10 +01:00
ffbdfb86ec Release 2.53.1 (#3025) 2024-02-18 18:56:34 +01:00
be7f6bb657 Feature/add inactive as user role (#3024)
* Add INACTIVE as user role

* Update changelog
2024-02-18 18:54:49 +01:00
6f7cbc93b9 Add missing type (#3023) 2024-02-18 18:54:36 +01:00
0b5c71130d Update OSS friends (#3008) 2024-02-18 18:50:04 +01:00
0578c645d1 Release 2.53.0 (#3021) 2024-02-18 14:39:10 +01:00
67ae86763e Handle premium data provider in getQuotes() (#3020)
* Handle premium data provider in getQuotes()
2024-02-18 14:37:42 +01:00
266c0a9a2c Feature/eliminate search request in get quotes of eod service (#3019)
* Eliminate search request to get quotes

* Update changelog
2024-02-18 14:14:25 +01:00
a3cdb23776 Feature/refactor query to filter activities of excluded accounts (#3016)
* Refactor query to filter activities of excluded accounts

* Update changelog
2024-02-18 12:29:00 +01:00
e1371a8d2b Clean up (#3018) 2024-02-18 12:27:34 +01:00
448cea0b69 Feature/improve usability of holdings table (#3017)
* Improve usability

* Update changelog
2024-02-18 10:18:53 +01:00
ad42c0bf28 Enable FAQ link for self-hoster (#3015) 2024-02-18 08:53:01 +01:00
f50670c7fe Feature/improve language localization for de 20240217 (#3014)
* Update translations

* Update changelog
2024-02-18 08:51:36 +01:00
c0029d3b1d Feature/upgrade ng extract i18n merge to version 2.10.0 (#3013)
* Upgrade ng-extract-i18n-merge to version 2.10.0

* Update changelog
2024-02-17 22:15:50 +01:00
2518a8fd9d Feature/add accounts tab to position detail dialog (#3012)
* Add accounts tab to position detail dialog

* Update changelog
2024-02-17 21:32:56 +01:00
572dcf075a Release 2.52.0 (#3011) 2024-02-16 20:05:01 +01:00
29cb83d469 Bugfix/improve x axis scale of dividend and investment timeline (#3010)
* Improve X-axis scale

* Update changelog
2024-02-16 20:03:20 +01:00
cac73ac111 Feature/divide faq page in three sections (#3003)
* Divide FAQ page in three sections

* General
* Cloud (SaaS)
* Self-Hosting

* Update changelog
2024-02-16 18:57:34 +01:00
02cf4295a9 Feature/add loading indicator to dividend and investment timelines (#3007)
* Add loading indicators

* Dividend timeline
* Investment timeline

* Update changelog
2024-02-16 09:43:51 +01:00
78b3328bf7 Add activities count (#3005) 2024-02-15 10:25:47 +01:00
e0d6d9e8ca Migrate if / else to control flow (#3001) 2024-02-13 20:46:21 +01:00
54310f2214 Feature/add support for jupiter cryptocurrency (#2999)
* Add JUP29210

* Update changelog
2024-02-13 19:46:15 +01:00
1fec49fbc2 Improve states (#3000) 2024-02-13 19:44:17 +01:00
195 changed files with 28348 additions and 24914 deletions

View File

@ -12,6 +12,12 @@
"importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"], "importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"],
"importOrderSeparation": true, "importOrderSeparation": true,
"overrides": [ "overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
},
{ {
"files": "*.ts", "files": "*.ts",
"options": { "options": {

View File

@ -5,6 +5,135 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.60.0 - 2024-03-02
- Added support for the cryptocurrency _Uniswap_ (`UNI7083-USD`)
### Changed
- Improved the usability of the benchmarks in the markets overview
- Integrated (wealth) items into the transaction point concept in the portfolio service
- Refreshed the cryptocurrencies list
### Fixed
- Fixed a missing value in the activities table on mobile
- Fixed a missing value on the public page
- Displayed the button to fetch the current market price only if the activity is from today
## 2.59.0 - 2024-02-29
### Added
- Added an index for `isExcluded` to the account database table
- Extended the content of the _Self-Hosting_ section on the Frequently Asked Questions (FAQ) page
### Changed
- Improved the activities import by `isin` in the _Yahoo Finance_ service
### Fixed
- Fixed an issue with the exchange rate calculation of (wealth) items in accounts
## 2.58.0 - 2024-02-27
### Changed
- Improved the handling of activities without account
### Fixed
- Fixed the query to filter activities of excluded accounts
- Improved the asset profile validation in the activities import
## 2.57.0 - 2024-02-25
### Changed
- Moved the break down of the performance into asset and currency on the analysis page from experimental to general availability
- Restructured the `copy-assets` `Nx` target
### Fixed
- Changed the performances of the _Top 3_ and _Bottom 3_ performers on the analysis page to take the currency effects into account
## 2.56.0 - 2024-02-24
### Changed
- Switched the performance calculations to take the currency effects into account
- Removed the `isDefault` flag from the `Account` database schema
- Exposed the database index of _Redis_ as an environment variable (`REDIS_DB`)
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.9.1` to `5.10.2`
### Fixed
- Added the missing default currency to the prepare currencies function in the exchange rate data service
## 2.55.0 - 2024-02-22
### Added
- Added indexes for `alias`, `granteeUserId` and `userId` to the access database table
- Added indexes for `currency`, `name` and `userId` to the account database table
- Added indexes for `accountId`, `date` and `updatedAt` to the account balance database table
- Added an index for `userId` to the auth device database table
- Added indexes for `marketPrice` and `state` to the market data database table
- Added indexes for `date`, `isDraft` and `userId` to the order database table
- Added an index for `name` to the platform database table
- Added indexes for `assetClass`, `currency`, `dataSource`, `isin`, `name` and `symbol` to the symbol profile database table
- Added an index for `userId` to the subscription database table
- Added an index for `name` to the tag database table
- Added indexes for `accessToken`, `createdAt`, `provider`, `role` and `thirdPartyId` to the user database table
### Changed
- Improved the validation for `currency` in various endpoints
- Harmonized the setting of a default locale in various components
- Set the parser to `angular` in the `prettier` options
## 2.54.0 - 2024-02-19
### Added
- Added an index for `id` to the account database table
- Added indexes for `dataSource` and `date` to the market data database table
- Added an index for `accountId` to the order database table
## 2.53.1 - 2024-02-18
### Added
- Added an accounts tab to the position detail dialog
- Added `INACTIVE` as a new user role
### Changed
- Improved the usability of the holdings table
- Refactored the query to filter activities of excluded accounts
- Eliminated the search request to get quotes in the _EOD Historical Data_ service
- Improved the language localization for German (`de`)
- Upgraded `ng-extract-i18n-merge` from version `2.9.1` to `2.10.0`
## 2.52.0 - 2024-02-16
### Added
- Added a loading indicator to the dividend timeline on the analysis page
- Added a loading indicator to the investment timeline on the analysis page
- Added support for the cryptocurrency _Jupiter_ (`JUP29210-USD`)
### Changed
- Divided the content of the Frequently Asked Questions (FAQ) page into three sections: _General_, _Cloud (SaaS)_ and _Self-Hosting_
### Fixed
- Fixed an issue with the X-axis scale of the dividend timeline on the analysis page
- Fixed an issue with the X-axis scale of the investment timeline on the analysis page
## 2.51.0 - 2024-02-12 ## 2.51.0 - 2024-02-12
### Changed ### Changed

View File

@ -99,6 +99,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database | | `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database | | `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `0` | The database index of _Redis_ |
| `REDIS_HOST` | | The host where _Redis_ is running | | `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ | | `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running | | `REDIS_PORT` | | The port where _Redis_ is running |

View File

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

View File

@ -63,7 +63,7 @@ export class AccountController {
{ Order: true } { Order: true }
); );
if (account?.isDefault || account?.Order.length > 0) { if (!account || account?.Order.length > 0) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN

View File

@ -21,10 +21,8 @@ export class AccountService {
public async account({ public async account({
id_userId id_userId
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> { }: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
const { id, userId } = id_userId;
const [account] = await this.accounts({ const [account] = await this.accounts({
where: { id, userId } where: id_userId
}); });
return account; return account;

View File

@ -1,6 +1,7 @@
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsISO4217CurrencyCode,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
@ -19,7 +20,7 @@ export class CreateAccountDto {
) )
comment?: string; comment?: string;
@IsString() @IsISO4217CurrencyCode()
currency: string; currency: string;
@IsOptional() @IsOptional()

View File

@ -1,6 +1,7 @@
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsISO4217CurrencyCode,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
@ -19,7 +20,7 @@ export class UpdateAccountDto {
) )
comment?: string; comment?: string;
@IsString() @IsISO4217CurrencyCode()
currency: string; currency: string;
@IsString() @IsString()

View File

@ -226,7 +226,7 @@ export class AdminService {
this.prismaService.symbolProfile.count({ where }) this.prismaService.symbolProfile.count({ where })
]); ]);
let marketData = assetProfiles.map( let marketData: AdminMarketDataItem[] = assetProfiles.map(
({ ({
_count, _count,
assetClass, assetClass,

View File

@ -2,6 +2,7 @@ import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
import { import {
IsArray, IsArray,
IsEnum, IsEnum,
IsISO4217CurrencyCode,
IsObject, IsObject,
IsOptional, IsOptional,
IsString IsString
@ -24,7 +25,7 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
countries?: Prisma.InputJsonArray; countries?: Prisma.InputJsonArray;
@IsString() @IsISO4217CurrencyCode()
@IsOptional() @IsOptional()
currency?: string; currency?: string;

View File

@ -53,6 +53,7 @@ import { UserModule } from './user/user.module';
BenchmarkModule, BenchmarkModule,
BullModule.forRoot({ BullModule.forRoot({
redis: { redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10),
host: process.env.REDIS_HOST, host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10), port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
password: process.env.REDIS_PASSWORD password: process.env.REDIS_PASSWORD

View File

@ -43,7 +43,7 @@ export class ImportController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async import( public async import(
@Body() importData: ImportDataDto, @Body() importData: ImportDataDto,
@Query('dryRun') isDryRun?: boolean @Query('dryRun') isDryRun = false
): Promise<ImportResponse> { ): Promise<ImportResponse> {
if ( if (
!hasPermission(this.request.user.permissions, permissions.createAccount) !hasPermission(this.request.user.permissions, permissions.createAccount)

View File

@ -570,17 +570,10 @@ export class ImportService {
[assetProfileIdentifier: string]: Partial<SymbolProfile>; [assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {}; } = {};
const uniqueActivitiesDto = uniqBy(
activitiesDto,
({ dataSource, symbol }) => {
return getAssetProfileIdentifier({ dataSource, symbol });
}
);
for (const [ for (const [
index, index,
{ currency, dataSource, symbol, type } { currency, dataSource, symbol, type }
] of uniqueActivitiesDto.entries()) { ] of activitiesDto.entries()) {
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) { if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
throw new Error( throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid` `activities.${index}.dataSource ("${dataSource}") is not valid`
@ -602,37 +595,33 @@ export class ImportService {
} }
} }
const assetProfile = { if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
currency, const assetProfile = {
...( currency,
await this.dataProviderService.getAssetProfiles([ ...(
{ dataSource, symbol } await this.dataProviderService.getAssetProfiles([
]) { dataSource, symbol }
)?.[symbol] ])
}; )?.[symbol]
};
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') { if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
}
if (assetProfile.currency !== currency) {
throw new Error(
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
);
}
} }
if ( assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
assetProfile.currency !== currency && assetProfile;
!this.exchangeRateDataService.hasCurrencyPair(
currency,
assetProfile.currency
)
) {
throw new Error(
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
);
}
} }
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
assetProfile;
} }
return assetProfiles; return assetProfiles;

View File

@ -60,10 +60,6 @@ export class InfoService {
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
if (this.configurationService.get('ENABLE_FEATURE_BLOG')) {
globalPermissions.push(permissions.enableBlog);
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource( info.fearAndGreedDataSource = encodeDataSource(

View File

@ -10,6 +10,7 @@ import {
IsArray, IsArray,
IsBoolean, IsBoolean,
IsEnum, IsEnum,
IsISO4217CurrencyCode,
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
@ -38,7 +39,7 @@ export class CreateOrderDto {
) )
comment?: string; comment?: string;
@IsString() @IsISO4217CurrencyCode()
currency: string; currency: string;
@IsOptional() @IsOptional()

View File

@ -8,7 +8,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -200,6 +200,17 @@ export class OrderService {
return count; return count;
} }
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
return this.prismaService.order.findFirst({
orderBy: {
date: 'desc'
},
where: {
SymbolProfile: { dataSource, symbol }
}
});
}
public async getOrders({ public async getOrders({
filters, filters,
includeDrafts = false, includeDrafts = false,
@ -292,13 +303,14 @@ export class OrderService {
} }
if (types) { if (types) {
where.OR = types.map((type) => { where.type = { in: types };
return { }
type: {
equals: type if (withExcludedAccounts === false) {
} where.OR = [
}; { Account: null },
}); { Account: { NOT: { isExcluded: true } } }
];
} }
const [orders, count] = await Promise.all([ const [orders, count] = await Promise.all([
@ -322,32 +334,24 @@ export class OrderService {
this.prismaService.order.count({ where }) this.prismaService.order.count({ where })
]); ]);
const activities = orders const activities = orders.map((order) => {
.filter((order) => { const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return (
withExcludedAccounts ||
!order.Account ||
order.Account?.isExcluded === false
);
})
.map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return { return {
...order, ...order,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.SymbolProfile.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value, value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( order.SymbolProfile.currency,
order.fee, userCurrency
order.SymbolProfile.currency, )
userCurrency };
), });
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.SymbolProfile.currency,
userCurrency
)
};
});
return { activities, count }; return { activities, count };
} }

View File

@ -9,6 +9,7 @@ import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsArray, IsArray,
IsEnum, IsEnum,
IsISO4217CurrencyCode,
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
@ -37,7 +38,7 @@ export class UpdateOrderDto {
) )
comment?: string; comment?: string;
@IsString() @IsISO4217CurrencyCode()
currency: string; currency: string;
@IsString() @IsString()

View File

@ -107,7 +107,9 @@ describe('CurrentRateService', () => {
currentRateService = new CurrentRateService( currentRateService = new CurrentRateService(
dataProviderService, dataProviderService,
marketDataService marketDataService,
null,
null
); );
}); });

View File

@ -1,3 +1,4 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
@ -6,8 +7,10 @@ import {
ResponseError, ResponseError,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { isBefore, isToday } from 'date-fns'; import { isBefore, isToday } from 'date-fns';
import { flatten, isEmpty, uniqBy } from 'lodash'; import { flatten, isEmpty, uniqBy } from 'lodash';
@ -19,9 +22,12 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
export class CurrentRateService { export class CurrentRateService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
// TODO: Pass user instead of using this.request.user
public async getValues({ public async getValues({
dataGatheringItems, dataGatheringItems,
dateQuery dateQuery
@ -40,7 +46,7 @@ export class CurrentRateService {
if (includeToday) { if (includeToday) {
promises.push( promises.push(
this.dataProviderService this.dataProviderService
.getQuotes({ items: dataGatheringItems }) .getQuotes({ items: dataGatheringItems, user: this.request?.user })
.then((dataResultProvider) => { .then((dataResultProvider) => {
const result: GetValueObject[] = []; const result: GetValueObject[] = [];
@ -117,11 +123,17 @@ export class CurrentRateService {
}); });
if (!value) { if (!value) {
// Fallback to unit price of latest activity
const latestActivity = await this.orderService.getLatestOrder({
dataSource,
symbol
});
value = { value = {
dataSource, dataSource,
symbol, symbol,
date: today, date: today,
marketPrice: 0 marketPrice: latestActivity?.unitPrice ?? 0
}; };
response.values.push(value); response.values.push(value);

View File

@ -5,9 +5,10 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Tag } from '@prisma/client'; import { Account, Tag } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioPositionDetail {
accounts: Account[];
averagePrice: number; averagePrice: number;
dataProviderInfo: DataProviderInfo; dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number; dividendInBaseCurrency: number;

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,

View File

@ -34,7 +34,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,

View File

@ -34,7 +34,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,

View File

@ -10,7 +10,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,

View File

@ -825,6 +825,7 @@ export class PortfolioCalculator {
switch (type) { switch (type) {
case 'BUY': case 'BUY':
case 'ITEM':
factor = 1; factor = 1;
break; break;
case 'SELL': case 'SELL':

View File

@ -118,27 +118,23 @@ export class PortfolioController {
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
const totalInvestment = Object.values(holdings) const totalInvestment = Object.values(holdings)
.map((portfolioPosition) => { .map(({ investment }) => {
return portfolioPosition.investment; return investment;
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
const totalValue = Object.values(holdings) const totalValue = Object.values(holdings)
.map((portfolioPosition) => { .filter(({ assetClass, assetSubClass }) => {
return this.exchangeRateDataService.toCurrency( return assetClass !== 'CASH' && assetSubClass !== 'CASH';
portfolioPosition.quantity * portfolioPosition.marketPrice, })
portfolioPosition.currency, .map(({ valueInBaseCurrency }) => {
this.request.user.Settings.settings.baseCurrency return valueInBaseCurrency;
);
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPosition.grossPerformance = null;
portfolioPosition.investment = portfolioPosition.investment =
portfolioPosition.investment / totalInvestment; portfolioPosition.investment / totalInvestment;
portfolioPosition.netPerformance = null;
portfolioPosition.quantity = null;
portfolioPosition.valueInPercentage = portfolioPosition.valueInPercentage =
portfolioPosition.valueInBaseCurrency / totalValue; portfolioPosition.valueInBaseCurrency / totalValue;
} }
@ -346,7 +342,8 @@ export class PortfolioController {
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccounts = false @Query('withExcludedAccounts') withExcludedAccounts = false,
@Query('withItems') withItems = false
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const hasReadRestrictedAccessPermission = const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({ this.userService.hasReadRestrictedAccessPermission({
@ -365,6 +362,7 @@ export class PortfolioController {
filters, filters,
impersonationId, impersonationId,
withExcludedAccounts, withExcludedAccounts,
withItems,
userId: this.request.user.id userId: this.request.user.id
}); });
@ -429,6 +427,10 @@ export class PortfolioController {
return nullifyValuesInObject(item, ['totalInvestment', 'value']); return nullifyValuesInObject(item, ['totalInvestment', 'value']);
} }
); );
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,
['currentNetPerformance', 'currentNetPerformancePercent']
);
} }
return performanceInformation; return performanceInformation;
@ -515,7 +517,8 @@ export class PortfolioController {
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
markets: hasDetails ? portfolioPosition.markets : undefined, markets: hasDetails ? portfolioPosition.markets : undefined,
name: portfolioPosition.name, name: portfolioPosition.name,
netPerformancePercent: portfolioPosition.netPerformancePercent, netPerformancePercentWithCurrencyEffect:
portfolioPosition.netPerformancePercentWithCurrencyEffect,
sectors: hasDetails ? portfolioPosition.sectors : [], sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol, symbol: portfolioPosition.symbol,
url: portfolioPosition.url, url: portfolioPosition.url,

View File

@ -119,7 +119,7 @@ export class PortfolioService {
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<AccountWithValue[]> { }): Promise<AccountWithValue[]> {
const where: Prisma.AccountWhereInput = { userId: userId }; const where: Prisma.AccountWhereInput = { userId };
const accountFilter = filters?.find(({ type }) => { const accountFilter = filters?.find(({ type }) => {
return type === 'ACCOUNT'; return type === 'ACCOUNT';
@ -227,18 +227,20 @@ export class PortfolioService {
impersonationId: string; impersonationId: string;
}): Promise<InvestmentItem[]> { }): Promise<InvestmentItem[]> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
filters, filters,
userCurrency,
userId, userId,
types: ['DIVIDEND'], types: ['DIVIDEND']
userCurrency: this.request.user.Settings.settings.baseCurrency
}); });
let dividends = activities.map((dividend) => { let dividends = activities.map(({ date, valueInBaseCurrency }) => {
return { return {
date: format(dividend.date, DATE_FORMAT), date: format(date, DATE_FORMAT),
investment: dividend.valueInBaseCurrency investment: valueInBaseCurrency
}; };
}); });
@ -275,7 +277,8 @@ export class PortfolioService {
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
userId, userId,
includeDrafts: true includeDrafts: true,
types: ['BUY', 'SELL']
}); });
if (transactionPoints.length === 0) { if (transactionPoints.length === 0) {
@ -420,7 +423,7 @@ export class PortfolioService {
); );
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes({ items: dataGatheringItems }), this.dataProviderService.getQuotes({ user, items: dataGatheringItems }),
this.symbolProfileService.getSymbolProfiles(dataGatheringItems) this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
]); ]);
@ -529,12 +532,20 @@ export class PortfolioService {
grossPerformance: item.grossPerformance?.toNumber() ?? 0, grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent: grossPerformancePercent:
item.grossPerformancePercentage?.toNumber() ?? 0, item.grossPerformancePercentage?.toNumber() ?? 0,
grossPerformancePercentWithCurrencyEffect:
item.grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
grossPerformanceWithCurrencyEffect:
item.grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
investment: item.investment.toNumber(), investment: item.investment.toNumber(),
marketPrice: item.marketPrice, marketPrice: item.marketPrice,
marketState: dataProviderResponse?.marketState ?? 'delayed', marketState: dataProviderResponse?.marketState ?? 'delayed',
name: symbolProfile.name, name: symbolProfile.name,
netPerformance: item.netPerformance?.toNumber() ?? 0, netPerformance: item.netPerformance?.toNumber() ?? 0,
netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0, netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0,
netPerformancePercentWithCurrencyEffect:
item.netPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
netPerformanceWithCurrencyEffect:
item.netPerformanceWithCurrencyEffect?.toNumber() ?? 0,
quantity: item.quantity.toNumber(), quantity: item.quantity.toNumber(),
sectors: symbolProfile.sectors, sectors: symbolProfile.sectors,
symbol: item.symbol, symbol: item.symbol,
@ -657,6 +668,7 @@ export class PortfolioService {
if (orders.length <= 0) { if (orders.length <= 0) {
return { return {
tags, tags,
accounts: [],
averagePrice: undefined, averagePrice: undefined,
dataProviderInfo: undefined, dataProviderInfo: undefined,
dividendInBaseCurrency: undefined, dividendInBaseCurrency: undefined,
@ -691,7 +703,7 @@ export class PortfolioService {
.filter((order) => { .filter((order) => {
tags = tags.concat(order.tags); tags = tags.concat(order.tags);
return order.type === 'BUY' || order.type === 'SELL'; return ['BUY', 'ITEM', 'SELL'].includes(order.type);
}) })
.map((order) => ({ .map((order) => ({
currency: order.SymbolProfile.currency, currency: order.SymbolProfile.currency,
@ -739,6 +751,15 @@ export class PortfolioService {
transactionCount transactionCount
} = position; } = position;
const accounts: PortfolioPositionDetail['accounts'] = uniqBy(
orders.filter(({ Account }) => {
return Account;
}),
'Account.id'
).map(({ Account }) => {
return Account;
});
const dividendInBaseCurrency = getSum( const dividendInBaseCurrency = getSum(
orders orders
.filter(({ type }) => { .filter(({ type }) => {
@ -812,6 +833,7 @@ export class PortfolioService {
} }
return { return {
accounts,
firstBuyDate, firstBuyDate,
marketPrice, marketPrice,
maxPrice, maxPrice,
@ -852,6 +874,7 @@ export class PortfolioService {
}; };
} else { } else {
const currentData = await this.dataProviderService.getQuotes({ const currentData = await this.dataProviderService.getQuotes({
user,
items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }] items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }]
}); });
const marketPrice = currentData[aSymbol]?.marketPrice; const marketPrice = currentData[aSymbol]?.marketPrice;
@ -894,6 +917,7 @@ export class PortfolioService {
orders, orders,
SymbolProfile, SymbolProfile,
tags, tags,
accounts: [],
averagePrice: 0, averagePrice: 0,
dataProviderInfo: undefined, dataProviderInfo: undefined,
dividendInBaseCurrency: 0, dividendInBaseCurrency: 0,
@ -929,11 +953,13 @@ export class PortfolioService {
return type === 'SEARCH_QUERY'; return type === 'SEARCH_QUERY';
})?.id; })?.id;
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
userId userId,
types: ['BUY', 'SELL']
}); });
if (transactionPoints?.length <= 0) { if (transactionPoints?.length <= 0) {
@ -969,7 +995,7 @@ export class PortfolioService {
}); });
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes({ items: dataGatheringItems }), this.dataProviderService.getQuotes({ user, items: dataGatheringItems }),
this.symbolProfileService.getSymbolProfiles( this.symbolProfileService.getSymbolProfiles(
positions.map(({ dataSource, symbol }) => { positions.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
@ -1063,13 +1089,15 @@ export class PortfolioService {
filters, filters,
impersonationId, impersonationId,
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false,
withItems = false
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string; impersonationId: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
withItems?: boolean;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@ -1104,7 +1132,8 @@ export class PortfolioService {
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
userId, userId,
withExcludedAccounts withExcludedAccounts,
types: withItems ? ['BUY', 'ITEM', 'SELL'] : ['BUY', 'SELL']
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
@ -1256,7 +1285,8 @@ export class PortfolioService {
const { orders, portfolioOrders, transactionPoints } = const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
userId userId,
types: ['BUY', 'SELL']
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
@ -1588,12 +1618,16 @@ export class PortfolioService {
dateOfFirstActivity: undefined, dateOfFirstActivity: undefined,
grossPerformance: 0, grossPerformance: 0,
grossPerformancePercent: 0, grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
investment: balance, investment: balance,
marketPrice: 0, marketPrice: 0,
marketState: 'open', marketState: 'open',
name: currency, name: currency,
netPerformance: 0, netPerformance: 0,
netPerformancePercent: 0, netPerformancePercent: 0,
netPerformancePercentWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
quantity: 0, quantity: 0,
sectors: [], sectors: [],
symbol: currency, symbol: currency,
@ -1802,9 +1836,25 @@ export class PortfolioService {
}) })
?.toNumber(); ?.toNumber();
const annualizedPerformancePercentWithCurrencyEffect =
new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
orders: []
})
.getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent: new Big(
performanceInformation.performance.currentNetPerformancePercentWithCurrencyEffect
)
})
?.toNumber();
return { return {
...performanceInformation.performance, ...performanceInformation.performance,
annualizedPerformancePercent, annualizedPerformancePercent,
annualizedPerformancePercentWithCurrencyEffect,
cash, cash,
dividend, dividend,
excludedAccountsAndActivities, excludedAccountsAndActivities,
@ -1869,11 +1919,13 @@ export class PortfolioService {
private async getTransactionPoints({ private async getTransactionPoints({
filters, filters,
includeDrafts = false, includeDrafts = false,
types = ['BUY', 'ITEM', 'SELL'],
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
types?: ActivityType[];
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<{ }): Promise<{
@ -1887,10 +1939,10 @@ export class PortfolioService {
const { activities, count } = await this.orderService.getOrders({ const { activities, count } = await this.orderService.getOrders({
filters, filters,
includeDrafts, includeDrafts,
types,
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts, withExcludedAccounts
types: ['BUY', 'SELL']
}); });
if (count <= 0) { if (count <= 0) {
@ -1950,7 +2002,7 @@ export class PortfolioService {
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
orders: OrderWithAccount[]; orders: Activity[];
portfolioItemsNow: { [p: string]: TimelinePosition }; portfolioItemsNow: { [p: string]: TimelinePosition };
userCurrency: string; userCurrency: string;
userId: string; userId: string;
@ -1962,7 +2014,7 @@ export class PortfolioService {
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts, withExcludedAccounts,
types: ['ITEM', 'LIABILITY'] types: ['LIABILITY']
}); });
const accounts: PortfolioDetails['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
@ -2046,41 +2098,42 @@ export class PortfolioService {
}; };
} }
for (const order of ordersByAccount) { for (const {
Account,
quantity,
SymbolProfile,
type
} of ordersByAccount) {
let currentValueOfSymbolInBaseCurrency = let currentValueOfSymbolInBaseCurrency =
order.quantity * quantity *
(portfolioItemsNow[order.SymbolProfile.symbol] portfolioItemsNow[SymbolProfile.symbol]
?.marketPriceInBaseCurrency ?? ?.marketPriceInBaseCurrency ?? 0;
order.unitPrice ??
0);
if (order.type === 'LIABILITY' || order.type === 'SELL') { if (['LIABILITY', 'SELL'].includes(type)) {
currentValueOfSymbolInBaseCurrency *= -1; currentValueOfSymbolInBaseCurrency *= -1;
} }
if (accounts[order.Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { if (accounts[Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
accounts[order.Account?.id || UNKNOWN_KEY].valueInBaseCurrency += accounts[Account?.id || UNKNOWN_KEY].valueInBaseCurrency +=
currentValueOfSymbolInBaseCurrency; currentValueOfSymbolInBaseCurrency;
} else { } else {
accounts[order.Account?.id || UNKNOWN_KEY] = { accounts[Account?.id || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: order.Account?.currency, currency: Account?.currency,
name: account.name, name: account.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
}; };
} }
if ( if (
platforms[order.Account?.Platform?.id || UNKNOWN_KEY] platforms[Account?.Platform?.id || UNKNOWN_KEY]?.valueInBaseCurrency
?.valueInBaseCurrency
) { ) {
platforms[ platforms[Account?.Platform?.id || UNKNOWN_KEY].valueInBaseCurrency +=
order.Account?.Platform?.id || UNKNOWN_KEY currentValueOfSymbolInBaseCurrency;
].valueInBaseCurrency += currentValueOfSymbolInBaseCurrency;
} else { } else {
platforms[order.Account?.Platform?.id || UNKNOWN_KEY] = { platforms[Account?.Platform?.id || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: order.Account?.currency, currency: Account?.currency,
name: account.Platform?.name, name: account.Platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
}; };

View File

@ -15,6 +15,7 @@ import { RedisCacheService } from './redis-cache.service';
inject: [ConfigurationService], inject: [ConfigurationService],
useFactory: async (configurationService: ConfigurationService) => { useFactory: async (configurationService: ConfigurationService) => {
return <RedisClientOptions>{ return <RedisClientOptions>{
db: configurationService.get('REDIS_DB'),
host: configurationService.get('REDIS_HOST'), host: configurationService.get('REDIS_HOST'),
max: configurationService.get('MAX_ITEM_IN_CACHE'), max: configurationService.get('MAX_ITEM_IN_CACHE'),
password: configurationService.get('REDIS_PASSWORD'), password: configurationService.get('REDIS_PASSWORD'),

View File

@ -39,7 +39,7 @@ export class SymbolController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol( public async lookupSymbol(
@Query('includeIndices') includeIndices: boolean = false, @Query('includeIndices') includeIndices = false,
@Query('query') query = '' @Query('query') query = ''
): Promise<{ items: LookupItem[] }> { ): Promise<{ items: LookupItem[] }> {
try { try {

View File

@ -7,6 +7,7 @@ import type {
import { import {
IsArray, IsArray,
IsBoolean, IsBoolean,
IsISO4217CurrencyCode,
IsISO8601, IsISO8601,
IsIn, IsIn,
IsNumber, IsNumber,
@ -19,8 +20,8 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
annualInterestRate?: number; annualInterestRate?: number;
@IsISO4217CurrencyCode()
@IsOptional() @IsOptional()
@IsString()
baseCurrency?: string; baseCurrency?: string;
@IsString() @IsString()

View File

@ -2,7 +2,11 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { User, UserSettings } from '@ghostfolio/common/interfaces'; import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import {
hasPermission,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -59,6 +63,13 @@ export class UserController {
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string @Headers('accept-language') acceptLanguage: string
): Promise<User> { ): Promise<User> {
if (hasRole(this.request.user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
return this.userService.getUser( return this.userService.getUser(
this.request.user, this.request.user,
acceptLanguage?.split(',')?.[0] acceptLanguage?.split(',')?.[0]

View File

@ -1,11 +1,13 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
locale locale
@ -31,6 +33,8 @@ const crypto = require('crypto');
@Injectable() @Injectable()
export class UserService { export class UserService {
private i18nService = new I18nService();
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -325,8 +329,10 @@ export class UserService {
Account: { Account: {
create: { create: {
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
isDefault: true, name: this.i18nService.getTranslation({
name: 'Default Account' id: 'myAccount',
languageCode: DEFAULT_LANGUAGE_CODE // TODO
})
} }
}, },
Settings: { Settings: {
@ -438,7 +444,7 @@ export class UserService {
settings settings
}, },
where: { where: {
userId: userId userId
} }
}); });

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,10 @@
{ {
"CYBER24781": "CyberConnect", "CYBER24781": "CyberConnect",
"JUP29210": "Jupiter",
"LUNA1": "Terra", "LUNA1": "Terra",
"LUNA2": "Terra", "LUNA2": "Terra",
"SGB1": "Songbird", "SGB1": "Songbird",
"UNI1": "Uniswap", "UNI1": "Uniswap",
"UNI7083": "Uniswap",
"UST": "TerraUSD" "UST": "TerraUSD"
} }

View File

@ -370,6 +370,14 @@
<loc>https://ghostfol.io/en/faq</loc> <loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/saas</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/self-hosting</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/features</loc> <loc>https://ghostfol.io/en/features</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>

View File

@ -51,8 +51,10 @@ export class RedactValuesInResponseInterceptor<T>
'feeInBaseCurrency', 'feeInBaseCurrency',
'filteredValueInBaseCurrency', 'filteredValueInBaseCurrency',
'grossPerformance', 'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'investment', 'investment',
'netPerformance', 'netPerformance',
'netPerformanceWithCurrencyEffect',
'quantity', 'quantity',
'symbolMapping', 'symbolMapping',
'totalBalanceInBaseCurrency', 'totalBalanceInBaseCurrency',

View File

@ -27,7 +27,6 @@ export class ConfigurationService {
DATA_SOURCES: json({ DATA_SOURCES: json({
default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO] default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO]
}), }),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
@ -44,6 +43,7 @@ export class ConfigurationService {
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }), MAX_ITEM_IN_CACHE: num({ default: 9999 }),
PORT: port({ default: 3333 }), PORT: port({ default: 3333 }),
REDIS_DB: num({ default: 0 }),
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }), REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),

View File

@ -37,12 +37,14 @@ export class AlphaVantageService implements DataProviderInterface {
return !!this.configurationService.get('API_KEY_ALPHA_VANTAGE'); return !!this.configurationService.get('API_KEY_ALPHA_VANTAGE');
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return { return {
dataSource: this.getName(), symbol,
symbol: aSymbol dataSource: this.getName()
}; };
} }

View File

@ -52,15 +52,17 @@ export class CoinGeckoService implements DataProviderInterface {
return true; return true;
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = { const response: Partial<SymbolProfile> = {
symbol,
assetClass: AssetClass.CASH, assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY, assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
dataSource: this.getName(), dataSource: this.getName()
symbol: aSymbol
}; };
try { try {
@ -70,7 +72,7 @@ export class CoinGeckoService implements DataProviderInterface {
abortController.abort(); abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT')); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { name } = await got(`${this.apiUrl}/coins/${aSymbol}`, { const { name } = await got(`${this.apiUrl}/coins/${symbol}`, {
headers: this.headers, headers: this.headers,
// @ts-ignore // @ts-ignore
signal: abortController.signal signal: abortController.signal
@ -81,7 +83,7 @@ export class CoinGeckoService implements DataProviderInterface {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to get the asset profile for ${aSymbol} was aborted because the request to the data provider took more than ${this.configurationService.get( message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT' 'REQUEST_TIMEOUT'
)}ms`; )}ms`;
} }

View File

@ -196,7 +196,9 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
shortName: assetProfile.price.shortName, shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol symbol: assetProfile.price.symbol
}); });
response.symbol = assetProfile.price.symbol; response.symbol = this.convertFromYahooFinanceSymbol(
assetProfile.price.symbol
);
if (assetSubClass === AssetSubClass.MUTUALFUND) { if (assetSubClass === AssetSubClass.MUTUALFUND) {
response.sectors = []; response.sectors = [];

View File

@ -92,7 +92,9 @@ export class DataProviderService {
for (const symbol of symbols) { for (const symbol of symbols) {
const promise = Promise.resolve( const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol) this.getDataProvider(DataSource[dataSource]).getAssetProfile({
symbol
})
); );
promises.push( promises.push(
@ -335,11 +337,13 @@ export class DataProviderService {
public async getQuotes({ public async getQuotes({
items, items,
requestTimeout, requestTimeout,
useCache = true useCache = true,
user
}: { }: {
items: UniqueAsset[]; items: UniqueAsset[];
requestTimeout?: number; requestTimeout?: number;
useCache?: boolean; useCache?: boolean;
user?: UserWithSettings;
}): Promise<{ }): Promise<{
[symbol: string]: IDataProviderResponse; [symbol: string]: IDataProviderResponse;
}> { }> {
@ -405,6 +409,14 @@ export class DataProviderService {
)) { )) {
const dataProvider = this.getDataProvider(DataSource[dataSource]); const dataProvider = this.getDataProvider(DataSource[dataSource]);
if (
dataProvider.getDataProviderInfo().isPremium &&
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
user?.subscription.type === 'Basic'
) {
continue;
}
const symbols = dataGatheringItems.map((dataGatheringItem) => { const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol; return dataGatheringItem.symbol;
}); });

View File

@ -11,6 +11,7 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
REPLACE_NAME_PARTS REPLACE_NAME_PARTS
@ -35,7 +36,8 @@ export class EodHistoricalDataService implements DataProviderInterface {
private readonly URL = 'https://eodhistoricaldata.com/api'; private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService
) { ) {
this.apiKey = this.configurationService.get('API_KEY_EOD_HISTORICAL_DATA'); this.apiKey = this.configurationService.get('API_KEY_EOD_HISTORICAL_DATA');
} }
@ -44,19 +46,21 @@ export class EodHistoricalDataService implements DataProviderInterface {
return true; return true;
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
const [searchResult] = await this.getSearchResult(aSymbol); symbol: string;
}): Promise<Partial<SymbolProfile>> {
const [searchResult] = await this.getSearchResult(symbol);
return { return {
symbol,
assetClass: searchResult?.assetClass, assetClass: searchResult?.assetClass,
assetSubClass: searchResult?.assetSubClass, assetSubClass: searchResult?.assetSubClass,
currency: this.convertCurrency(searchResult?.currency), currency: this.convertCurrency(searchResult?.currency),
dataSource: this.getName(), dataSource: this.getName(),
isin: searchResult?.isin, isin: searchResult?.isin,
name: searchResult?.name, name: searchResult?.name
symbol: aSymbol
}; };
} }
@ -228,27 +232,22 @@ export class EodHistoricalDataService implements DataProviderInterface {
? [realTimeResponse] ? [realTimeResponse]
: realTimeResponse; : realTimeResponse;
const searchResponse = await Promise.all( const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
eodHistoricalDataSymbols symbols.map((symbol) => {
.filter((symbol) => { return {
return !symbol.endsWith('.FOREX'); symbol,
}) dataSource: this.getName()
.map((symbol) => { };
return this.search({ query: symbol }); })
})
); );
const lookupItems = searchResponse.flat().map(({ items }) => {
return items[0];
});
response = quotes.reduce( response = quotes.reduce(
( (
result: { [symbol: string]: IDataProviderResponse }, result: { [symbol: string]: IDataProviderResponse },
{ close, code, timestamp } { close, code, timestamp }
) => { ) => {
const currency = lookupItems.find((lookupItem) => { const currency = symbolProfiles.find(({ symbol }) => {
return lookupItem.symbol === code; return symbol === code;
})?.currency; })?.currency;
if (isNumber(close)) { if (isNumber(close)) {

View File

@ -37,12 +37,14 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return true; return true;
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return { return {
dataSource: this.getName(), symbol,
symbol: aSymbol dataSource: this.getName()
}; };
} }

View File

@ -33,12 +33,14 @@ export class GoogleSheetsService implements DataProviderInterface {
return true; return true;
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return { return {
dataSource: this.getName(), symbol,
symbol: aSymbol dataSource: this.getName()
}; };
} }

View File

@ -11,7 +11,11 @@ import { DataSource, SymbolProfile } from '@prisma/client';
export interface DataProviderInterface { export interface DataProviderInterface {
canHandle(symbol: string): boolean; canHandle(symbol: string): boolean;
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>; getAssetProfile({
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>>;
getDataProviderInfo(): DataProviderInfo; getDataProviderInfo(): DataProviderInfo;

View File

@ -43,16 +43,18 @@ export class ManualService implements DataProviderInterface {
return true; return true;
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const assetProfile: Partial<SymbolProfile> = { const assetProfile: Partial<SymbolProfile> = {
dataSource: this.getName(), symbol,
symbol: aSymbol dataSource: this.getName()
}; };
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([ const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource: this.getName(), symbol: aSymbol } { symbol, dataSource: this.getName() }
]); ]);
if (symbolProfile) { if (symbolProfile) {
@ -164,13 +166,15 @@ export class ManualService implements DataProviderInterface {
} }
}); });
for (const symbolProfile of symbolProfiles) { for (const { currency, symbol } of symbolProfiles) {
response[symbolProfile.symbol] = { let marketPrice = marketData.find((marketDataItem) => {
currency: symbolProfile.currency, return marketDataItem.symbol === symbol;
})?.marketPrice;
response[symbol] = {
currency,
marketPrice,
dataSource: this.getName(), dataSource: this.getName(),
marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
})?.marketPrice,
marketState: 'delayed' marketState: 'delayed'
}; };
} }

View File

@ -30,12 +30,14 @@ export class RapidApiService implements DataProviderInterface {
return !!this.configurationService.get('API_KEY_RAPID_API'); return !!this.configurationService.get('API_KEY_RAPID_API');
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return { return {
dataSource: this.getName(), symbol,
symbol: aSymbol dataSource: this.getName()
}; };
} }

View File

@ -33,20 +33,12 @@ export class YahooFinanceService implements DataProviderInterface {
return true; return true;
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
const { assetClass, assetSubClass, currency, name, symbol } = symbol: string;
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol); }): Promise<Partial<SymbolProfile>> {
return this.yahooFinanceDataEnhancerService.getAssetProfile(symbol);
return {
assetClass,
assetSubClass,
currency,
name,
symbol,
dataSource: this.getName()
};
} }
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {

View File

@ -451,7 +451,7 @@ export class ExchangeRateDataService {
} }
private async prepareCurrencies(): Promise<string[]> { private async prepareCurrencies(): Promise<string[]> {
let currencies: string[] = []; let currencies: string[] = [DEFAULT_CURRENCY];
( (
await this.prismaService.account.findMany({ await this.prismaService.account.findMany({

View File

@ -15,7 +15,6 @@ export interface Environment extends CleanedEnvAccessors {
DATA_SOURCE_EXCHANGE_RATES: string; DATA_SOURCE_EXCHANGE_RATES: string;
DATA_SOURCE_IMPORT: string; DATA_SOURCE_IMPORT: string;
DATA_SOURCES: string[]; DATA_SOURCES: string[];
ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
@ -31,6 +30,7 @@ export interface Environment extends CleanedEnvAccessors {
MAX_ACTIVITIES_TO_IMPORT: number; MAX_ACTIVITIES_TO_IMPORT: number;
MAX_ITEM_IN_CACHE: number; MAX_ITEM_IN_CACHE: number;
PORT: number; PORT: number;
REDIS_DB: number;
REDIS_HOST: string; REDIS_HOST: string;
REDIS_PASSWORD: string; REDIS_PASSWORD: string;
REDIS_PORT: number; REDIS_PORT: number;

View File

@ -13,13 +13,13 @@
"build": { "build": {
"executor": "@nx/angular:webpack-browser", "executor": "@nx/angular:webpack-browser",
"options": { "options": {
"deleteOutputPath": false,
"localize": true, "localize": true,
"outputPath": "dist/apps/client", "outputPath": "dist/apps/client",
"index": "apps/client/src/index.html", "index": "apps/client/src/index.html",
"main": "apps/client/src/main.ts", "main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts", "polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json", "tsConfig": "apps/client/tsconfig.app.json",
"assets": [],
"styles": [ "styles": [
"apps/client/src/assets/fonts/inter.css", "apps/client/src/assets/fonts/inter.css",
"apps/client/src/styles/theme.scss", "apps/client/src/styles/theme.scss",
@ -108,13 +108,22 @@
"options": { "options": {
"commands": [ "commands": [
{ {
"command": "shx mkdir -p dist/apps/client" "command": "shx rm -rf dist/apps/client"
}, },
{ {
"command": "shx cp -r apps/client/src/assets dist/apps/client" "command": "shx mkdir -p dist/apps/client/.well-known"
}, },
{ {
"command": "shx cp -r apps/client/src/assets/.well-known dist/apps/client" "command": "shx mkdir -p dist/apps/client/assets"
},
{
"command": "shx mkdir -p dist/apps/client/ionicons"
},
{
"command": "shx cp -r apps/client/src/assets/* dist/apps/client/assets"
},
{
"command": "shx cp -r apps/client/src/assets/.well-known/* dist/apps/client/.well-known"
}, },
{ {
"command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client" "command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client"
@ -128,9 +137,6 @@
{ {
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client" "command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
}, },
{
"command": "shx cp -r apps/client/src/locales dist/apps/api/assets"
},
{ {
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client" "command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
}, },
@ -138,7 +144,7 @@
"command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client" "command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
}, },
{ {
"command": "shx cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons" "command": "shx cp -r node_modules/ionicons/dist/ionicons/* dist/apps/client/ionicons"
}, },
{ {
"command": "shx cp CHANGELOG.md dist/apps/client/assets" "command": "shx cp CHANGELOG.md dist/apps/client/assets"
@ -146,7 +152,8 @@
{ {
"command": "shx cp LICENSE dist/apps/client/assets" "command": "shx cp LICENSE dist/apps/client/assets"
} }
] ],
"parallel": false
} }
}, },
"serve": { "serve": {

View File

@ -64,7 +64,7 @@
<div class="h6 mt-2">Ghostfolio</div> <div class="h6 mt-2">Ghostfolio</div>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li><a i18n [routerLink]="routerLinkAbout">About</a></li> <li><a i18n [routerLink]="routerLinkAbout">About</a></li>
<li *ngIf="hasPermissionForBlog"> <li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li> <li>

View File

@ -38,7 +38,6 @@ export class AppComponent implements OnDestroy, OnInit {
public currentYear = new Date().getFullYear(); public currentYear = new Date().getFullYear();
public deviceType: string; public deviceType: string;
public hasInfoMessage: boolean; public hasInfoMessage: boolean;
public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean; public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
@ -81,11 +80,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
this.hasPermissionForBlog = hasPermission(
this.info?.globalPermissions,
permissions.enableBlog
);
this.hasPermissionForSubscription = hasPermission( this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions, this.info?.globalPermissions,
permissions.enableSubscription permissions.enableSubscription
@ -111,6 +105,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.hasTabs = this.hasTabs =
(this.currentRoute === this.routerLinkAbout[0].slice(1) || (this.currentRoute === this.routerLinkAbout[0].slice(1) ||
this.currentRoute === this.routerLinkFaq[0].slice(1) ||
this.currentRoute === 'account' || this.currentRoute === 'account' ||
this.currentRoute === 'admin' || this.currentRoute === 'admin' ||
this.currentRoute === 'home' || this.currentRoute === 'home' ||
@ -120,7 +115,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.showFooter = this.showFooter =
(this.currentRoute === 'blog' || (this.currentRoute === 'blog' ||
this.currentRoute === this.routerLinkFaq[0].slice(1) ||
this.currentRoute === this.routerLinkFeatures[0].slice(1) || this.currentRoute === this.routerLinkFeatures[0].slice(1) ||
this.currentRoute === this.routerLinkMarkets[0].slice(1) || this.currentRoute === this.routerLinkMarkets[0].slice(1) ||
this.currentRoute === 'open' || this.currentRoute === 'open' ||

View File

@ -227,7 +227,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
} }
], ],
range: 'max', range: 'max',
withExcludedAccounts: true withExcludedAccounts: true,
withItems: true
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => { .subscribe(({ chart }) => {

View File

@ -25,7 +25,9 @@
class="h-100" class="h-100"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[isInPercent]="data.hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="
data.hasImpersonationId || user.settings.isRestrictedView
"
[isLoading]="isLoadingChart" [isLoading]="isLoadingChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />
@ -77,6 +79,7 @@
<gf-holdings-table <gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToOpenDetails]="false"
[holdings]="holdings" [holdings]="holdings"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />
@ -91,7 +94,9 @@
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!data.hasImpersonationId && !user.settings.isRestrictedView" [hasPermissionToExportActivities]="
!data.hasImpersonationId && !user.settings.isRestrictedView
"
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
@ -112,7 +117,11 @@
[accountBalances]="accountBalances" [accountBalances]="accountBalances"
[accountId]="data.accountId" [accountId]="data.accountId"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]="!data.hasImpersonationId && hasPermissionToDeleteAccountBalance && !user.settings.isRestrictedView" [showActions]="
!data.hasImpersonationId &&
hasPermissionToDeleteAccountBalance &&
!user.settings.isRestrictedView
"
(accountBalanceDeleted)="onDeleteAccountBalance($event)" (accountBalanceDeleted)="onDeleteAccountBalance($event)"
/> />
</mat-tab> </mat-tab>

View File

@ -40,12 +40,7 @@
[tooltip]="element.Platform?.name" [tooltip]="element.Platform?.name"
[url]="element.Platform?.url" [url]="element.Platform?.url"
/> />
<span>{{ element.name }} </span> <span>{{ element.name }}</span>
<span
*ngIf="element.isDefault"
class="d-lg-inline-block d-none text-muted"
>(Default)</span
>
</td> </td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td> <td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
</ng-container> </ng-container>
@ -261,7 +256,7 @@
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.isDefault || element.transactionCount > 0" [disabled]="element.transactionCount > 0"
(click)="onDeleteAccount(element.id)" (click)="onDeleteAccount(element.id)"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
@ -277,14 +272,16 @@
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr <tr
*matRowDef="let row; columns: displayedColumns" *matRowDef="let row; columns: displayedColumns"
class="cursor-pointer"
mat-row mat-row
[ngClass]="{
'cursor-pointer': hasPermissionToOpenDetails
}"
(click)="onOpenAccountDetailDialog(row.id)" (click)="onOpenAccountDetailDialog(row.id)"
></tr> ></tr>
<tr <tr
*matFooterRowDef="displayedColumns" *matFooterRowDef="displayedColumns"
mat-footer-row mat-footer-row
[ngClass]="{ 'd-none': isLoading }" [ngClass]="{ 'd-none': isLoading || !showFooter }"
></tr> ></tr>
</table> </table>

View File

@ -1,3 +1,5 @@
import { getLocale } from '@ghostfolio/common/helper';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@ -26,8 +28,14 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() accounts: AccountModel[]; @Input() accounts: AccountModel[];
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() locale: string; @Input() hasPermissionToOpenDetails = true;
@Input() locale = getLocale();
@Input() showActions: boolean; @Input() showActions: boolean;
@Input() showBalance = true;
@Input() showFooter = true;
@Input() showTransactions = true;
@Input() showValue = true;
@Input() showValueInBaseCurrency = true;
@Input() totalBalanceInBaseCurrency: number; @Input() totalBalanceInBaseCurrency: number;
@Input() totalValueInBaseCurrency: number; @Input() totalValueInBaseCurrency: number;
@Input() transactionCount: number; @Input() transactionCount: number;
@ -51,17 +59,27 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnInit() {} public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = [ this.displayedColumns = ['status', 'account', 'platform'];
'status',
'account', if (this.showTransactions) {
'platform', this.displayedColumns.push('transactions');
'transactions', }
'balance',
'value', if (this.showBalance) {
'currency', this.displayedColumns.push('balance');
'valueInBaseCurrency', }
'comment'
]; if (this.showValue) {
this.displayedColumns.push('value');
}
this.displayedColumns.push('currency');
if (this.showValueInBaseCurrency) {
this.displayedColumns.push('valueInBaseCurrency');
}
this.displayedColumns.push('comment');
if (this.showActions) { if (this.showActions) {
this.displayedColumns.push('actions'); this.displayedColumns.push('actions');
@ -89,9 +107,11 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
} }
public onOpenAccountDetailDialog(accountId: string) { public onOpenAccountDetailDialog(accountId: string) {
this.router.navigate([], { if (this.hasPermissionToOpenDetails) {
queryParams: { accountId, accountDetailDialog: true } this.router.navigate([], {
}); queryParams: { accountId, accountDetailDialog: true }
});
}
} }
public onOpenComment(aComment: string) { public onOpenComment(aComment: string) {

View File

@ -163,7 +163,12 @@
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before"> <mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button <button
mat-menu-item mat-menu-item
(click)="onOpenAssetProfileDialog({ dataSource: element.dataSource, symbol: element.symbol })" (click)="
onOpenAssetProfileDialog({
dataSource: element.dataSource,
symbol: element.symbol
})
"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" /> <ion-icon class="mr-2" name="create-outline" />
@ -173,7 +178,12 @@
<button <button
mat-menu-item mat-menu-item
[disabled]="element.activitiesCount !== 0" [disabled]="element.activitiesCount !== 0"
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})" (click)="
onDeleteProfileData({
dataSource: element.dataSource,
symbol: element.symbol
})
"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" /> <ion-icon class="mr-2" name="trash-outline" />
@ -189,16 +199,19 @@
*matRowDef="let row; columns: displayedColumns" *matRowDef="let row; columns: displayedColumns"
class="cursor-pointer" class="cursor-pointer"
mat-row mat-row
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })" (click)="
onOpenAssetProfileDialog({
dataSource: row.dataSource,
symbol: row.symbol
})
"
></tr> ></tr>
</table> </table>
<mat-paginator <mat-paginator
[length]="totalItems" [length]="totalItems"
[ngClass]="{ [ngClass]="{
'd-none': 'd-none': (isLoading && totalItems === 0) || totalItems <= pageSize
(isLoading && totalItems === 0) ||
totalItems <= pageSize
}" }"
[pageSize]="pageSize" [pageSize]="pageSize"
[showFirstLastButtons]="true" [showFirstLastButtons]="true"

View File

@ -91,7 +91,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
private snackBar: MatSnackBar private snackBar: MatSnackBar
) {} ) {}
public ngOnInit(): void { public ngOnInit() {
const { benchmarks, currencies } = this.dataService.fetchInfo(); const { benchmarks, currencies } = this.dataService.fetchInfo();
this.benchmarks = benchmarks; this.benchmarks = benchmarks;
@ -167,7 +167,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}); });
} }
public onClose(): void { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }

View File

@ -25,7 +25,9 @@
mat-menu-item mat-menu-item
type="button" type="button"
[disabled]="assetProfileForm.dirty" [disabled]="assetProfileForm.dirty"
(click)="onGatherSymbol({dataSource: data.dataSource, symbol: data.symbol})" (click)="
onGatherSymbol({ dataSource: data.dataSource, symbol: data.symbol })
"
> >
<ng-container i18n>Gather Historical Data</ng-container> <ng-container i18n>Gather Historical Data</ng-container>
</button> </button>
@ -33,7 +35,12 @@
mat-menu-item mat-menu-item
type="button" type="button"
[disabled]="assetProfileForm.dirty" [disabled]="assetProfileForm.dirty"
(click)="onGatherProfileDataBySymbol({dataSource: data.dataSource, symbol: data.symbol})" (click)="
onGatherProfileDataBySymbol({
dataSource: data.dataSource,
symbol: data.symbol
})
"
> >
<ng-container i18n>Gather Profile Data</ng-container> <ng-container i18n>Gather Profile Data</ng-container>
</button> </button>
@ -73,7 +80,12 @@
color="accent" color="accent"
mat-flat-button mat-flat-button
type="button" type="button"
[disabled]="!assetProfileForm.controls['historicalData']?.controls['csvString'].touched || assetProfileForm.controls['historicalData']?.controls['csvString']?.value === ''" [disabled]="
!assetProfileForm.controls['historicalData']?.controls['csvString']
.touched ||
assetProfileForm.controls['historicalData']?.controls['csvString']
?.value === ''
"
(click)="onImportHistoricalData()" (click)="onImportHistoricalData()"
> >
<ng-container i18n>Import</ng-container> <ng-container i18n>Import</ng-container>
@ -129,11 +141,15 @@
> >
</div> </div>
<ng-container <ng-container
*ngIf="assetProfile?.countries?.length > 0 || assetProfile?.sectors?.length > 0" *ngIf="
assetProfile?.countries?.length > 0 ||
assetProfile?.sectors?.length > 0
"
> >
<ng-container @if (
*ngIf="assetProfile?.countries?.length === 1 && assetProfile?.sectors?.length === 1; else charts" assetProfile?.countries?.length === 1 &&
> assetProfile?.sectors?.length === 1
) {
<div *ngIf="assetProfile?.sectors?.length === 1" class="col-6 mb-3"> <div *ngIf="assetProfile?.sectors?.length === 1" class="col-6 mb-3">
<gf-value <gf-value
i18n i18n
@ -152,8 +168,7 @@
>Country</gf-value >Country</gf-value
> >
</div> </div>
</ng-container> } @else {
<ng-template #charts>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div> <div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
@ -174,7 +189,7 @@
[positions]="countries" [positions]="countries"
/> />
</div> </div>
</ng-template> }
</ng-container> </ng-container>
</div> </div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3"> <div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
@ -224,7 +239,17 @@
color="primary" color="primary"
i18n i18n
[checked]="isBenchmark" [checked]="isBenchmark"
(change)="isBenchmark ? onUnsetBenchmark({dataSource: data.dataSource, symbol: data.symbol}) : onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})" (change)="
isBenchmark
? onUnsetBenchmark({
dataSource: data.dataSource,
symbol: data.symbol
})
: onSetBenchmark({
dataSource: data.dataSource,
symbol: data.symbol
})
"
>Benchmark</mat-checkbox >Benchmark</mat-checkbox
> >
</div> </div>
@ -255,7 +280,9 @@
color="accent" color="accent"
mat-flat-button mat-flat-button
type="button" type="button"
[disabled]="assetProfileForm.controls['scraperConfiguration'].value === '{}'" [disabled]="
assetProfileForm.controls['scraperConfiguration'].value === '{}'
"
(click)="onTestMarketData()" (click)="onTestMarketData()"
> >
<ng-container i18n>Test</ng-container> <ng-container i18n>Test</ng-container>

View File

@ -28,7 +28,7 @@
[value]="transactionCount" [value]="transactionCount"
/> />
<div *ngIf="transactionCount && userCount"> <div *ngIf="transactionCount && userCount">
{{ transactionCount / userCount | number : '1.2-2' }} {{ transactionCount / userCount | number: '1.2-2' }}
<span i18n>per User</span> <span i18n>per User</span>
</div> </div>
</div> </div>
@ -69,10 +69,10 @@
<a <a
mat-menu-item mat-menu-item
[queryParams]="{ [queryParams]="{
assetProfileDialog: true, assetProfileDialog: true,
dataSource: exchangeRate.dataSource, dataSource: exchangeRate.dataSource,
symbol: exchangeRate.symbol symbol: exchangeRate.symbol
}" }"
[routerLink]="['/admin', 'market-data']" [routerLink]="['/admin', 'market-data']"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
@ -112,7 +112,9 @@
<mat-slide-toggle <mat-slide-toggle
color="primary" color="primary"
hideIcon="true" hideIcon="true"
[checked]="info.globalPermissions.includes(permissions.createUserAccount)" [checked]="
info.globalPermissions.includes(permissions.createUserAccount)
"
(change)="onEnableUserSignupModeChange($event)" (change)="onEnableUserSignupModeChange($event)"
/> />
</div> </div>
@ -143,7 +145,7 @@
<div class="w-50" i18n>System Message</div> <div class="w-50" i18n>System Message</div>
<div class="w-50"> <div class="w-50">
<div *ngIf="systemMessage" class="align-items-center d-flex"> <div *ngIf="systemMessage" class="align-items-center d-flex">
<div class="text-truncate">{{ systemMessage | json }}</div> <div class="text-truncate">{{ systemMessage | json }}</div>
<button <button
class="h-100 mx-1 no-min-width px-2" class="h-100 mx-1 no-min-width px-2"
mat-button mat-button

View File

@ -12,7 +12,7 @@
# #
</th> </th>
<td <td
*matCellDef="let element; let i=index" *matCellDef="let element; let i = index"
class="mat-mdc-cell px-1 py-2 text-right" class="mat-mdc-cell px-1 py-2 text-right"
mat-cell mat-cell
> >
@ -35,17 +35,23 @@
mat-cell mat-cell
> >
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block text-monospace" <span class="d-none d-sm-inline-block text-monospace">{{
>{{ element.id }}</span element.id
> }}</span>
<span class="d-inline-block d-sm-none text-monospace" <span class="d-inline-block d-sm-none text-monospace">{{
>{{ (element.id | slice:0:5) + '...' }}</span (element.id | slice: 0 : 5) + '...'
> }}</span>
<gf-premium-indicator <gf-premium-indicator
*ngIf="element?.subscription?.type === 'Premium'" *ngIf="element?.subscription?.type === 'Premium'"
class="ml-1" class="ml-1"
[enableLink]="false" [enableLink]="false"
[title]="'Expires ' + formatDistanceToNow(element.subscription.expiresAt) + ' (' + (element.subscription.expiresAt | date: defaultDateFormat) + ')'" [title]="
'Expires ' +
formatDistanceToNow(element.subscription.expiresAt) +
' (' +
(element.subscription.expiresAt | date: defaultDateFormat) +
')'
"
/> />
</div> </div>
</td> </td>
@ -67,9 +73,9 @@
class="mat-mdc-cell px-1 py-2" class="mat-mdc-cell px-1 py-2"
mat-cell mat-cell
> >
<span class="h5" [title]="element.country" <span class="h5" [title]="element.country">{{
>{{ getEmojiFlag(element.country) }}</span getEmojiFlag(element.country)
> }}</span>
</td> </td>
</ng-container> </ng-container>

View File

@ -7,6 +7,7 @@ import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { import {
getBackgroundColor, getBackgroundColor,
getDateFormatString, getDateFormatString,
getLocale,
getTextColor, getTextColor,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -51,7 +52,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() colorScheme: ColorScheme; @Input() colorScheme: ColorScheme;
@Input() daysInMarket: number; @Input() daysInMarket: number;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() locale: string; @Input() locale = getLocale();
@Input() performanceDataItems: LineChartItem[]; @Input() performanceDataItems: LineChartItem[];
@Input() user: User; @Input() user: User;

View File

@ -217,7 +217,7 @@ export class HeaderComponent implements OnChanges {
this.signOut.next(); this.signOut.next();
} }
public openLoginDialog(): void { public openLoginDialog() {
const dialogRef = this.dialog.open(LoginWithAccessTokenDialog, { const dialogRef = this.dialog.open(LoginWithAccessTokenDialog, {
autoFocus: false, autoFocus: false,
data: { data: {

View File

@ -154,8 +154,8 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchPositions({ range: this.user?.settings?.dateRange }) .fetchPositions({ range: this.user?.settings?.dateRange })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => { .subscribe(({ positions }) => {
this.positions = response.positions; this.positions = positions;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

View File

@ -127,10 +127,10 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
this.isLoadingPerformance = false; this.isLoadingPerformance = false;
this.historicalDataItems = chart.map( this.historicalDataItems = chart.map(
({ date, netPerformanceInPercentage }) => { ({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
return { return {
date, date,
value: netPerformanceInPercentage value: netPerformanceInPercentageWithCurrencyEffect
}; };
} }
); );

View File

@ -1,66 +1,64 @@
<div <div
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative" class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
> >
<div @if (hasPermissionToCreateOrder && historicalDataItems?.length === 0) {
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0; else isUserActive" <div class="justify-content-center row w-100">
class="justify-content-center row w-100" <div class="col introduction">
> <h4 i18n>Welcome to Ghostfolio</h4>
<div class="col introduction"> <p i18n>Ready to take control of your personal finances?</p>
<h4 i18n>Welcome to Ghostfolio</h4> <ol class="font-weight-bold">
<p i18n>Ready to take control of your personal finances?</p> <li
<ol class="font-weight-bold"> class="mb-2"
<li [ngClass]="{ 'text-muted': user?.accounts?.length > 1 }"
class="mb-2"
[ngClass]="{ 'text-muted': user?.accounts?.length > 1 }"
>
<a class="d-block" [routerLink]="['/accounts']"
><span i18n>Setup your accounts</span><br />
<span class="font-weight-normal" i18n
>Get a comprehensive financial overview by adding your bank and
brokerage accounts.</span
></a
> >
</li> <a class="d-block" [routerLink]="['/accounts']"
<li class="mb-2"> ><span i18n>Setup your accounts</span><br />
<a class="d-block" [routerLink]="['/portfolio', 'activities']"> <span class="font-weight-normal" i18n
<span i18n>Capture your activities</span><br /> >Get a comprehensive financial overview by adding your bank and
<span class="font-weight-normal" i18n brokerage accounts.</span
>Record your investment activities to keep your portfolio up to ></a
date.</span
></a
>
</li>
<li class="mb-2">
<a class="d-block" [routerLink]="['/portfolio']">
<span i18n>Monitor and analyze your portfolio</span><br />
<span class="font-weight-normal" i18n
>Track your progress in real-time with comprehensive analysis and
insights.</span
> >
</li>
<li class="mb-2">
<a class="d-block" [routerLink]="['/portfolio', 'activities']">
<span i18n>Capture your activities</span><br />
<span class="font-weight-normal" i18n
>Record your investment activities to keep your portfolio up to
date.</span
></a
>
</li>
<li class="mb-2">
<a class="d-block" [routerLink]="['/portfolio']">
<span i18n>Monitor and analyze your portfolio</span><br />
<span class="font-weight-normal" i18n
>Track your progress in real-time with comprehensive analysis
and insights.</span
>
</a>
</li>
</ol>
<div class="d-flex justify-content-center">
<a
*ngIf="user?.accounts?.length === 1"
color="primary"
mat-flat-button
[routerLink]="['/accounts']"
>
<ng-container i18n>Setup accounts</ng-container>
</a> </a>
</li> <a
</ol> *ngIf="user?.accounts?.length > 1"
<div class="d-flex justify-content-center"> color="primary"
<a mat-flat-button
*ngIf="user?.accounts?.length === 1" [routerLink]="['/portfolio', 'activities']"
color="primary" >
mat-flat-button <ng-container i18n>Add activity</ng-container>
[routerLink]="['/accounts']" </a>
> </div>
<ng-container i18n>Setup accounts</ng-container>
</a>
<a
*ngIf="user?.accounts?.length > 1"
color="primary"
mat-flat-button
[routerLink]="['/portfolio', 'activities']"
>
<ng-container i18n>Add activity</ng-container>
</a>
</div> </div>
</div> </div>
</div> } @else {
<ng-template #isUserActive>
<div class="row w-100"> <div class="row w-100">
<div class="col p-0"> <div class="col p-0">
<div class="chart-container mx-auto position-relative"> <div class="chart-container mx-auto position-relative">
@ -98,5 +96,5 @@
/> />
</div> </div>
</div> </div>
</ng-template> }
</div> </div>

View File

@ -6,7 +6,9 @@
<mat-card-content> <mat-card-content>
<gf-portfolio-summary <gf-portfolio-summary
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[hasPermissionToUpdateUserSettings]="!hasImpersonationId && hasPermissionToUpdateUserSettings" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading" [isLoading]="isLoading"
[language]="user?.settings?.language" [language]="user?.settings?.language"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"

View File

@ -9,6 +9,7 @@ import {
DATE_FORMAT, DATE_FORMAT,
getBackgroundColor, getBackgroundColor,
getDateFormatString, getDateFormatString,
getLocale,
getTextColor, getTextColor,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -38,8 +39,16 @@ import {
} from 'chart.js'; } from 'chart.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation'; import annotationPlugin from 'chartjs-plugin-annotation';
import { addDays, format, isAfter, parseISO, subDays } from 'date-fns'; import {
import { last } from 'lodash'; addDays,
format,
isAfter,
isValid,
min,
parseISO,
subDays
} from 'date-fns';
import { first, last } from 'lodash';
@Component({ @Component({
selector: 'gf-investment-chart', selector: 'gf-investment-chart',
@ -57,7 +66,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() historicalDataItems: LineChartItem[] = []; @Input() historicalDataItems: LineChartItem[] = [];
@Input() isInPercent = false; @Input() isInPercent = false;
@Input() isLoading = false; @Input() isLoading = false;
@Input() locale: string; @Input() locale = getLocale();
@Input() range: DateRange = 'max'; @Input() range: DateRange = 'max';
@Input() savingsRate = 0; @Input() savingsRate = 0;
@ -143,7 +152,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}); });
} }
const chartData: ChartData<'line'> = { const chartData: ChartData<'bar' | 'line'> = {
labels: this.historicalDataItems.map(({ date }) => { labels: this.historicalDataItems.map(({ date }) => {
return parseDate(date); return parseDate(date);
}), }),
@ -194,17 +203,23 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}; };
if (this.chartCanvas) { if (this.chartCanvas) {
let scaleXMin: string;
if (this.daysInMarket) {
const minDate = min([
parseDate(first(this.investments)?.date),
subDays(new Date().setHours(0, 0, 0, 0), this.daysInMarket)
]);
scaleXMin = isValid(minDate) ? minDate.toISOString() : undefined;
}
if (this.chart) { if (this.chart) {
this.chart.data = chartData; this.chart.data = chartData;
this.chart.options.plugins.tooltip = <unknown>( this.chart.options.plugins.tooltip = <unknown>(
this.getTooltipPluginConfiguration() this.getTooltipPluginConfiguration()
); );
this.chart.options.scales.x.min = this.daysInMarket this.chart.options.scales.x.min = scaleXMin;
? subDays(
new Date().setHours(0, 0, 0, 0),
this.daysInMarket
).toISOString()
: undefined;
if ( if (
this.savingsRate && this.savingsRate &&
@ -287,9 +302,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
grid: { grid: {
display: false display: false
}, },
min: this.daysInMarket min: scaleXMin,
? subDays(new Date(), this.daysInMarket).toISOString()
: undefined,
suggestedMax: new Date().toISOString(), suggestedMax: new Date().toISOString(),
type: 'time', type: 'time',
time: { time: {

View File

@ -40,7 +40,11 @@
[colorizeSign]="true" [colorizeSign]="true"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : performance?.currentNetPerformance" [value]="
isLoading
? undefined
: performance?.currentNetPerformanceWithCurrencyEffect
"
/> />
</div> </div>
<div class="col"> <div class="col">
@ -49,7 +53,9 @@
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]=" [value]="
isLoading ? undefined : performance?.currentNetPerformancePercent isLoading
? undefined
: performance?.currentNetPerformancePercentWithCurrencyEffect
" "
/> />
</div> </div>

View File

@ -1,4 +1,5 @@
import { import {
getLocale,
getNumberFormatDecimal, getNumberFormatDecimal,
getNumberFormatGroup getNumberFormatGroup
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -31,7 +32,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
@Input() isAllTimeHigh: boolean; @Input() isAllTimeHigh: boolean;
@Input() isAllTimeLow: boolean; @Input() isAllTimeLow: boolean;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() locale: string; @Input() locale = getLocale();
@Input() performance: PortfolioPerformance; @Input() performance: PortfolioPerformance;
@Input() showDetails: boolean; @Input() showDetails: boolean;
@Input() unit: string; @Input() unit: string;
@ -62,7 +63,8 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
} else if (this.showDetails === false) { } else if (this.showDetails === false) {
new CountUp( new CountUp(
'value', 'value',
this.performance?.currentNetPerformancePercent * 100, this.performance?.currentNetPerformancePercentWithCurrencyEffect *
100,
{ {
decimal: getNumberFormatDecimal(this.locale), decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: 2, decimalPlaces: 2,

View File

@ -64,7 +64,11 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentGrossPerformance" [value]="
isLoading
? undefined
: summary?.currentGrossPerformanceWithCurrencyEffect
"
/> />
</div> </div>
</div> </div>
@ -85,7 +89,9 @@
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]=" [value]="
isLoading ? undefined : summary?.currentGrossPerformancePercent isLoading
? undefined
: summary?.currentGrossPerformancePercentWithCurrencyEffect
" "
/> />
</div> </div>
@ -114,7 +120,11 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentNetPerformance" [value]="
isLoading
? undefined
: summary?.currentNetPerformanceWithCurrencyEffect
"
/> />
</div> </div>
</div> </div>
@ -134,7 +144,11 @@
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : summary?.currentNetPerformancePercent" [value]="
isLoading
? undefined
: summary?.currentNetPerformancePercentWithCurrencyEffect
"
/> />
</div> </div>
</div> </div>
@ -283,7 +297,11 @@
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : summary?.annualizedPerformancePercent" [value]="
isLoading
? undefined
: summary?.annualizedPerformancePercentWithCurrencyEffect
"
/> />
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import { getDateFnsLocale } from '@ghostfolio/common/helper'; import { getDateFnsLocale, getLocale } from '@ghostfolio/common/helper';
import { PortfolioSummary } from '@ghostfolio/common/interfaces'; import { PortfolioSummary } from '@ghostfolio/common/interfaces';
import { import {
@ -23,7 +23,7 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
@Input() hasPermissionToUpdateUserSettings: boolean; @Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() language: string; @Input() language: string;
@Input() locale: string; @Input() locale = getLocale();
@Input() summary: PortfolioSummary; @Input() summary: PortfolioSummary;
@Output() emergencyFundChanged = new EventEmitter<number>(); @Output() emergencyFundChanged = new EventEmitter<number>();

View File

@ -19,9 +19,9 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Sort, SortDirection } from '@angular/material/sort'; import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { Tag } from '@prisma/client'; import { Account, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -36,6 +36,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./position-detail-dialog.component.scss'] styleUrls: ['./position-detail-dialog.component.scss']
}) })
export class PositionDetailDialog implements OnDestroy, OnInit { export class PositionDetailDialog implements OnDestroy, OnInit {
public accounts: Account[];
public activities: OrderWithAccount[]; public activities: OrderWithAccount[];
public assetClass: string; public assetClass: string;
public assetSubClass: string; public assetSubClass: string;
@ -49,15 +50,13 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public dividendInBaseCurrency: number; public dividendInBaseCurrency: number;
public feeInBaseCurrency: number; public feeInBaseCurrency: number;
public firstBuyDate: string; public firstBuyDate: string;
public grossPerformance: number;
public grossPerformancePercent: number;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public investment: number; public investment: number;
public marketPrice: number; public marketPrice: number;
public maxPrice: number; public maxPrice: number;
public minPrice: number; public minPrice: number;
public netPerformance: number; public netPerformancePercentWithCurrencyEffect: number;
public netPerformancePercent: number; public netPerformanceWithCurrencyEffect: number;
public quantity: number; public quantity: number;
public quantityPrecision = 2; public quantityPrecision = 2;
public reportDataGlitchMail: string; public reportDataGlitchMail: string;
@ -83,7 +82,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
private userService: UserService private userService: UserService
) {} ) {}
public ngOnInit(): void { public ngOnInit() {
this.dataService this.dataService
.fetchPositionDetail({ .fetchPositionDetail({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
@ -92,20 +91,19 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe( .subscribe(
({ ({
accounts,
averagePrice, averagePrice,
dataProviderInfo, dataProviderInfo,
dividendInBaseCurrency, dividendInBaseCurrency,
feeInBaseCurrency, feeInBaseCurrency,
firstBuyDate, firstBuyDate,
grossPerformance,
grossPerformancePercent,
historicalData, historicalData,
investment, investment,
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
netPerformance, netPerformancePercentWithCurrencyEffect,
netPerformancePercent, netPerformanceWithCurrencyEffect,
orders, orders,
quantity, quantity,
SymbolProfile, SymbolProfile,
@ -113,6 +111,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
transactionCount, transactionCount,
value value
}) => { }) => {
this.accounts = accounts;
this.activities = orders; this.activities = orders;
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
this.benchmarkDataItems = []; this.benchmarkDataItems = [];
@ -122,8 +121,6 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.dividendInBaseCurrency = dividendInBaseCurrency; this.dividendInBaseCurrency = dividendInBaseCurrency;
this.feeInBaseCurrency = feeInBaseCurrency; this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate; this.firstBuyDate = firstBuyDate;
this.grossPerformance = grossPerformance;
this.grossPerformancePercent = grossPerformancePercent;
this.historicalDataItems = historicalData.map( this.historicalDataItems = historicalData.map(
(historicalDataItem) => { (historicalDataItem) => {
this.benchmarkDataItems.push({ this.benchmarkDataItems.push({
@ -141,8 +138,10 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.marketPrice = marketPrice; this.marketPrice = marketPrice;
this.maxPrice = maxPrice; this.maxPrice = maxPrice;
this.minPrice = minPrice; this.minPrice = minPrice;
this.netPerformance = netPerformance; this.netPerformancePercentWithCurrencyEffect =
this.netPerformancePercent = netPerformancePercent; netPerformancePercentWithCurrencyEffect;
this.netPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect;
this.quantity = quantity; 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.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.sectors = {};
@ -264,7 +263,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
}); });
} }
public onClose(): void { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }

View File

@ -44,7 +44,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="data.baseCurrency" [unit]="data.baseCurrency"
[value]="netPerformance" [value]="netPerformanceWithCurrencyEffect"
>Change</gf-value >Change</gf-value
> >
</div> </div>
@ -55,7 +55,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="data.locale" [locale]="data.locale"
[value]="netPerformancePercent" [value]="netPerformancePercentWithCurrencyEffect"
>Performance</gf-value >Performance</gf-value
> >
</div> </div>
@ -87,7 +87,11 @@
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }" [ngClass]="{
'text-danger':
minPrice?.toFixed(2) === marketPrice?.toFixed(2) &&
maxPrice?.toFixed(2) !== minPrice?.toFixed(2)
}"
[unit]="SymbolProfile?.currency" [unit]="SymbolProfile?.currency"
[value]="minPrice" [value]="minPrice"
>Minimum Price</gf-value >Minimum Price</gf-value
@ -99,7 +103,11 @@
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }" [ngClass]="{
'text-success':
maxPrice?.toFixed(2) === marketPrice?.toFixed(2) &&
maxPrice?.toFixed(2) !== minPrice?.toFixed(2)
}"
[unit]="SymbolProfile?.currency" [unit]="SymbolProfile?.currency"
[value]="maxPrice" [value]="maxPrice"
>Maximum Price</gf-value >Maximum Price</gf-value
@ -184,11 +192,15 @@
> >
</div> </div>
<ng-container <ng-container
*ngIf="SymbolProfile?.countries?.length > 0 || SymbolProfile?.sectors?.length > 0" *ngIf="
SymbolProfile?.countries?.length > 0 ||
SymbolProfile?.sectors?.length > 0
"
> >
<ng-container @if (
*ngIf="SymbolProfile?.countries?.length === 1 && SymbolProfile?.sectors?.length === 1; else charts" SymbolProfile?.countries?.length === 1 &&
> SymbolProfile?.sectors?.length === 1
) {
<div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3"> <div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3">
<gf-value <gf-value
i18n i18n
@ -210,8 +222,7 @@
>Country</gf-value >Country</gf-value
> >
</div> </div>
</ng-container> } @else {
<ng-template #charts>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div> <div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
@ -236,7 +247,7 @@
[positions]="countries" [positions]="countries"
/> />
</div> </div>
</ng-template> }
</ng-container> </ng-container>
<div *ngIf="dataProviderInfo" class="col-md-12 mb-3 text-center"> <div *ngIf="dataProviderInfo" class="col-md-12 mb-3 text-center">
<hr /> <hr />
@ -246,15 +257,25 @@
</div> </div>
</div> </div>
<div class="row" [ngClass]="{ 'd-none': !activities?.length }"> <mat-tab-group
<div class="col mb-3"> animationDuration="0"
<div class="h5 mb-0" i18n>Activities</div> class="mb-3"
[mat-stretch-tabs]="false"
[ngClass]="{ 'd-none': !activities?.length }"
>
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="swap-vertical-outline" />
<div class="d-none d-sm-block ml-2" i18n>Activities</div>
</ng-template>
<gf-activities-table <gf-activities-table
[baseCurrency]="data.baseCurrency" [baseCurrency]="data.baseCurrency"
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!data.hasImpersonationId && !user.settings.isRestrictedView" [hasPermissionToExportActivities]="
!data.hasImpersonationId && !user.settings.isRestrictedView
"
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[locale]="data.locale" [locale]="data.locale"
@ -266,22 +287,42 @@
[totalItems]="totalItems" [totalItems]="totalItems"
(export)="onExport()" (export)="onExport()"
/> />
</div> </mat-tab>
</div> <mat-tab>
<ng-template mat-tab-label>
<ion-icon name="wallet-outline" />
<div class="d-none d-sm-block ml-2" i18n>Accounts</div>
</ng-template>
<gf-accounts-table
[accounts]="accounts"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showBalance]="false"
[showFooter]="false"
[showTransactions]="false"
[showValue]="false"
[showValueInBaseCurrency]="false"
/>
</mat-tab>
</mat-tab-group>
<div *ngIf="tags?.length > 0" class="row"> <div *ngIf="tags?.length > 0" class="row">
<div class="col"> <div class="col">
<div class="h5" i18n>Tags</div> <div class="h5" i18n>Tags</div>
<mat-chip-listbox> <mat-chip-listbox>
<mat-chip-option *ngFor="let tag of tags" disabled <mat-chip-option *ngFor="let tag of tags" disabled>{{
>{{ tag.name }}</mat-chip-option tag.name
> }}</mat-chip-option>
</mat-chip-listbox> </mat-chip-listbox>
</div> </div>
</div> </div>
<div <div
*ngIf="activities?.length > 0 && data.hasPermissionToReportDataGlitch === true" *ngIf="
activities?.length > 0 && data.hasPermissionToReportDataGlitch === true
"
class="row" class="row"
> >
<div class="col"> <div class="col">

View File

@ -1,3 +1,4 @@
import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-table/accounts-table.module';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module'; import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
@ -11,6 +12,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips'; import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatTabsModule } from '@angular/material/tabs';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PositionDetailDialog } from './position-detail-dialog.component'; import { PositionDetailDialog } from './position-detail-dialog.component';
@ -19,6 +21,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
declarations: [PositionDetailDialog], declarations: [PositionDetailDialog],
imports: [ imports: [
CommonModule, CommonModule,
GfAccountsTableModule,
GfActivitiesTableModule, GfActivitiesTableModule,
GfDataProviderCreditsModule, GfDataProviderCreditsModule,
GfDialogFooterModule, GfDialogFooterModule,
@ -29,6 +32,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
MatButtonModule, MatButtonModule,
MatChipsModule, MatChipsModule,
MatDialogModule, MatDialogModule,
MatTabsModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -17,7 +17,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[marketState]="position?.marketState" [marketState]="position?.marketState"
[range]="range" [range]="range"
[value]="position?.netPerformancePercentage" [value]="position?.netPerformancePercentageWithCurrencyEffect"
/> />
</div> </div>
<div *ngIf="isLoading" class="flex-grow-1"> <div *ngIf="isLoading" class="flex-grow-1">
@ -49,13 +49,13 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]="position?.netPerformance" [value]="position?.netPerformanceWithCurrencyEffect"
/> />
<gf-value <gf-value
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="position?.netPerformancePercentage" [value]="position?.netPerformancePercentageWithCurrencyEffect"
/> />
</div> </div>
</div> </div>

View File

@ -1,4 +1,5 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getLocale } from '@ghostfolio/common/helper';
import { Position } from '@ghostfolio/common/interfaces'; import { Position } from '@ghostfolio/common/interfaces';
import { import {
@ -20,7 +21,7 @@ export class PositionComponent implements OnDestroy, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() locale: string; @Input() locale = getLocale();
@Input() position: Position; @Input() position: Position;
@Input() range: string; @Input() range: string;

View File

@ -1,3 +1,4 @@
import { getLocale } from '@ghostfolio/common/helper';
import { Position } from '@ghostfolio/common/interfaces'; import { Position } from '@ghostfolio/common/interfaces';
import { import {
@ -18,7 +19,7 @@ export class PositionsComponent implements OnChanges, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToCreateOrder: boolean; @Input() hasPermissionToCreateOrder: boolean;
@Input() locale: string; @Input() locale = getLocale();
@Input() positions: Position[]; @Input() positions: Position[];
@Input() range: string; @Input() range: string;

View File

@ -28,30 +28,32 @@
</div> </div>
@if (accessForm.controls['type'].value === 'PRIVATE') { @if (accessForm.controls['type'].value === 'PRIVATE') {
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Permission</mat-label> <mat-label i18n>Permission</mat-label>
<mat-select formControlName="permissions"> <mat-select formControlName="permissions">
<mat-option i18n value="READ_RESTRICTED">Restricted view</mat-option> <mat-option i18n value="READ_RESTRICTED"
@if(data?.user?.settings?.isExperimentalFeatures) { >Restricted view</mat-option
<mat-option i18n value="READ">View</mat-option> >
} @if (data?.user?.settings?.isExperimentalFeatures) {
</mat-select> <mat-option i18n value="READ">View</mat-option>
</mat-form-field> }
</div> </mat-select>
<div> </mat-form-field>
<mat-form-field appearance="outline" class="w-100"> </div>
<mat-label> <div>
Ghostfolio <ng-container i18n>User ID</ng-container> <mat-form-field appearance="outline" class="w-100">
</mat-label> <mat-label>
<input Ghostfolio <ng-container i18n>User ID</ng-container>
formControlName="userId" </mat-label>
matInput <input
type="text" formControlName="userId"
(keydown.enter)="$event.stopPropagation()" matInput
/> type="text"
</mat-form-field> (keydown.enter)="$event.stopPropagation()"
</div> />
</mat-form-field>
</div>
} }
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>

View File

@ -100,7 +100,7 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private openCreateAccessDialog(): void { private openCreateAccessDialog() {
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, { const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
data: { data: {
access: { access: {

View File

@ -3,7 +3,7 @@
<div class="col"> <div class="col">
<div class="align-items-center d-flex flex-column"> <div class="align-items-center d-flex flex-column">
<gf-membership-card <gf-membership-card
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat" [expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
[name]="user?.subscription?.type" [name]="user?.subscription?.type"
/> />
<div <div
@ -11,14 +11,19 @@
class="d-flex flex-column mt-5" class="d-flex flex-column mt-5"
> >
<ng-container <ng-container
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings" *ngIf="
hasPermissionForSubscription && hasPermissionToUpdateUserSettings
"
> >
<button color="primary" mat-flat-button (click)="onCheckout()"> <button color="primary" mat-flat-button (click)="onCheckout()">
<ng-container *ngIf="user.subscription.offer === 'default'" i18n <ng-container *ngIf="user.subscription.offer === 'default'" i18n
>Upgrade Plan</ng-container >Upgrade Plan</ng-container
> >
<ng-container <ng-container
*ngIf="user.subscription.offer === 'renewal' || user.subscription.offer === 'renewal-early-bird'" *ngIf="
user.subscription.offer === 'renewal' ||
user.subscription.offer === 'renewal-early-bird'
"
i18n i18n
>Renew Plan</ng-container >Renew Plan</ng-container
> >
@ -27,7 +32,8 @@
<ng-container *ngIf="coupon" <ng-container *ngIf="coupon"
><del class="text-muted" ><del class="text-muted"
>{{ baseCurrency }}&nbsp;{{ price }}</del >{{ baseCurrency }}&nbsp;{{ price }}</del
>&nbsp;{{ baseCurrency }}&nbsp;{{ price - coupon >&nbsp;{{ baseCurrency }}&nbsp;{{
price - coupon
}}</ng-container }}</ng-container
> >
<ng-container *ngIf="!coupon" <ng-container *ngIf="!coupon"

View File

@ -32,7 +32,9 @@
name="baseCurrency" name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings" [disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency" [value]="user.settings.baseCurrency"
(selectionChange)="onChangeUserSetting('baseCurrency', $event.value)" (selectionChange)="
onChangeUserSetting('baseCurrency', $event.value)
"
> >
<mat-option <mat-option
*ngFor="let currency of currencies" *ngFor="let currency of currencies"
@ -53,7 +55,9 @@
> >
If a translation is missing, kindly support us in extending it If a translation is missing, kindly support us in extending it
<a <a
href="https://github.com/ghostfolio/ghostfolio/blob/main/apps/client/src/locales/messages.{{language}}.xlf" href="https://github.com/ghostfolio/ghostfolio/blob/main/apps/client/src/locales/messages.{{
language
}}.xlf"
target="_blank" target="_blank"
>here</a >here</a
>. >.
@ -65,7 +69,9 @@
name="language" name="language"
[disabled]="!hasPermissionToUpdateUserSettings" [disabled]="!hasPermissionToUpdateUserSettings"
[value]="language" [value]="language"
(selectionChange)="onChangeUserSetting('language', $event.value)" (selectionChange)="
onChangeUserSetting('language', $event.value)
"
> >
<mat-option [value]="null" /> <mat-option [value]="null" />
<mat-option value="de">Deutsch</mat-option> <mat-option value="de">Deutsch</mat-option>
@ -115,12 +121,14 @@
name="locale" name="locale"
[disabled]="!hasPermissionToUpdateUserSettings" [disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.locale" [value]="user.settings.locale"
(selectionChange)="onChangeUserSetting('locale', $event.value)" (selectionChange)="
onChangeUserSetting('locale', $event.value)
"
> >
<mat-option [value]="null" /> <mat-option [value]="null" />
<mat-option *ngFor="let locale of locales" [value]="locale" <mat-option *ngFor="let locale of locales" [value]="locale">{{
>{{ locale }}</mat-option locale
> }}</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
@ -137,7 +145,9 @@
[disabled]="!hasPermissionToUpdateUserSettings" [disabled]="!hasPermissionToUpdateUserSettings"
[placeholder]="appearancePlaceholder" [placeholder]="appearancePlaceholder"
[value]="user?.settings?.colorScheme" [value]="user?.settings?.colorScheme"
(selectionChange)="onChangeUserSetting('colorScheme', $event.value)" (selectionChange)="
onChangeUserSetting('colorScheme', $event.value)
"
> >
<mat-option i18n [value]="null">Auto</mat-option> <mat-option i18n [value]="null">Auto</mat-option>
<mat-option i18n value="LIGHT">Light</mat-option> <mat-option i18n value="LIGHT">Light</mat-option>

View File

@ -1,4 +1,4 @@
import { getNumberFormatGroup } from '@ghostfolio/common/helper'; import { getLocale, getNumberFormatGroup } from '@ghostfolio/common/helper';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -21,7 +21,7 @@ export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
@Input() countries: { [code: string]: { name?: string; value: number } }; @Input() countries: { [code: string]: { name?: string; value: number } };
@Input() format: string; @Input() format: string;
@Input() isInPercent = false; @Input() isInPercent = false;
@Input() locale: string; @Input() locale = getLocale();
public isLoading = true; public isLoading = true;
public svgMapElement; public svgMapElement;

View File

@ -99,6 +99,16 @@ export class HttpResponseInterceptor implements HttpInterceptor {
window.location.reload(); window.location.reload();
}); });
} }
} else if (error.status === StatusCodes.TOO_MANY_REQUESTS) {
if (!this.snackBarRef) {
this.snackBarRef = this.snackBar.open(
$localize`Oops! It looks like youre making too many requests. Please slow down a bit.`
);
this.snackBarRef.afterDismissed().subscribe(() => {
this.snackBarRef = undefined;
});
}
} else if (error.status === StatusCodes.UNAUTHORIZED) { } else if (error.status === StatusCodes.UNAUTHORIZED) {
if (this.webAuthnService.isEnabled()) { if (this.webAuthnService.isEnabled()) {
this.router.navigate(['/webauthn']); this.router.navigate(['/webauthn']);

View File

@ -3,7 +3,6 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import * as path from 'path';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';

View File

@ -21,7 +21,7 @@
> >
<ion-icon <ion-icon
[name]="tab.iconName" [name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large': 'small'" [size]="deviceType === 'mobile' ? 'large' : 'small'"
/> />
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div> <div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a> </a>

View File

@ -8,7 +8,7 @@ import { AboutPageComponent } from './about-page.component';
@NgModule({ @NgModule({
declarations: [AboutPageComponent], declarations: [AboutPageComponent],
imports: [CommonModule, MatTabsModule, AboutPageRoutingModule, RouterModule], imports: [AboutPageRoutingModule, CommonModule, MatTabsModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class AboutPageModule {} export class AboutPageModule {}

View File

@ -11,26 +11,28 @@
</h1> </h1>
<div class="row"> <div class="row">
@for (ossFriend of ossFriends; track ossFriend) { @for (ossFriend of ossFriends; track ossFriend) {
<div class="col-xs-12 col-md-4 mb-3"> <div class="col-xs-12 col-md-4 mb-3">
<a target="_blank" [href]="ossFriend.href"> <a target="_blank" [href]="ossFriend.href">
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-header> <mat-card-header>
<mat-card-title class="h4">{{ ossFriend.name }}</mat-card-title> <mat-card-title class="h4">{{
</mat-card-header> ossFriend.name
<mat-card-content class="flex-grow-1"> }}</mat-card-title>
<p>{{ ossFriend.description }}</p> </mat-card-header>
</mat-card-content> <mat-card-content class="flex-grow-1">
<mat-card-actions class="justify-content-end"> <p>{{ ossFriend.description }}</p>
<a mat-button target="_blank" [href]="ossFriend.href"> </mat-card-content>
<span <mat-card-actions class="justify-content-end">
><ng-container i18n>Visit</ng-container> {{ ossFriend.name <a mat-button target="_blank" [href]="ossFriend.href">
}}</span <span
><ion-icon class="ml-1" name="arrow-forward-outline" /> ><ng-container i18n>Visit</ng-container>
</a> {{ ossFriend.name }}</span
</mat-card-actions> ><ion-icon class="ml-1" name="arrow-forward-outline" />
</mat-card> </a>
</a> </mat-card-actions>
</div> </mat-card>
</a>
</div>
} }
</div> </div>
</div> </div>

View File

@ -13,7 +13,6 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './about-overview-page.html' templateUrl: './about-overview-page.html'
}) })
export class AboutOverviewPageComponent implements OnDestroy, OnInit { export class AboutOverviewPageComponent implements OnDestroy, OnInit {
public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean; public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean; public isLoggedIn: boolean;
@ -30,11 +29,6 @@ export class AboutOverviewPageComponent implements OnDestroy, OnInit {
) { ) {
const { globalPermissions } = this.dataService.fetchInfo(); const { globalPermissions } = this.dataService.fetchInfo();
this.hasPermissionForBlog = hasPermission(
globalPermissions,
permissions.enableBlog
);
this.hasPermissionForStatistics = hasPermission( this.hasPermissionForStatistics = hasPermission(
globalPermissions, globalPermissions,
permissions.enableStatistics permissions.enableStatistics

View File

@ -133,23 +133,29 @@
</div> </div>
<div class="row"> <div class="row">
<div *ngIf="hasPermissionForSubscription" class="col-md-6 col-xs-12 my-2"> <div
class="col-md-6 col-xs-12 my-2"
[ngClass]="{ 'offset-md-3': hasPermissionForSubscription === false }"
>
<a <a
class="py-4 w-100" class="py-4 w-100"
color="primary" color="primary"
i18n
mat-flat-button mat-flat-button
[routerLink]="routerLinkFaq" [routerLink]="routerLinkFaq"
>Frequently Asked Questions (FAQ)</a >Frequently Asked Questions (FAQ)</a
> >
</div> </div>
<div *ngIf="hasPermissionForBlog" class="col-md-6 col-xs-12 my-2"> @if (hasPermissionForSubscription) {
<a <div class="col-md-6 col-xs-12 my-2">
class="py-4 w-100" <a
color="primary" class="py-4 w-100"
mat-flat-button color="primary"
[routerLink]="['/blog']" mat-flat-button
>Blog</a [routerLink]="['/blog']"
> >Blog</a
</div> >
</div>
}
</div> </div>
</div> </div>

View File

@ -169,7 +169,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
isExcluded, isExcluded,
name, name,
platformId platformId
}: AccountModel): void { }: AccountModel) {
const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, { const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, {
data: { data: {
account: { account: {
@ -237,7 +237,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
}); });
} }
private openCreateAccountDialog(): void { private openCreateAccountDialog() {
const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, { const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, {
data: { data: {
account: { account: {
@ -279,7 +279,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
}); });
} }
private openTransferBalanceDialog(): void { private openTransferBalanceDialog() {
const dialogRef = this.dialog.open(TransferBalanceDialog, { const dialogRef = this.dialog.open(TransferBalanceDialog, {
data: { data: {
accounts: this.accounts accounts: this.accounts

View File

@ -8,7 +8,11 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToUpdateAccount && !user.settings.isRestrictedView" [showActions]="
!hasImpersonationId &&
hasPermissionToUpdateAccount &&
!user.settings.isRestrictedView
"
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency" [totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
[totalValueInBaseCurrency]="totalValueInBaseCurrency" [totalValueInBaseCurrency]="totalValueInBaseCurrency"
[transactionCount]="transactionCount" [transactionCount]="transactionCount"
@ -21,7 +25,11 @@
</div> </div>
<div <div
*ngIf="!hasImpersonationId && hasPermissionToCreateAccount && !user.settings.isRestrictedView" *ngIf="
!hasImpersonationId &&
hasPermissionToCreateAccount &&
!user.settings.isRestrictedView
"
class="fab-container" class="fab-container"
> >
<a <a

View File

@ -5,9 +5,9 @@
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
> >
@if (data.account.id) { @if (data.account.id) {
<h1 i18n mat-dialog-title>Update account</h1> <h1 i18n mat-dialog-title>Update account</h1>
} @else { } @else {
<h1 i18n mat-dialog-title>Add account</h1> <h1 i18n mat-dialog-title>Add account</h1>
} }
<div class="flex-grow-1 py-3" mat-dialog-content> <div class="flex-grow-1 py-3" mat-dialog-content>
<div> <div>
@ -38,9 +38,9 @@
type="number" type="number"
(keydown.enter)="$event.stopPropagation()" (keydown.enter)="$event.stopPropagation()"
/> />
<span class="ml-2" matTextSuffix <span class="ml-2" matTextSuffix>{{
>{{ accountForm.controls['currency']?.value?.value }}</span accountForm.controls['currency']?.value?.value
> }}</span>
</mat-form-field> </mat-form-field>
</div> </div>
<div [ngClass]="{ 'd-none': platforms?.length < 1 }"> <div [ngClass]="{ 'd-none': platforms?.length < 1 }">
@ -55,18 +55,20 @@
(keydown.enter)="$event.stopPropagation()" (keydown.enter)="$event.stopPropagation()"
/> />
<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn"> <mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn">
@for (platformEntry of filteredPlatforms | async; track platformEntry) @for (
{ platformEntry of filteredPlatforms | async;
<mat-option [value]="platformEntry"> track platformEntry
<span class="d-flex"> ) {
<gf-symbol-icon <mat-option [value]="platformEntry">
class="mr-1" <span class="d-flex">
[tooltip]="platformEntry.name" <gf-symbol-icon
[url]="platformEntry.url" class="mr-1"
/> [tooltip]="platformEntry.name"
<span>{{ platformEntry.name }}</span> [url]="platformEntry.url"
</span> />
</mat-option> <span>{{ platformEntry.name }}</span>
</span>
</mat-option>
} }
</mat-autocomplete> </mat-autocomplete>
</mat-form-field> </mat-form-field>
@ -89,12 +91,12 @@
> >
</div> </div>
@if (data.account.id) { @if (data.account.id) {
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Account ID</mat-label> <mat-label i18n>Account ID</mat-label>
<input formControlName="accountId" matInput /> <input formControlName="accountId" matInput />
</mat-form-field> </mat-form-field>
</div> </div>
} }
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>

View File

@ -21,7 +21,7 @@
> >
<ion-icon <ion-icon
[name]="tab.iconName" [name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large': 'small'" [size]="deviceType === 'mobile' ? 'large' : 'small'"
/> />
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div> <div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a> </a>

View File

@ -9,30 +9,30 @@
> >
</h1> </h1>
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription) {
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-content> <mat-card-content>
<div class="container p-0"> <div class="container p-0">
<div class="flex-nowrap no-gutters row"> <div class="flex-nowrap no-gutters row">
<a <a
class="d-flex overflow-hidden w-100" class="d-flex overflow-hidden w-100"
href="../en/blog/2023/11/black-week-2023" href="../en/blog/2023/11/black-week-2023"
> >
<div class="flex-grow-1 overflow-hidden"> <div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">Black Week 2023</div> <div class="h6 m-0 text-truncate">Black Week 2023</div>
<div class="d-flex text-muted">2023-11-19</div> <div class="d-flex text-muted">2023-11-19</div>
</div> </div>
<div class="align-items-center d-flex"> <div class="align-items-center d-flex">
<ion-icon <ion-icon
class="chevron text-muted" class="chevron text-muted"
name="chevron-forward-outline" name="chevron-forward-outline"
size="small" size="small"
/> />
</div> </div>
</a> </a>
</div>
</div> </div>
</div> </mat-card-content>
</mat-card-content> </mat-card>
</mat-card>
} }
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-content> <mat-card-content>
@ -294,30 +294,30 @@
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription) {
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-content> <mat-card-content>
<div class="container p-0"> <div class="container p-0">
<div class="flex-nowrap no-gutters row"> <div class="flex-nowrap no-gutters row">
<a <a
class="d-flex overflow-hidden w-100" class="d-flex overflow-hidden w-100"
href="../en/blog/2022/11/black-friday-2022" href="../en/blog/2022/11/black-friday-2022"
> >
<div class="flex-grow-1 overflow-hidden"> <div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">Black Friday 2022</div> <div class="h6 m-0 text-truncate">Black Friday 2022</div>
<div class="d-flex text-muted">2022-11-13</div> <div class="d-flex text-muted">2022-11-13</div>
</div> </div>
<div class="align-items-center d-flex"> <div class="align-items-center d-flex">
<ion-icon <ion-icon
class="chevron text-muted" class="chevron text-muted"
name="chevron-forward-outline" name="chevron-forward-outline"
size="small" size="small"
/> />
</div> </div>
</a> </a>
</div>
</div> </div>
</div> </mat-card-content>
</mat-card-content> </mat-card>
</mat-card>
} }
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-content> <mat-card-content>

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