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>", "^[./]"],
"importOrderSeparation": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
},
{
"files": "*.ts",
"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/),
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
### 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_PASSWORD` | | The password 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_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running |

View File

@ -9,12 +9,13 @@
"build": {
"executor": "@nx/webpack:webpack",
"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",
"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"
},
"configurations": {
@ -33,6 +34,26 @@
},
"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": {
"executor": "@nx/js:node",
"options": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -60,10 +60,6 @@ export class InfoService {
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_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource(

View File

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

View File

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

View File

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

View File

@ -107,7 +107,9 @@ describe('CurrentRateService', () => {
currentRateService = new CurrentRateService(
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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { resetHours } from '@ghostfolio/common/helper';
@ -6,8 +7,10 @@ import {
ResponseError,
UniqueAsset
} 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 { flatten, isEmpty, uniqBy } from 'lodash';
@ -19,9 +22,12 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
export class CurrentRateService {
public constructor(
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({
dataGatheringItems,
dateQuery
@ -40,7 +46,7 @@ export class CurrentRateService {
if (includeToday) {
promises.push(
this.dataProviderService
.getQuotes({ items: dataGatheringItems })
.getQuotes({ items: dataGatheringItems, user: this.request?.user })
.then((dataResultProvider) => {
const result: GetValueObject[] = [];
@ -117,11 +123,17 @@ export class CurrentRateService {
});
if (!value) {
// Fallback to unit price of latest activity
const latestActivity = await this.orderService.getLatestOrder({
dataSource,
symbol
});
value = {
dataSource,
symbol,
date: today,
marketPrice: 0
marketPrice: latestActivity?.unitPrice ?? 0
};
response.values.push(value);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import type {
import {
IsArray,
IsBoolean,
IsISO4217CurrencyCode,
IsISO8601,
IsIn,
IsNumber,
@ -19,8 +20,8 @@ export class UpdateUserSettingDto {
@IsOptional()
annualInterestRate?: number;
@IsISO4217CurrencyCode()
@IsOptional()
@IsString()
baseCurrency?: string;
@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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
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 {
@ -59,6 +63,13 @@ export class UserController {
public async getUser(
@Headers('accept-language') acceptLanguage: string
): Promise<User> {
if (hasRole(this.request.user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
return this.userService.getUser(
this.request.user,
acceptLanguage?.split(',')?.[0]

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -370,6 +370,14 @@
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</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>
<loc>https://ghostfol.io/en/features</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>

View File

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

View File

@ -27,7 +27,6 @@ export class ConfigurationService {
DATA_SOURCES: json({
default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO]
}),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_READ_ONLY_MODE: 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_ITEM_IN_CACHE: num({ default: 9999 }),
PORT: port({ default: 3333 }),
REDIS_DB: num({ default: 0 }),
REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }),

View File

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

View File

@ -52,15 +52,17 @@ export class CoinGeckoService implements DataProviderInterface {
return true;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
public async getAssetProfile({
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {
symbol,
assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: DEFAULT_CURRENCY,
dataSource: this.getName(),
symbol: aSymbol
dataSource: this.getName()
};
try {
@ -70,7 +72,7 @@ export class CoinGeckoService implements DataProviderInterface {
abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { name } = await got(`${this.apiUrl}/coins/${aSymbol}`, {
const { name } = await got(`${this.apiUrl}/coins/${symbol}`, {
headers: this.headers,
// @ts-ignore
signal: abortController.signal
@ -81,7 +83,7 @@ export class CoinGeckoService implements DataProviderInterface {
let message = error;
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'
)}ms`;
}

View File

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

View File

@ -92,7 +92,9 @@ export class DataProviderService {
for (const symbol of symbols) {
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol)
this.getDataProvider(DataSource[dataSource]).getAssetProfile({
symbol
})
);
promises.push(
@ -335,11 +337,13 @@ export class DataProviderService {
public async getQuotes({
items,
requestTimeout,
useCache = true
useCache = true,
user
}: {
items: UniqueAsset[];
requestTimeout?: number;
useCache?: boolean;
user?: UserWithSettings;
}): Promise<{
[symbol: string]: IDataProviderResponse;
}> {
@ -405,6 +409,14 @@ export class DataProviderService {
)) {
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) => {
return dataGatheringItem.symbol;
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,13 +13,13 @@
"build": {
"executor": "@nx/angular:webpack-browser",
"options": {
"deleteOutputPath": false,
"localize": true,
"outputPath": "dist/apps/client",
"index": "apps/client/src/index.html",
"main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json",
"assets": [],
"styles": [
"apps/client/src/assets/fonts/inter.css",
"apps/client/src/styles/theme.scss",
@ -108,13 +108,22 @@
"options": {
"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"
@ -128,9 +137,6 @@
{
"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"
},
@ -138,7 +144,7 @@
"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"
@ -146,7 +152,8 @@
{
"command": "shx cp LICENSE dist/apps/client/assets"
}
]
],
"parallel": false
}
},
"serve": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,7 +25,9 @@
mat-menu-item
type="button"
[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>
</button>
@ -33,7 +35,12 @@
mat-menu-item
type="button"
[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>
</button>
@ -73,7 +80,12 @@
color="accent"
mat-flat-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()"
>
<ng-container i18n>Import</ng-container>
@ -129,11 +141,15 @@
>
</div>
<ng-container
*ngIf="assetProfile?.countries?.length > 0 || assetProfile?.sectors?.length > 0"
*ngIf="
assetProfile?.countries?.length > 0 ||
assetProfile?.sectors?.length > 0
"
>
<ng-container
*ngIf="assetProfile?.countries?.length === 1 && assetProfile?.sectors?.length === 1; else charts"
>
@if (
assetProfile?.countries?.length === 1 &&
assetProfile?.sectors?.length === 1
) {
<div *ngIf="assetProfile?.sectors?.length === 1" class="col-6 mb-3">
<gf-value
i18n
@ -152,8 +168,7 @@
>Country</gf-value
>
</div>
</ng-container>
<ng-template #charts>
} @else {
<div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart
@ -174,7 +189,7 @@
[positions]="countries"
/>
</div>
</ng-template>
}
</ng-container>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
@ -224,7 +239,17 @@
color="primary"
i18n
[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
>
</div>
@ -255,7 +280,9 @@
color="accent"
mat-flat-button
type="button"
[disabled]="assetProfileForm.controls['scraperConfiguration'].value === '{}'"
[disabled]="
assetProfileForm.controls['scraperConfiguration'].value === '{}'
"
(click)="onTestMarketData()"
>
<ng-container i18n>Test</ng-container>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,66 +1,64 @@
<div
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
>
<div
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0; else isUserActive"
class="justify-content-center row w-100"
>
<div class="col introduction">
<h4 i18n>Welcome to Ghostfolio</h4>
<p i18n>Ready to take control of your personal finances?</p>
<ol class="font-weight-bold">
<li
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
@if (hasPermissionToCreateOrder && historicalDataItems?.length === 0) {
<div class="justify-content-center row w-100">
<div class="col introduction">
<h4 i18n>Welcome to Ghostfolio</h4>
<p i18n>Ready to take control of your personal finances?</p>
<ol class="font-weight-bold">
<li
class="mb-2"
[ngClass]="{ 'text-muted': user?.accounts?.length > 1 }"
>
</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 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>
<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>
</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
*ngIf="user?.accounts?.length > 1"
color="primary"
mat-flat-button
[routerLink]="['/portfolio', 'activities']"
>
<ng-container i18n>Add activity</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>
<ng-template #isUserActive>
} @else {
<div class="row w-100">
<div class="col p-0">
<div class="chart-container mx-auto position-relative">
@ -98,5 +96,5 @@
/>
</div>
</div>
</ng-template>
}
</div>

View File

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

View File

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

View File

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

View File

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

View File

@ -64,7 +64,11 @@
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentGrossPerformance"
[value]="
isLoading
? undefined
: summary?.currentGrossPerformanceWithCurrencyEffect
"
/>
</div>
</div>
@ -85,7 +89,9 @@
[isPercent]="true"
[locale]="locale"
[value]="
isLoading ? undefined : summary?.currentGrossPerformancePercent
isLoading
? undefined
: summary?.currentGrossPerformancePercentWithCurrencyEffect
"
/>
</div>
@ -114,7 +120,11 @@
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentNetPerformance"
[value]="
isLoading
? undefined
: summary?.currentNetPerformanceWithCurrencyEffect
"
/>
</div>
</div>
@ -134,7 +144,11 @@
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : summary?.currentNetPerformancePercent"
[value]="
isLoading
? undefined
: summary?.currentNetPerformancePercentWithCurrencyEffect
"
/>
</div>
</div>
@ -283,7 +297,11 @@
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : summary?.annualizedPerformancePercent"
[value]="
isLoading
? undefined
: summary?.annualizedPerformancePercentWithCurrencyEffect
"
/>
</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 {
@ -23,7 +23,7 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
@Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean;
@Input() language: string;
@Input() locale: string;
@Input() locale = getLocale();
@Input() summary: PortfolioSummary;
@Output() emergencyFundChanged = new EventEmitter<number>();

View File

@ -19,9 +19,9 @@ import {
OnInit
} from '@angular/core';
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 { Tag } from '@prisma/client';
import { Account, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -36,6 +36,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./position-detail-dialog.component.scss']
})
export class PositionDetailDialog implements OnDestroy, OnInit {
public accounts: Account[];
public activities: OrderWithAccount[];
public assetClass: string;
public assetSubClass: string;
@ -49,15 +50,13 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public dividendInBaseCurrency: number;
public feeInBaseCurrency: number;
public firstBuyDate: string;
public grossPerformance: number;
public grossPerformancePercent: number;
public historicalDataItems: LineChartItem[];
public investment: number;
public marketPrice: number;
public maxPrice: number;
public minPrice: number;
public netPerformance: number;
public netPerformancePercent: number;
public netPerformancePercentWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffect: number;
public quantity: number;
public quantityPrecision = 2;
public reportDataGlitchMail: string;
@ -83,7 +82,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
private userService: UserService
) {}
public ngOnInit(): void {
public ngOnInit() {
this.dataService
.fetchPositionDetail({
dataSource: this.data.dataSource,
@ -92,20 +91,19 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
accounts,
averagePrice,
dataProviderInfo,
dividendInBaseCurrency,
feeInBaseCurrency,
firstBuyDate,
grossPerformance,
grossPerformancePercent,
historicalData,
investment,
marketPrice,
maxPrice,
minPrice,
netPerformance,
netPerformancePercent,
netPerformancePercentWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
orders,
quantity,
SymbolProfile,
@ -113,6 +111,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
transactionCount,
value
}) => {
this.accounts = accounts;
this.activities = orders;
this.averagePrice = averagePrice;
this.benchmarkDataItems = [];
@ -122,8 +121,6 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.dividendInBaseCurrency = dividendInBaseCurrency;
this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate;
this.grossPerformance = grossPerformance;
this.grossPerformancePercent = grossPerformancePercent;
this.historicalDataItems = historicalData.map(
(historicalDataItem) => {
this.benchmarkDataItems.push({
@ -141,8 +138,10 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.marketPrice = marketPrice;
this.maxPrice = maxPrice;
this.minPrice = minPrice;
this.netPerformance = netPerformance;
this.netPerformancePercent = netPerformancePercent;
this.netPerformancePercentWithCurrencyEffect =
netPerformancePercentWithCurrencyEffect;
this.netPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect;
this.quantity = quantity;
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.sectors = {};
@ -264,7 +263,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
});
}
public onClose(): void {
public onClose() {
this.dialogRef.close();
}

View File

@ -44,7 +44,7 @@
[isCurrency]="true"
[locale]="data.locale"
[unit]="data.baseCurrency"
[value]="netPerformance"
[value]="netPerformanceWithCurrencyEffect"
>Change</gf-value
>
</div>
@ -55,7 +55,7 @@
[colorizeSign]="true"
[isPercent]="true"
[locale]="data.locale"
[value]="netPerformancePercent"
[value]="netPerformancePercentWithCurrencyEffect"
>Performance</gf-value
>
</div>
@ -87,7 +87,11 @@
size="medium"
[isCurrency]="true"
[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"
[value]="minPrice"
>Minimum Price</gf-value
@ -99,7 +103,11 @@
size="medium"
[isCurrency]="true"
[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"
[value]="maxPrice"
>Maximum Price</gf-value
@ -184,11 +192,15 @@
>
</div>
<ng-container
*ngIf="SymbolProfile?.countries?.length > 0 || SymbolProfile?.sectors?.length > 0"
*ngIf="
SymbolProfile?.countries?.length > 0 ||
SymbolProfile?.sectors?.length > 0
"
>
<ng-container
*ngIf="SymbolProfile?.countries?.length === 1 && SymbolProfile?.sectors?.length === 1; else charts"
>
@if (
SymbolProfile?.countries?.length === 1 &&
SymbolProfile?.sectors?.length === 1
) {
<div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3">
<gf-value
i18n
@ -210,8 +222,7 @@
>Country</gf-value
>
</div>
</ng-container>
<ng-template #charts>
} @else {
<div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart
@ -236,7 +247,7 @@
[positions]="countries"
/>
</div>
</ng-template>
}
</ng-container>
<div *ngIf="dataProviderInfo" class="col-md-12 mb-3 text-center">
<hr />
@ -246,15 +257,25 @@
</div>
</div>
<div class="row" [ngClass]="{ 'd-none': !activities?.length }">
<div class="col mb-3">
<div class="h5 mb-0" i18n>Activities</div>
<mat-tab-group
animationDuration="0"
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
[baseCurrency]="data.baseCurrency"
[dataSource]="dataSource"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!data.hasImpersonationId && !user.settings.isRestrictedView"
[hasPermissionToExportActivities]="
!data.hasImpersonationId && !user.settings.isRestrictedView
"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
@ -266,22 +287,42 @@
[totalItems]="totalItems"
(export)="onExport()"
/>
</div>
</div>
</mat-tab>
<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 class="col">
<div class="h5" i18n>Tags</div>
<mat-chip-listbox>
<mat-chip-option *ngFor="let tag of tags" disabled
>{{ tag.name }}</mat-chip-option
>
<mat-chip-option *ngFor="let tag of tags" disabled>{{
tag.name
}}</mat-chip-option>
</mat-chip-listbox>
</div>
</div>
<div
*ngIf="activities?.length > 0 && data.hasPermissionToReportDataGlitch === true"
*ngIf="
activities?.length > 0 && data.hasPermissionToReportDataGlitch === true
"
class="row"
>
<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 { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.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 { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTabsModule } from '@angular/material/tabs';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PositionDetailDialog } from './position-detail-dialog.component';
@ -19,6 +21,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
declarations: [PositionDetailDialog],
imports: [
CommonModule,
GfAccountsTableModule,
GfActivitiesTableModule,
GfDataProviderCreditsModule,
GfDialogFooterModule,
@ -29,6 +32,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
MatButtonModule,
MatChipsModule,
MatDialogModule,
MatTabsModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,7 +32,9 @@
name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency"
(selectionChange)="onChangeUserSetting('baseCurrency', $event.value)"
(selectionChange)="
onChangeUserSetting('baseCurrency', $event.value)
"
>
<mat-option
*ngFor="let currency of currencies"
@ -53,7 +55,9 @@
>
If a translation is missing, kindly support us in extending it
<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"
>here</a
>.
@ -65,7 +69,9 @@
name="language"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="language"
(selectionChange)="onChangeUserSetting('language', $event.value)"
(selectionChange)="
onChangeUserSetting('language', $event.value)
"
>
<mat-option [value]="null" />
<mat-option value="de">Deutsch</mat-option>
@ -115,12 +121,14 @@
name="locale"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.locale"
(selectionChange)="onChangeUserSetting('locale', $event.value)"
(selectionChange)="
onChangeUserSetting('locale', $event.value)
"
>
<mat-option [value]="null" />
<mat-option *ngFor="let locale of locales" [value]="locale"
>{{ locale }}</mat-option
>
<mat-option *ngFor="let locale of locales" [value]="locale">{{
locale
}}</mat-option>
</mat-select>
</mat-form-field>
</div>
@ -137,7 +145,9 @@
[disabled]="!hasPermissionToUpdateUserSettings"
[placeholder]="appearancePlaceholder"
[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="LIGHT">Light</mat-option>

View File

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

View File

@ -99,6 +99,16 @@ export class HttpResponseInterceptor implements HttpInterceptor {
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) {
if (this.webAuthnService.isEnabled()) {
this.router.navigate(['/webauthn']);

View File

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

View File

@ -21,7 +21,7 @@
>
<ion-icon
[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>
</a>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,7 +21,7 @@
>
<ion-icon
[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>
</a>

View File

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

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