Browse Source

Merge branch 'ghostfolio:main' into main

pull/5027/head
dandevaud 1 year ago
committed by GitHub
parent
commit
e6dcc57b9c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      .prettierrc
  2. 109
      CHANGELOG.md
  3. 1
      README.md
  4. 29
      apps/api/project.json
  5. 2
      apps/api/src/app/account/account.controller.ts
  6. 4
      apps/api/src/app/account/account.service.ts
  7. 3
      apps/api/src/app/account/create-account.dto.ts
  8. 3
      apps/api/src/app/account/update-account.dto.ts
  9. 3
      apps/api/src/app/admin/update-asset-profile.dto.ts
  10. 1
      apps/api/src/app/app.module.ts
  11. 2
      apps/api/src/app/import/import.controller.ts
  12. 59
      apps/api/src/app/import/import.service.ts
  13. 3
      apps/api/src/app/order/create-order.dto.ts
  14. 70
      apps/api/src/app/order/order.service.ts
  15. 3
      apps/api/src/app/order/update-order.dto.ts
  16. 4
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  17. 20
      apps/api/src/app/portfolio/current-rate.service.ts
  18. 2
      apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts
  19. 2
      apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts
  20. 2
      apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  21. 2
      apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts
  22. 2
      apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts
  23. 2
      apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  24. 2
      apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts
  25. 2
      apps/api/src/app/portfolio/portfolio-calculator.spec.ts
  26. 1
      apps/api/src/app/portfolio/portfolio-calculator.ts
  27. 29
      apps/api/src/app/portfolio/portfolio.controller.ts
  28. 168
      apps/api/src/app/portfolio/portfolio.service.ts
  29. 1
      apps/api/src/app/redis-cache/redis-cache.module.ts
  30. 2
      apps/api/src/app/symbol/symbol.controller.ts
  31. 3
      apps/api/src/app/user/update-user-setting.dto.ts
  32. 13
      apps/api/src/app/user/user.controller.ts
  33. 12
      apps/api/src/app/user/user.service.ts
  34. 1223
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  35. 1
      apps/api/src/assets/cryptocurrencies/custom.json
  36. 2
      apps/api/src/interceptors/redact-values-in-response.interceptor.ts
  37. 1
      apps/api/src/services/configuration/configuration.service.ts
  38. 12
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  39. 16
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  40. 4
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  41. 16
      apps/api/src/services/data-provider/data-provider.service.ts
  42. 41
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  43. 12
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  44. 12
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  45. 6
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  46. 28
      apps/api/src/services/data-provider/manual/manual.service.ts
  47. 12
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  48. 20
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  49. 2
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  50. 1
      apps/api/src/services/interfaces/environment.interface.ts
  51. 25
      apps/client/project.json
  52. 3
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  53. 15
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  54. 9
      apps/client/src/app/components/accounts-table/accounts-table.component.html
  55. 4
      apps/client/src/app/components/accounts-table/accounts-table.component.ts
  56. 25
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  57. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  58. 121
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  59. 16
      apps/client/src/app/components/admin-overview/admin-overview.html
  60. 28
      apps/client/src/app/components/admin-users/admin-users.html
  61. 3
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  62. 2
      apps/client/src/app/components/header/header.component.ts
  63. 4
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  64. 4
      apps/client/src/app/components/home-overview/home-overview.component.ts
  65. 170
      apps/client/src/app/components/home-overview/home-overview.html
  66. 4
      apps/client/src/app/components/home-summary/home-summary.html
  67. 3
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  68. 10
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html
  69. 6
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts
  70. 28
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  71. 4
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  72. 24
      apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts
  73. 126
      apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html
  74. 6
      apps/client/src/app/components/position/position.component.html
  75. 3
      apps/client/src/app/components/position/position.component.ts
  76. 3
      apps/client/src/app/components/positions/positions.component.ts
  77. 50
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  78. 2
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  79. 14
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  80. 26
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  81. 4
      apps/client/src/app/components/world-map-chart/world-map-chart.component.ts
  82. 10
      apps/client/src/app/core/http-response.interceptor.ts
  83. 2
      apps/client/src/app/pages/about/about-page.html
  84. 42
      apps/client/src/app/pages/about/oss-friends/oss-friends-page.html
  85. 18
      apps/client/src/app/pages/about/overview/about-overview-page.html
  86. 6
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  87. 12
      apps/client/src/app/pages/accounts/accounts-page.html
  88. 48
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  89. 2
      apps/client/src/app/pages/admin/admin-page.html
  90. 92
      apps/client/src/app/pages/blog/blog-page.html
  91. 2
      apps/client/src/app/pages/faq/faq-page.html
  92. 70
      apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html
  93. 58
      apps/client/src/app/pages/features/features-page.html
  94. 2
      apps/client/src/app/pages/home/home-page.html
  95. 1
      apps/client/src/app/pages/i18n/i18n-page.html
  96. 3
      apps/client/src/app/pages/landing/landing-page.html
  97. 11
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  98. 35
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  99. 2
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  100. 123
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

6
.prettierrc

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

109
CHANGELOG.md

@ -7,12 +7,121 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Changed
- Optimized the calculation of the portfolio summary
## 2.60.0 - 2024-03-02
### Added
- 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
- Added an accounts tab to the position detail dialog - Added an accounts tab to the position detail dialog
- Added `INACTIVE` as a new user role
### Changed ### 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`) - Improved the language localization for German (`de`)
- Upgraded `ng-extract-i18n-merge` from version `2.9.1` to `2.10.0` - Upgraded `ng-extract-i18n-merge` from version `2.9.1` to `2.10.0`

1
README.md

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

29
apps/api/project.json

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

2
apps/api/src/app/account/account.controller.ts

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

4
apps/api/src/app/account/account.service.ts

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

3
apps/api/src/app/account/create-account.dto.ts

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

3
apps/api/src/app/account/update-account.dto.ts

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

3
apps/api/src/app/admin/update-asset-profile.dto.ts

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

1
apps/api/src/app/app.module.ts

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

2
apps/api/src/app/import/import.controller.ts

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

59
apps/api/src/app/import/import.service.ts

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

3
apps/api/src/app/order/create-order.dto.ts

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

70
apps/api/src/app/order/order.service.ts

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

3
apps/api/src/app/order/update-order.dto.ts

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

4
apps/api/src/app/portfolio/current-rate.service.spec.ts

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

20
apps/api/src/app/portfolio/current-rate.service.ts

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

2
apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts

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

2
apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts

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

2
apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

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

2
apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts

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

2
apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts

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

2
apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

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

2
apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts

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

2
apps/api/src/app/portfolio/portfolio-calculator.spec.ts

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

1
apps/api/src/app/portfolio/portfolio-calculator.ts

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

29
apps/api/src/app/portfolio/portfolio.controller.ts

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

168
apps/api/src/app/portfolio/portfolio.service.ts

@ -62,6 +62,7 @@ import {
Tag Tag
} from '@prisma/client'; } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { isUUID } from 'class-validator';
import { import {
differenceInDays, differenceInDays,
format, format,
@ -119,7 +120,7 @@ export class PortfolioService {
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<AccountWithValue[]> { }): Promise<AccountWithValue[]> {
const where: Prisma.AccountWhereInput = { userId: userId }; const where: Prisma.AccountWhereInput = { userId };
const accountFilter = filters?.find(({ type }) => { const accountFilter = filters?.find(({ type }) => {
return type === 'ACCOUNT'; return type === 'ACCOUNT';
@ -227,18 +228,20 @@ export class PortfolioService {
impersonationId: string; impersonationId: string;
}): Promise<InvestmentItem[]> { }): Promise<InvestmentItem[]> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
filters, filters,
userCurrency,
userId, userId,
types: ['DIVIDEND'], types: ['DIVIDEND']
userCurrency: this.request.user.Settings.settings.baseCurrency
}); });
let dividends = activities.map((dividend) => { let dividends = activities.map(({ date, valueInBaseCurrency }) => {
return { return {
date: format(dividend.date, DATE_FORMAT), date: format(date, DATE_FORMAT),
investment: dividend.valueInBaseCurrency investment: valueInBaseCurrency
}; };
}); });
@ -275,7 +278,8 @@ export class PortfolioService {
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
userId, userId,
includeDrafts: true includeDrafts: true,
types: ['BUY', 'SELL']
}); });
if (transactionPoints.length === 0) { if (transactionPoints.length === 0) {
@ -420,7 +424,7 @@ export class PortfolioService {
); );
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes({ items: dataGatheringItems }), this.dataProviderService.getQuotes({ user, items: dataGatheringItems }),
this.symbolProfileService.getSymbolProfiles(dataGatheringItems) this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
]); ]);
@ -529,12 +533,20 @@ export class PortfolioService {
grossPerformance: item.grossPerformance?.toNumber() ?? 0, grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent: grossPerformancePercent:
item.grossPerformancePercentage?.toNumber() ?? 0, item.grossPerformancePercentage?.toNumber() ?? 0,
grossPerformancePercentWithCurrencyEffect:
item.grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
grossPerformanceWithCurrencyEffect:
item.grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
investment: item.investment.toNumber(), investment: item.investment.toNumber(),
marketPrice: item.marketPrice, marketPrice: item.marketPrice,
marketState: dataProviderResponse?.marketState ?? 'delayed', marketState: dataProviderResponse?.marketState ?? 'delayed',
name: symbolProfile.name, name: symbolProfile.name,
netPerformance: item.netPerformance?.toNumber() ?? 0, netPerformance: item.netPerformance?.toNumber() ?? 0,
netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0, netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0,
netPerformancePercentWithCurrencyEffect:
item.netPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
netPerformanceWithCurrencyEffect:
item.netPerformanceWithCurrencyEffect?.toNumber() ?? 0,
quantity: item.quantity.toNumber(), quantity: item.quantity.toNumber(),
sectors: symbolProfile.sectors, sectors: symbolProfile.sectors,
symbol: item.symbol, symbol: item.symbol,
@ -606,6 +618,7 @@ export class PortfolioService {
} }
const summary = await this.getSummary({ const summary = await this.getSummary({
holdings,
impersonationId, impersonationId,
userCurrency, userCurrency,
userId, userId,
@ -692,7 +705,7 @@ export class PortfolioService {
.filter((order) => { .filter((order) => {
tags = tags.concat(order.tags); tags = tags.concat(order.tags);
return order.type === 'BUY' || order.type === 'SELL'; return ['BUY', 'ITEM', 'SELL'].includes(order.type);
}) })
.map((order) => ({ .map((order) => ({
currency: order.SymbolProfile.currency, currency: order.SymbolProfile.currency,
@ -741,7 +754,9 @@ export class PortfolioService {
} = position; } = position;
const accounts: PortfolioPositionDetail['accounts'] = uniqBy( const accounts: PortfolioPositionDetail['accounts'] = uniqBy(
orders, orders.filter(({ Account }) => {
return Account;
}),
'Account.id' 'Account.id'
).map(({ Account }) => { ).map(({ Account }) => {
return Account; return Account;
@ -861,6 +876,7 @@ export class PortfolioService {
}; };
} else { } else {
const currentData = await this.dataProviderService.getQuotes({ const currentData = await this.dataProviderService.getQuotes({
user,
items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }] items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }]
}); });
const marketPrice = currentData[aSymbol]?.marketPrice; const marketPrice = currentData[aSymbol]?.marketPrice;
@ -939,11 +955,13 @@ export class PortfolioService {
return type === 'SEARCH_QUERY'; return type === 'SEARCH_QUERY';
})?.id; })?.id;
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
userId userId,
types: ['BUY', 'SELL']
}); });
if (transactionPoints?.length <= 0) { if (transactionPoints?.length <= 0) {
@ -979,7 +997,7 @@ export class PortfolioService {
}); });
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes({ items: dataGatheringItems }), this.dataProviderService.getQuotes({ user, items: dataGatheringItems }),
this.symbolProfileService.getSymbolProfiles( this.symbolProfileService.getSymbolProfiles(
positions.map(({ dataSource, symbol }) => { positions.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
@ -1073,13 +1091,15 @@ export class PortfolioService {
filters, filters,
impersonationId, impersonationId,
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false,
withItems = false
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string; impersonationId: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
withItems?: boolean;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@ -1114,7 +1134,8 @@ export class PortfolioService {
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
userId, userId,
withExcludedAccounts withExcludedAccounts,
types: withItems ? ['BUY', 'ITEM', 'SELL'] : ['BUY', 'SELL']
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
@ -1266,7 +1287,8 @@ export class PortfolioService {
const { orders, portfolioOrders, transactionPoints } = const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
userId userId,
types: ['BUY', 'SELL']
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
@ -1598,12 +1620,16 @@ export class PortfolioService {
dateOfFirstActivity: undefined, dateOfFirstActivity: undefined,
grossPerformance: 0, grossPerformance: 0,
grossPerformancePercent: 0, grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
investment: balance, investment: balance,
marketPrice: 0, marketPrice: 0,
marketState: 'open', marketState: 'open',
name: currency, name: currency,
netPerformance: 0, netPerformance: 0,
netPerformancePercent: 0, netPerformancePercent: 0,
netPerformancePercentWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
quantity: 0, quantity: 0,
sectors: [], sectors: [],
symbol: currency, symbol: currency,
@ -1684,12 +1710,14 @@ export class PortfolioService {
private async getSummary({ private async getSummary({
balanceInBaseCurrency, balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency, emergencyFundPositionsValueInBaseCurrency,
holdings,
impersonationId, impersonationId,
userCurrency, userCurrency,
userId userId
}: { }: {
balanceInBaseCurrency: number; balanceInBaseCurrency: number;
emergencyFundPositionsValueInBaseCurrency: number; emergencyFundPositionsValueInBaseCurrency: number;
holdings: PortfolioDetails['holdings'];
impersonationId: string; impersonationId: string;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
@ -1703,43 +1731,57 @@ export class PortfolioService {
}); });
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
userCurrency,
userId
});
let { activities: excludedActivities } = await this.orderService.getOrders({
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts: true withExcludedAccounts: true
}); });
excludedActivities = excludedActivities.filter(({ Account: account }) => { const excludedActivities: Activity[] = [];
return account?.isExcluded ?? false; const nonExcludedActivities: Activity[] = [];
});
for (const activity of activities) {
if (activity.Account?.isExcluded) {
excludedActivities.push(activity);
} else {
nonExcludedActivities.push(activity);
}
}
const dividend = this.getSumOfActivityType({ const dividend = this.getSumOfActivityType({
activities, activities,
userCurrency, userCurrency,
activityType: 'DIVIDEND' activityType: 'DIVIDEND'
}).toNumber(); }).toNumber();
const emergencyFund = new Big( const emergencyFund = new Big(
Math.max( Math.max(
emergencyFundPositionsValueInBaseCurrency, emergencyFundPositionsValueInBaseCurrency,
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
) )
); );
const fees = this.getFees({ activities, userCurrency }).toNumber(); const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date; const firstOrderDate = activities[0]?.date;
const interest = this.getSumOfActivityType({ const interest = this.getSumOfActivityType({
activities, activities,
userCurrency, userCurrency,
activityType: 'INTEREST' activityType: 'INTEREST'
}).toNumber(); }).toNumber();
const items = this.getSumOfActivityType({
activities, const items = Object.keys(holdings)
userCurrency, .filter((symbol) => {
activityType: 'ITEM' return isUUID(symbol) && holdings[symbol].dataSource === 'MANUAL';
}).toNumber(); })
.map((symbol) => {
return holdings[symbol].valueInBaseCurrency;
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
)
.toNumber();
const liabilities = this.getSumOfActivityType({ const liabilities = this.getSumOfActivityType({
activities, activities,
userCurrency, userCurrency,
@ -1747,13 +1789,14 @@ export class PortfolioService {
}).toNumber(); }).toNumber();
const totalBuy = this.getSumOfActivityType({ const totalBuy = this.getSumOfActivityType({
activities,
userCurrency, userCurrency,
activities: nonExcludedActivities,
activityType: 'BUY' activityType: 'BUY'
}).toNumber(); }).toNumber();
const totalSell = this.getSumOfActivityType({ const totalSell = this.getSumOfActivityType({
activities,
userCurrency, userCurrency,
activities: nonExcludedActivities,
activityType: 'SELL' activityType: 'SELL'
}).toNumber(); }).toNumber();
@ -1761,7 +1804,9 @@ export class PortfolioService {
.minus(emergencyFund) .minus(emergencyFund)
.plus(emergencyFundPositionsValueInBaseCurrency) .plus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(); .toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell); const committedFunds = new Big(totalBuy).minus(totalSell);
const totalOfExcludedActivities = this.getSumOfActivityType({ const totalOfExcludedActivities = this.getSumOfActivityType({
userCurrency, userCurrency,
activities: excludedActivities, activities: excludedActivities,
@ -1812,9 +1857,25 @@ export class PortfolioService {
}) })
?.toNumber(); ?.toNumber();
const annualizedPerformancePercentWithCurrencyEffect =
new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
orders: []
})
.getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent: new Big(
performanceInformation.performance.currentNetPerformancePercentWithCurrencyEffect
)
})
?.toNumber();
return { return {
...performanceInformation.performance, ...performanceInformation.performance,
annualizedPerformancePercent, annualizedPerformancePercent,
annualizedPerformancePercentWithCurrencyEffect,
cash, cash,
dividend, dividend,
excludedAccountsAndActivities, excludedAccountsAndActivities,
@ -1879,11 +1940,13 @@ export class PortfolioService {
private async getTransactionPoints({ private async getTransactionPoints({
filters, filters,
includeDrafts = false, includeDrafts = false,
types = ['BUY', 'ITEM', 'SELL'],
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
types?: ActivityType[];
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<{ }): Promise<{
@ -1897,10 +1960,10 @@ export class PortfolioService {
const { activities, count } = await this.orderService.getOrders({ const { activities, count } = await this.orderService.getOrders({
filters, filters,
includeDrafts, includeDrafts,
types,
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts, withExcludedAccounts
types: ['BUY', 'SELL']
}); });
if (count <= 0) { if (count <= 0) {
@ -1960,7 +2023,7 @@ export class PortfolioService {
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
orders: OrderWithAccount[]; orders: Activity[];
portfolioItemsNow: { [p: string]: TimelinePosition }; portfolioItemsNow: { [p: string]: TimelinePosition };
userCurrency: string; userCurrency: string;
userId: string; userId: string;
@ -1972,7 +2035,7 @@ export class PortfolioService {
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts, withExcludedAccounts,
types: ['ITEM', 'LIABILITY'] types: ['LIABILITY']
}); });
const accounts: PortfolioDetails['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
@ -2056,41 +2119,42 @@ export class PortfolioService {
}; };
} }
for (const order of ordersByAccount) { for (const {
Account,
quantity,
SymbolProfile,
type
} of ordersByAccount) {
let currentValueOfSymbolInBaseCurrency = let currentValueOfSymbolInBaseCurrency =
order.quantity * quantity *
(portfolioItemsNow[order.SymbolProfile.symbol] portfolioItemsNow[SymbolProfile.symbol]
?.marketPriceInBaseCurrency ?? ?.marketPriceInBaseCurrency ?? 0;
order.unitPrice ??
0);
if (order.type === 'LIABILITY' || order.type === 'SELL') { if (['LIABILITY', 'SELL'].includes(type)) {
currentValueOfSymbolInBaseCurrency *= -1; currentValueOfSymbolInBaseCurrency *= -1;
} }
if (accounts[order.Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { if (accounts[Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
accounts[order.Account?.id || UNKNOWN_KEY].valueInBaseCurrency += accounts[Account?.id || UNKNOWN_KEY].valueInBaseCurrency +=
currentValueOfSymbolInBaseCurrency; currentValueOfSymbolInBaseCurrency;
} else { } else {
accounts[order.Account?.id || UNKNOWN_KEY] = { accounts[Account?.id || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: order.Account?.currency, currency: Account?.currency,
name: account.name, name: account.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
}; };
} }
if ( if (
platforms[order.Account?.Platform?.id || UNKNOWN_KEY] platforms[Account?.Platform?.id || UNKNOWN_KEY]?.valueInBaseCurrency
?.valueInBaseCurrency
) { ) {
platforms[ platforms[Account?.Platform?.id || UNKNOWN_KEY].valueInBaseCurrency +=
order.Account?.Platform?.id || UNKNOWN_KEY currentValueOfSymbolInBaseCurrency;
].valueInBaseCurrency += currentValueOfSymbolInBaseCurrency;
} else { } else {
platforms[order.Account?.Platform?.id || UNKNOWN_KEY] = { platforms[Account?.Platform?.id || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: order.Account?.currency, currency: Account?.currency,
name: account.Platform?.name, name: account.Platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
}; };

1
apps/api/src/app/redis-cache/redis-cache.module.ts

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

2
apps/api/src/app/symbol/symbol.controller.ts

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

3
apps/api/src/app/user/update-user-setting.dto.ts

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

13
apps/api/src/app/user/user.controller.ts

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

12
apps/api/src/app/user/user.service.ts

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

1223
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json

File diff suppressed because it is too large

1
apps/api/src/assets/cryptocurrencies/custom.json

@ -5,5 +5,6 @@
"LUNA2": "Terra", "LUNA2": "Terra",
"SGB1": "Songbird", "SGB1": "Songbird",
"UNI1": "Uniswap", "UNI1": "Uniswap",
"UNI7083": "Uniswap",
"UST": "TerraUSD" "UST": "TerraUSD"
} }

2
apps/api/src/interceptors/redact-values-in-response.interceptor.ts

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

1
apps/api/src/services/configuration/configuration.service.ts

@ -43,6 +43,7 @@ export class ConfigurationService {
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }), MAX_ITEM_IN_CACHE: num({ default: 9999 }),
PORT: port({ default: 3333 }), PORT: port({ default: 3333 }),
REDIS_DB: num({ default: 0 }),
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }), REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),

12
apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts

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

16
apps/api/src/services/data-provider/coingecko/coingecko.service.ts

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

4
apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts

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

16
apps/api/src/services/data-provider/data-provider.service.ts

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

41
apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts

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

12
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

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

12
apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts

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

6
apps/api/src/services/data-provider/interfaces/data-provider.interface.ts

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

28
apps/api/src/services/data-provider/manual/manual.service.ts

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

12
apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts

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

20
apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts

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

2
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

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

1
apps/api/src/services/interfaces/environment.interface.ts

@ -30,6 +30,7 @@ export interface Environment extends CleanedEnvAccessors {
MAX_ACTIVITIES_TO_IMPORT: number; MAX_ACTIVITIES_TO_IMPORT: number;
MAX_ITEM_IN_CACHE: number; MAX_ITEM_IN_CACHE: number;
PORT: number; PORT: number;
REDIS_DB: number;
REDIS_HOST: string; REDIS_HOST: string;
REDIS_PASSWORD: string; REDIS_PASSWORD: string;
REDIS_PORT: number; REDIS_PORT: number;

25
apps/client/project.json

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

3
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

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

15
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

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

9
apps/client/src/app/components/accounts-table/accounts-table.component.html

@ -40,12 +40,7 @@
[tooltip]="element.Platform?.name" [tooltip]="element.Platform?.name"
[url]="element.Platform?.url" [url]="element.Platform?.url"
/> />
<span>{{ element.name }} </span> <span>{{ element.name }}</span>
<span
*ngIf="element.isDefault"
class="d-lg-inline-block d-none text-muted"
>(Default)</span
>
</td> </td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td> <td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
</ng-container> </ng-container>
@ -261,7 +256,7 @@
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.isDefault || element.transactionCount > 0" [disabled]="element.transactionCount > 0"
(click)="onDeleteAccount(element.id)" (click)="onDeleteAccount(element.id)"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">

4
apps/client/src/app/components/accounts-table/accounts-table.component.ts

@ -1,3 +1,5 @@
import { getLocale } from '@ghostfolio/common/helper';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@ -27,7 +29,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToOpenDetails = true; @Input() hasPermissionToOpenDetails = true;
@Input() locale: string; @Input() locale = getLocale();
@Input() showActions: boolean; @Input() showActions: boolean;
@Input() showBalance = true; @Input() showBalance = true;
@Input() showFooter = true; @Input() showFooter = true;

25
apps/client/src/app/components/admin-market-data/admin-market-data.html

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

4
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

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

121
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -25,7 +25,9 @@
mat-menu-item mat-menu-item
type="button" type="button"
[disabled]="assetProfileForm.dirty" [disabled]="assetProfileForm.dirty"
(click)="onGatherSymbol({dataSource: data.dataSource, symbol: data.symbol})" (click)="
onGatherSymbol({ dataSource: data.dataSource, symbol: data.symbol })
"
> >
<ng-container i18n>Gather Historical Data</ng-container> <ng-container i18n>Gather Historical Data</ng-container>
</button> </button>
@ -33,7 +35,12 @@
mat-menu-item mat-menu-item
type="button" type="button"
[disabled]="assetProfileForm.dirty" [disabled]="assetProfileForm.dirty"
(click)="onGatherProfileDataBySymbol({dataSource: data.dataSource, symbol: data.symbol})" (click)="
onGatherProfileDataBySymbol({
dataSource: data.dataSource,
symbol: data.symbol
})
"
> >
<ng-container i18n>Gather Profile Data</ng-container> <ng-container i18n>Gather Profile Data</ng-container>
</button> </button>
@ -73,7 +80,12 @@
color="accent" color="accent"
mat-flat-button mat-flat-button
type="button" type="button"
[disabled]="!assetProfileForm.controls['historicalData']?.controls['csvString'].touched || assetProfileForm.controls['historicalData']?.controls['csvString']?.value === ''" [disabled]="
!assetProfileForm.controls['historicalData']?.controls['csvString']
.touched ||
assetProfileForm.controls['historicalData']?.controls['csvString']
?.value === ''
"
(click)="onImportHistoricalData()" (click)="onImportHistoricalData()"
> >
<ng-container i18n>Import</ng-container> <ng-container i18n>Import</ng-container>
@ -129,49 +141,54 @@
> >
</div> </div>
<ng-container <ng-container
*ngIf="assetProfile?.countries?.length > 0 || assetProfile?.sectors?.length > 0" *ngIf="
assetProfile?.countries?.length > 0 ||
assetProfile?.sectors?.length > 0
"
> >
@if (assetProfile?.countries?.length === 1 && @if (
assetProfile?.sectors?.length === 1 ) { assetProfile?.countries?.length === 1 &&
<div *ngIf="assetProfile?.sectors?.length === 1" class="col-6 mb-3"> assetProfile?.sectors?.length === 1
<gf-value ) {
i18n <div *ngIf="assetProfile?.sectors?.length === 1" class="col-6 mb-3">
size="medium" <gf-value
[locale]="data.locale" i18n
[value]="assetProfile?.sectors[0].name" size="medium"
>Sector</gf-value [locale]="data.locale"
> [value]="assetProfile?.sectors[0].name"
</div> >Sector</gf-value
<div *ngIf="assetProfile?.countries?.length === 1" class="col-6 mb-3"> >
<gf-value </div>
i18n <div *ngIf="assetProfile?.countries?.length === 1" class="col-6 mb-3">
size="medium" <gf-value
[locale]="data.locale" i18n
[value]="assetProfile?.countries[0].name" size="medium"
>Country</gf-value [locale]="data.locale"
> [value]="assetProfile?.countries[0].name"
</div> >Country</gf-value
>
</div>
} @else { } @else {
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div> <div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[isInPercent]="true" [isInPercent]="true"
[keys]="['name']" [keys]="['name']"
[maxItems]="10" [maxItems]="10"
[positions]="sectors" [positions]="sectors"
/> />
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="h5" i18n>Countries</div> <div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[isInPercent]="true" [isInPercent]="true"
[keys]="['name']" [keys]="['name']"
[maxItems]="10" [maxItems]="10"
[positions]="countries" [positions]="countries"
/> />
</div> </div>
} }
</ng-container> </ng-container>
</div> </div>
@ -222,7 +239,17 @@
color="primary" color="primary"
i18n i18n
[checked]="isBenchmark" [checked]="isBenchmark"
(change)="isBenchmark ? onUnsetBenchmark({dataSource: data.dataSource, symbol: data.symbol}) : onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})" (change)="
isBenchmark
? onUnsetBenchmark({
dataSource: data.dataSource,
symbol: data.symbol
})
: onSetBenchmark({
dataSource: data.dataSource,
symbol: data.symbol
})
"
>Benchmark</mat-checkbox >Benchmark</mat-checkbox
> >
</div> </div>
@ -253,7 +280,9 @@
color="accent" color="accent"
mat-flat-button mat-flat-button
type="button" type="button"
[disabled]="assetProfileForm.controls['scraperConfiguration'].value === '{}'" [disabled]="
assetProfileForm.controls['scraperConfiguration'].value === '{}'
"
(click)="onTestMarketData()" (click)="onTestMarketData()"
> >
<ng-container i18n>Test</ng-container> <ng-container i18n>Test</ng-container>

16
apps/client/src/app/components/admin-overview/admin-overview.html

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

28
apps/client/src/app/components/admin-users/admin-users.html

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

3
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts

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

2
apps/client/src/app/components/header/header.component.ts

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

4
apps/client/src/app/components/home-holdings/home-holdings.component.ts

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

4
apps/client/src/app/components/home-overview/home-overview.component.ts

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

170
apps/client/src/app/components/home-overview/home-overview.html

@ -1,100 +1,100 @@
<div <div
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative" class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
> >
@if(hasPermissionToCreateOrder && historicalDataItems?.length === 0) { @if (hasPermissionToCreateOrder && historicalDataItems?.length === 0) {
<div class="justify-content-center row w-100"> <div class="justify-content-center row w-100">
<div class="col introduction"> <div class="col introduction">
<h4 i18n>Welcome to Ghostfolio</h4> <h4 i18n>Welcome to Ghostfolio</h4>
<p i18n>Ready to take control of your personal finances?</p> <p i18n>Ready to take control of your personal finances?</p>
<ol class="font-weight-bold"> <ol class="font-weight-bold">
<li <li
class="mb-2" class="mb-2"
[ngClass]="{ 'text-muted': user?.accounts?.length > 1 }" [ngClass]="{ 'text-muted': user?.accounts?.length > 1 }"
>
<a class="d-block" [routerLink]="['/accounts']"
><span i18n>Setup your accounts</span><br />
<span class="font-weight-normal" i18n
>Get a comprehensive financial overview by adding your bank and
brokerage accounts.</span
></a
> >
</li> <a class="d-block" [routerLink]="['/accounts']"
<li class="mb-2"> ><span i18n>Setup your accounts</span><br />
<a class="d-block" [routerLink]="['/portfolio', 'activities']"> <span class="font-weight-normal" i18n
<span i18n>Capture your activities</span><br /> >Get a comprehensive financial overview by adding your bank and
<span class="font-weight-normal" i18n brokerage accounts.</span
>Record your investment activities to keep your portfolio up to ></a
date.</span >
></a </li>
> <li class="mb-2">
</li> <a class="d-block" [routerLink]="['/portfolio', 'activities']">
<li class="mb-2"> <span i18n>Capture your activities</span><br />
<a class="d-block" [routerLink]="['/portfolio']"> <span class="font-weight-normal" i18n
<span i18n>Monitor and analyze your portfolio</span><br /> >Record your investment activities to keep your portfolio up to
<span class="font-weight-normal" i18n date.</span
>Track your progress in real-time with comprehensive analysis and ></a
insights.</span
> >
</li>
<li class="mb-2">
<a class="d-block" [routerLink]="['/portfolio']">
<span i18n>Monitor and analyze your portfolio</span><br />
<span class="font-weight-normal" i18n
>Track your progress in real-time with comprehensive analysis
and insights.</span
>
</a>
</li>
</ol>
<div class="d-flex justify-content-center">
<a
*ngIf="user?.accounts?.length === 1"
color="primary"
mat-flat-button
[routerLink]="['/accounts']"
>
<ng-container i18n>Setup accounts</ng-container>
</a>
<a
*ngIf="user?.accounts?.length > 1"
color="primary"
mat-flat-button
[routerLink]="['/portfolio', 'activities']"
>
<ng-container i18n>Add activity</ng-container>
</a> </a>
</li> </div>
</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>
</div> </div>
</div> </div>
</div>
} @else { } @else {
<div class="row w-100"> <div class="row w-100">
<div class="col p-0"> <div class="col p-0">
<div class="chart-container mx-auto position-relative"> <div class="chart-container mx-auto position-relative">
<gf-line-chart <gf-line-chart
class="position-absolute" class="position-absolute"
symbol="Performance" symbol="Performance"
unit="%" unit="%"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[hidden]="historicalDataItems?.length === 0" [hidden]="historicalDataItems?.length === 0"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[isAnimated]="user?.settings?.dateRange === '1d' ? false : true" [isAnimated]="user?.settings?.dateRange === '1d' ? false : true"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
/>
</div>
</div>
</div>
<div class="overview-container row mt-1">
<div class="col">
<gf-portfolio-performance
class="pb-4"
[deviceType]="deviceType"
[errors]="errors"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }" [performance]="performance"
[showGradient]="true" [showDetails]="showDetails"
[showLoader]="false" [unit]="unit"
[showXAxis]="false"
[showYAxis]="false"
/> />
</div> </div>
</div> </div>
</div>
<div class="overview-container row mt-1">
<div class="col">
<gf-portfolio-performance
class="pb-4"
[deviceType]="deviceType"
[errors]="errors"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance"
[locale]="user?.settings?.locale"
[performance]="performance"
[showDetails]="showDetails"
[unit]="unit"
/>
</div>
</div>
} }
</div> </div>

4
apps/client/src/app/components/home-summary/home-summary.html

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

3
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -9,6 +9,7 @@ import {
DATE_FORMAT, DATE_FORMAT,
getBackgroundColor, getBackgroundColor,
getDateFormatString, getDateFormatString,
getLocale,
getTextColor, getTextColor,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -65,7 +66,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() historicalDataItems: LineChartItem[] = []; @Input() historicalDataItems: LineChartItem[] = [];
@Input() isInPercent = false; @Input() isInPercent = false;
@Input() isLoading = false; @Input() isLoading = false;
@Input() locale: string; @Input() locale = getLocale();
@Input() range: DateRange = 'max'; @Input() range: DateRange = 'max';
@Input() savingsRate = 0; @Input() savingsRate = 0;

10
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html

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

6
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts

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

28
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

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

4
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts

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

24
apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts

@ -50,15 +50,13 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public dividendInBaseCurrency: number; public dividendInBaseCurrency: number;
public feeInBaseCurrency: number; public feeInBaseCurrency: number;
public firstBuyDate: string; public firstBuyDate: string;
public grossPerformance: number;
public grossPerformancePercent: number;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public investment: number; public investment: number;
public marketPrice: number; public marketPrice: number;
public maxPrice: number; public maxPrice: number;
public minPrice: number; public minPrice: number;
public netPerformance: number; public netPerformancePercentWithCurrencyEffect: number;
public netPerformancePercent: number; public netPerformanceWithCurrencyEffect: number;
public quantity: number; public quantity: number;
public quantityPrecision = 2; public quantityPrecision = 2;
public reportDataGlitchMail: string; public reportDataGlitchMail: string;
@ -84,7 +82,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
private userService: UserService private userService: UserService
) {} ) {}
public ngOnInit(): void { public ngOnInit() {
this.dataService this.dataService
.fetchPositionDetail({ .fetchPositionDetail({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
@ -99,15 +97,13 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
dividendInBaseCurrency, dividendInBaseCurrency,
feeInBaseCurrency, feeInBaseCurrency,
firstBuyDate, firstBuyDate,
grossPerformance,
grossPerformancePercent,
historicalData, historicalData,
investment, investment,
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
netPerformance, netPerformancePercentWithCurrencyEffect,
netPerformancePercent, netPerformanceWithCurrencyEffect,
orders, orders,
quantity, quantity,
SymbolProfile, SymbolProfile,
@ -125,8 +121,6 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.dividendInBaseCurrency = dividendInBaseCurrency; this.dividendInBaseCurrency = dividendInBaseCurrency;
this.feeInBaseCurrency = feeInBaseCurrency; this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate; this.firstBuyDate = firstBuyDate;
this.grossPerformance = grossPerformance;
this.grossPerformancePercent = grossPerformancePercent;
this.historicalDataItems = historicalData.map( this.historicalDataItems = historicalData.map(
(historicalDataItem) => { (historicalDataItem) => {
this.benchmarkDataItems.push({ this.benchmarkDataItems.push({
@ -144,8 +138,10 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.marketPrice = marketPrice; this.marketPrice = marketPrice;
this.maxPrice = maxPrice; this.maxPrice = maxPrice;
this.minPrice = minPrice; this.minPrice = minPrice;
this.netPerformance = netPerformance; this.netPerformancePercentWithCurrencyEffect =
this.netPerformancePercent = netPerformancePercent; netPerformancePercentWithCurrencyEffect;
this.netPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect;
this.quantity = quantity; this.quantity = quantity;
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`; this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.sectors = {}; this.sectors = {};
@ -267,7 +263,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
}); });
} }
public onClose(): void { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }

126
apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html

@ -44,7 +44,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="data.baseCurrency" [unit]="data.baseCurrency"
[value]="netPerformance" [value]="netPerformanceWithCurrencyEffect"
>Change</gf-value >Change</gf-value
> >
</div> </div>
@ -55,7 +55,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="data.locale" [locale]="data.locale"
[value]="netPerformancePercent" [value]="netPerformancePercentWithCurrencyEffect"
>Performance</gf-value >Performance</gf-value
> >
</div> </div>
@ -87,7 +87,11 @@
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[ngClass]="{ 'text-danger': minPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }" [ngClass]="{
'text-danger':
minPrice?.toFixed(2) === marketPrice?.toFixed(2) &&
maxPrice?.toFixed(2) !== minPrice?.toFixed(2)
}"
[unit]="SymbolProfile?.currency" [unit]="SymbolProfile?.currency"
[value]="minPrice" [value]="minPrice"
>Minimum Price</gf-value >Minimum Price</gf-value
@ -99,7 +103,11 @@
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[ngClass]="{ 'text-success': maxPrice?.toFixed(2) === marketPrice?.toFixed(2) && maxPrice?.toFixed(2) !== minPrice?.toFixed(2) }" [ngClass]="{
'text-success':
maxPrice?.toFixed(2) === marketPrice?.toFixed(2) &&
maxPrice?.toFixed(2) !== minPrice?.toFixed(2)
}"
[unit]="SymbolProfile?.currency" [unit]="SymbolProfile?.currency"
[value]="maxPrice" [value]="maxPrice"
>Maximum Price</gf-value >Maximum Price</gf-value
@ -184,53 +192,61 @@
> >
</div> </div>
<ng-container <ng-container
*ngIf="SymbolProfile?.countries?.length > 0 || SymbolProfile?.sectors?.length > 0" *ngIf="
SymbolProfile?.countries?.length > 0 ||
SymbolProfile?.sectors?.length > 0
"
> >
@if(SymbolProfile?.countries?.length === 1 && @if (
SymbolProfile?.sectors?.length === 1) { SymbolProfile?.countries?.length === 1 &&
<div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3"> SymbolProfile?.sectors?.length === 1
<gf-value ) {
i18n <div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3">
size="medium" <gf-value
[locale]="data.locale" i18n
[value]="SymbolProfile.sectors[0].name" size="medium"
>Sector</gf-value [locale]="data.locale"
[value]="SymbolProfile.sectors[0].name"
>Sector</gf-value
>
</div>
<div
*ngIf="SymbolProfile?.countries?.length === 1"
class="col-6 mb-3"
> >
</div> <gf-value
<div *ngIf="SymbolProfile?.countries?.length === 1" class="col-6 mb-3"> i18n
<gf-value size="medium"
i18n [locale]="data.locale"
size="medium" [value]="SymbolProfile.countries[0].name"
[locale]="data.locale" >Country</gf-value
[value]="SymbolProfile.countries[0].name" >
>Country</gf-value </div>
>
</div>
} @else { } @else {
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div> <div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="data.baseCurrency" [baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[isInPercent]="true" [isInPercent]="true"
[keys]="['name']" [keys]="['name']"
[locale]="data.locale" [locale]="data.locale"
[maxItems]="10" [maxItems]="10"
[positions]="sectors" [positions]="sectors"
/> />
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="h5" i18n>Countries</div> <div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
[baseCurrency]="data.baseCurrency" [baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme" [colorScheme]="data.colorScheme"
[isInPercent]="true" [isInPercent]="true"
[keys]="['name']" [keys]="['name']"
[locale]="data.locale" [locale]="data.locale"
[maxItems]="10" [maxItems]="10"
[positions]="countries" [positions]="countries"
/> />
</div> </div>
} }
</ng-container> </ng-container>
<div *ngIf="dataProviderInfo" class="col-md-12 mb-3 text-center"> <div *ngIf="dataProviderInfo" class="col-md-12 mb-3 text-center">
@ -257,7 +273,9 @@
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!data.hasImpersonationId && !user.settings.isRestrictedView" [hasPermissionToExportActivities]="
!data.hasImpersonationId && !user.settings.isRestrictedView
"
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[locale]="data.locale" [locale]="data.locale"
@ -294,15 +312,17 @@
<div class="col"> <div class="col">
<div class="h5" i18n>Tags</div> <div class="h5" i18n>Tags</div>
<mat-chip-listbox> <mat-chip-listbox>
<mat-chip-option *ngFor="let tag of tags" disabled <mat-chip-option *ngFor="let tag of tags" disabled>{{
>{{ tag.name }}</mat-chip-option tag.name
> }}</mat-chip-option>
</mat-chip-listbox> </mat-chip-listbox>
</div> </div>
</div> </div>
<div <div
*ngIf="activities?.length > 0 && data.hasPermissionToReportDataGlitch === true" *ngIf="
activities?.length > 0 && data.hasPermissionToReportDataGlitch === true
"
class="row" class="row"
> >
<div class="col"> <div class="col">

6
apps/client/src/app/components/position/position.component.html

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

3
apps/client/src/app/components/position/position.component.ts

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

3
apps/client/src/app/components/positions/positions.component.ts

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

50
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

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

2
apps/client/src/app/components/user-account-access/user-account-access.component.ts

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

14
apps/client/src/app/components/user-account-membership/user-account-membership.html

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

26
apps/client/src/app/components/user-account-settings/user-account-settings.html

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

4
apps/client/src/app/components/world-map-chart/world-map-chart.component.ts

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

10
apps/client/src/app/core/http-response.interceptor.ts

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

2
apps/client/src/app/pages/about/about-page.html

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

42
apps/client/src/app/pages/about/oss-friends/oss-friends-page.html

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

18
apps/client/src/app/pages/about/overview/about-overview-page.html

@ -147,15 +147,15 @@
> >
</div> </div>
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription) {
<div class="col-md-6 col-xs-12 my-2"> <div class="col-md-6 col-xs-12 my-2">
<a <a
class="py-4 w-100" class="py-4 w-100"
color="primary" color="primary"
mat-flat-button mat-flat-button
[routerLink]="['/blog']" [routerLink]="['/blog']"
>Blog</a >Blog</a
> >
</div> </div>
} }
</div> </div>
</div> </div>

6
apps/client/src/app/pages/accounts/accounts-page.component.ts

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

12
apps/client/src/app/pages/accounts/accounts-page.html

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

48
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html

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

2
apps/client/src/app/pages/admin/admin-page.html

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

92
apps/client/src/app/pages/blog/blog-page.html

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

2
apps/client/src/app/pages/faq/faq-page.html

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

70
apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html

@ -18,6 +18,76 @@
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>. <a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>How do I add a new currency?</mat-card-title>
</mat-card-header>
<mat-card-content>
<p>
Ghostfolio manages currencies automatically based on all the
recorded activities. If you need an additional currency, you can
manually enter it.
</p>
<ol>
<li>Go to the <i>Admin Control</i> panel</li>
<li>Click on the <i>Add Currency</i> button</li>
<li>Insert e.g. <code>EUR</code> in the prompt</li>
</ol>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>How do I resolve
<i>No exchange rate has been found</i> errors?</mat-card-title
>
</mat-card-header>
<mat-card-content>
<p>
In Ghostfolio, you are responsible for providing the relevant
historical exchange rates. This can be done with a one-time import
of the data. If you see errors like
<i
>Historical exchange rate at 2024-01-01 is not available from
"EUR" to "USD"</i
>
do the following:
</p>
<ol>
<li>Go to the <i>Admin Control</i> panel</li>
<li>Go to the <i>Market Data</i> section</li>
<li>Select <i>Filter by Currencies</i></li>
<li>Find the entry <i>USDEUR</i></li>
<li>
Click the menu item <i>Gather Historical Data</i> in the dialog
</li>
</ol>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>How do I add a new platform?</mat-card-title>
</mat-card-header>
<mat-card-content>
<ol>
<li>Go to the <i>Admin Control</i> panel</li>
<li>Go to the <i>Settings</i> section</li>
<li>Click on the <i>Add Platform</i> button</li>
</ol>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>How do I add a new tag?</mat-card-title>
</mat-card-header>
<mat-card-content>
<ol>
<li>Go to the <i>Admin Control</i> panel</li>
<li>Go to the <i>Settings</i> section</li>
<li>Click on the <i>Add Tag</i> button</li>
</ol>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-header> <mat-card-header>
<mat-card-title>Which devices are supported?</mat-card-title> <mat-card-title>Which devices are supported?</mat-card-title>

58
apps/client/src/app/pages/features/features-page.html

@ -140,7 +140,7 @@
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span i18n>Portfolio Calculations</span> <span i18n>Portfolio Calculations</span>
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription) {
<gf-premium-indicator class="ml-1" /> <gf-premium-indicator class="ml-1" />
} }
</h4> </h4>
<p class="m-0"> <p class="m-0">
@ -159,7 +159,7 @@
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span i18n>Portfolio Allocations</span> <span i18n>Portfolio Allocations</span>
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription) {
<gf-premium-indicator class="ml-1" /> <gf-premium-indicator class="ml-1" />
} }
</h4> </h4>
<p class="m-0"> <p class="m-0">
@ -197,24 +197,24 @@
</mat-card> </mat-card>
</div> </div>
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription) {
<div class="col-xs-12 col-md-4 mb-3"> <div class="col-xs-12 col-md-4 mb-3">
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content> <mat-card-content>
<div class="flex-grow-1"> <div class="flex-grow-1">
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span i18n>Market Mood</span> <span i18n>Market Mood</span>
<gf-premium-indicator class="ml-1" /> <gf-premium-indicator class="ml-1" />
</h4> </h4>
<p class="m-0"> <p class="m-0">
Check the current market mood (<a Check the current market mood (<a
[routerLink]="routerLinkResources" [routerLink]="routerLinkResources"
>Fear & Greed Index</a >Fear & Greed Index</a
>) within the app. >) within the app.
</p> </p>
</div> </div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
} }
<div class="col-xs-12 col-md-4 mb-3"> <div class="col-xs-12 col-md-4 mb-3">
<mat-card appearance="outlined" class="d-flex flex-column h-100"> <mat-card appearance="outlined" class="d-flex flex-column h-100">
@ -223,7 +223,7 @@
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span i18n>Static Analysis</span> <span i18n>Static Analysis</span>
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription) {
<gf-premium-indicator class="ml-1" /> <gf-premium-indicator class="ml-1" />
} }
</h4> </h4>
<p class="m-0"> <p class="m-0">
@ -290,12 +290,16 @@
</div> </div>
</div> </div>
@if (!user) { @if (!user) {
<div class="row"> <div class="row">
<div class="col mt-3 text-center"> <div class="col mt-3 text-center">
<a color="primary" i18n mat-flat-button [routerLink]="routerLinkRegister" <a
>Get Started</a color="primary"
> i18n
mat-flat-button
[routerLink]="routerLinkRegister"
>Get Started</a
>
</div>
</div> </div>
</div>
} }
</div> </div>

2
apps/client/src/app/pages/home/home-page.html

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

1
apps/client/src/app/pages/i18n/i18n-page.html

@ -10,6 +10,7 @@
app, asset, cryptocurrency, dashboard, etf, finance, management, app, asset, cryptocurrency, dashboard, etf, finance, management,
performance, portfolio, software, stock, trading, wealth, web3 performance, portfolio, software, stock, trading, wealth, web3
</li> </li>
<li i18n="@@myAccount">My Account</li>
<li i18n="@@slogan">Open Source Wealth Management Software</li> <li i18n="@@slogan">Open Source Wealth Management Software</li>
</ul> </ul>
</div> </div>

3
apps/client/src/app/pages/landing/landing-page.html

@ -339,7 +339,8 @@
[href]="testimonial.url" [href]="testimonial.url"
>{{ testimonial.author }}</a >{{ testimonial.author }}</a
> >
<span *ngIf="!testimonial.url">{{ testimonial.author }}</span>, <span *ngIf="!testimonial.url">{{ testimonial.author }}</span
>,
{{ testimonial.country }} {{ testimonial.country }}
</div> </div>
</div> </div>

11
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -36,7 +36,6 @@ import { ImportActivitiesDialogParams } from './import-activities-dialog/interfa
export class ActivitiesPageComponent implements OnDestroy, OnInit { export class ActivitiesPageComponent implements OnDestroy, OnInit {
public activities: Activity[]; public activities: Activity[];
public dataSource: MatTableDataSource<Activity>; public dataSource: MatTableDataSource<Activity>;
public defaultAccountId: string;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateActivity: boolean; public hasPermissionToCreateActivity: boolean;
@ -274,7 +273,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
}); });
} }
public openUpdateActivityDialog(activity: Activity): void { public openUpdateActivityDialog(activity: Activity) {
const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, { const dialogRef = this.dialog.open(CreateOrUpdateActivityDialog, {
data: { data: {
activity, activity,
@ -311,7 +310,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private openCreateActivityDialog(aActivity?: Activity): void { private openCreateActivityDialog(aActivity?: Activity) {
this.userService this.userService
.get() .get()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -323,7 +322,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
accounts: this.user?.accounts, accounts: this.user?.accounts,
activity: { activity: {
...aActivity, ...aActivity,
accountId: aActivity?.accountId ?? this.defaultAccountId, accountId: aActivity?.accountId,
date: new Date(), date: new Date(),
id: null, id: null,
fee: 0, fee: 0,
@ -399,10 +398,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
private updateUser(aUser: User) { private updateUser(aUser: User) {
this.user = aUser; this.user = aUser;
this.defaultAccountId = this.user?.accounts.find((account) => {
return account.isDefault;
})?.id;
this.hasPermissionToCreateActivity = this.hasPermissionToCreateActivity =
!this.hasImpersonationId && !this.hasImpersonationId &&
hasPermission(this.user.permissions, permissions.createOrder); hasPermission(this.user.permissions, permissions.createOrder);

35
apps/client/src/app/pages/portfolio/activities/activities-page.html

@ -11,7 +11,11 @@
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[pageIndex]="pageIndex" [pageIndex]="pageIndex"
[pageSize]="pageSize" [pageSize]="pageSize"
[showActions]="!hasImpersonationId && hasPermissionToDeleteActivity && !user.settings.isRestrictedView" [showActions]="
!hasImpersonationId &&
hasPermissionToDeleteActivity &&
!user.settings.isRestrictedView
"
[sortColumn]="sortColumn" [sortColumn]="sortColumn"
[sortDirection]="sortDirection" [sortDirection]="sortDirection"
[totalItems]="totalItems" [totalItems]="totalItems"
@ -29,18 +33,21 @@
</div> </div>
</div> </div>
@if (!hasImpersonationId && hasPermissionToCreateActivity && @if (
!user.settings.isRestrictedView) { !hasImpersonationId &&
<div class="fab-container"> hasPermissionToCreateActivity &&
<a !user.settings.isRestrictedView
class="align-items-center d-flex justify-content-center" ) {
color="primary" <div class="fab-container">
mat-fab <a
[queryParams]="{ createDialog: true }" class="align-items-center d-flex justify-content-center"
[routerLink]="[]" color="primary"
> mat-fab
<ion-icon name="add-outline" size="large" /> [queryParams]="{ createDialog: true }"
</a> [routerLink]="[]"
</div> >
<ion-icon name="add-outline" size="large" />
</a>
</div>
} }
</div> </div>

2
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -20,6 +20,7 @@ import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client'; import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { isToday } from 'date-fns';
import { EMPTY, Observable, Subject, lastValueFrom, of } from 'rxjs'; import { EMPTY, Observable, Subject, lastValueFrom, of } from 'rxjs';
import { catchError, delay, map, startWith, takeUntil } from 'rxjs/operators'; import { catchError, delay, map, startWith, takeUntil } from 'rxjs/operators';
@ -48,6 +49,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public defaultDateFormat: string; public defaultDateFormat: string;
public filteredTagsObservable: Observable<Tag[]> = of([]); public filteredTagsObservable: Observable<Tag[]> = of([]);
public isLoading = false; public isLoading = false;
public isToday = isToday;
public platforms: { id: string; name: string }[]; public platforms: { id: string; name: string }[];
public separatorKeysCodes: number[] = [ENTER, COMMA]; public separatorKeysCodes: number[] = [ENTER, COMMA];
public tags: Tag[] = []; public tags: Tag[] = [];

123
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -11,48 +11,61 @@
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label> <mat-label i18n>Type</mat-label>
<mat-select formControlName="type"> <mat-select formControlName="type">
<mat-select-trigger <mat-select-trigger>{{
>{{ typesTranslationMap[activityForm.controls['type'].value] typesTranslationMap[activityForm.controls['type'].value]
}}</mat-select-trigger }}</mat-select-trigger>
>
<mat-option value="BUY"> <mat-option value="BUY">
<span><b>{{ typesTranslationMap['BUY'] }}</b></span> <span
><b>{{ typesTranslationMap['BUY'] }}</b></span
>
<small class="d-block line-height-1 text-muted text-nowrap" i18n <small class="d-block line-height-1 text-muted text-nowrap" i18n
>Stocks, ETFs, bonds, cryptocurrencies, commodities</small >Stocks, ETFs, bonds, cryptocurrencies, commodities</small
> >
</mat-option> </mat-option>
<mat-option value="FEE"> <mat-option value="FEE">
<span><b>{{ typesTranslationMap['FEE'] }}</b></span> <span
><b>{{ typesTranslationMap['FEE'] }}</b></span
>
<small class="d-block line-height-1 text-muted text-nowrap" i18n <small class="d-block line-height-1 text-muted text-nowrap" i18n
>One-time fee, annual account fees</small >One-time fee, annual account fees</small
> >
</mat-option> </mat-option>
<mat-option value="DIVIDEND"> <mat-option value="DIVIDEND">
<span><b>{{ typesTranslationMap['DIVIDEND'] }}</b></span> <span
><b>{{ typesTranslationMap['DIVIDEND'] }}</b></span
>
<small class="d-block line-height-1 text-muted text-nowrap" i18n <small class="d-block line-height-1 text-muted text-nowrap" i18n
>Distribution of corporate earnings</small >Distribution of corporate earnings</small
> >
</mat-option> </mat-option>
<mat-option value="INTEREST"> <mat-option value="INTEREST">
<span><b>{{ typesTranslationMap['INTEREST'] }}</b></span> <span
><b>{{ typesTranslationMap['INTEREST'] }}</b></span
>
<small class="d-block line-height-1 text-muted text-nowrap" i18n <small class="d-block line-height-1 text-muted text-nowrap" i18n
>Revenue for lending out money</small >Revenue for lending out money</small
> >
</mat-option> </mat-option>
<mat-option value="LIABILITY"> <mat-option value="LIABILITY">
<span><b>{{ typesTranslationMap['LIABILITY'] }}</b></span> <span
><b>{{ typesTranslationMap['LIABILITY'] }}</b></span
>
<small class="d-block line-height-1 text-muted text-nowrap" i18n <small class="d-block line-height-1 text-muted text-nowrap" i18n
>Mortgages, personal loans, credit cards</small >Mortgages, personal loans, credit cards</small
> >
</mat-option> </mat-option>
<mat-option value="SELL"> <mat-option value="SELL">
<span><b>{{ typesTranslationMap['SELL'] }}</b></span> <span
><b>{{ typesTranslationMap['SELL'] }}</b></span
>
<small class="d-block line-height-1 text-muted text-nowrap" i18n <small class="d-block line-height-1 text-muted text-nowrap" i18n
>Stocks, ETFs, bonds, cryptocurrencies, commodities</small >Stocks, ETFs, bonds, cryptocurrencies, commodities</small
> >
</mat-option> </mat-option>
<mat-option value="ITEM"> <mat-option value="ITEM">
<span><b>{{ typesTranslationMap['ITEM'] }}</b></span> <span
><b>{{ typesTranslationMap['ITEM'] }}</b></span
>
<small class="d-block line-height-1 text-muted text-nowrap" i18n <small class="d-block line-height-1 text-muted text-nowrap" i18n
>Luxury items, real estate, private companies</small >Luxury items, real estate, private companies</small
> >
@ -60,16 +73,20 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div [ngClass]="{'mb-3': data.activity.id}"> <div [ngClass]="{ 'mb-3': data.activity.id }">
<mat-form-field <mat-form-field
appearance="outline" appearance="outline"
class="w-100" class="w-100"
[ngClass]="{'mb-1 without-hint': !data.activity.id}" [ngClass]="{ 'mb-1 without-hint': !data.activity.id }"
> >
<mat-label i18n>Account</mat-label> <mat-label i18n>Account</mat-label>
<mat-select formControlName="accountId"> <mat-select formControlName="accountId">
<mat-option <mat-option
*ngIf="!activityForm.controls['accountId'].hasValidator(Validators.required)" *ngIf="
!activityForm.controls['accountId'].hasValidator(
Validators.required
)
"
[value]="null" [value]="null"
/> />
<mat-option <mat-option
@ -88,14 +105,18 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="mb-3" [ngClass]="{'d-none': data.activity.id}"> <div class="mb-3" [ngClass]="{ 'd-none': data.activity.id }">
<mat-checkbox color="primary" formControlName="updateAccountBalance" i18n <mat-checkbox color="primary" formControlName="updateAccountBalance" i18n
>Update Cash Balance</mat-checkbox >Update Cash Balance</mat-checkbox
> >
</div> </div>
<div <div
class="mb-3" class="mb-3"
[ngClass]="{ 'd-none': !activityForm.controls['searchSymbol'].hasValidator(Validators.required) }" [ngClass]="{
'd-none': !activityForm.controls['searchSymbol'].hasValidator(
Validators.required
)
}"
> >
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name, symbol or ISIN</mat-label> <mat-label i18n>Name, symbol or ISIN</mat-label>
@ -107,7 +128,11 @@
</div> </div>
<div <div
class="mb-3" class="mb-3"
[ngClass]="{ 'd-none': !activityForm.controls['name'].hasValidator(Validators.required) }" [ngClass]="{
'd-none': !activityForm.controls['name'].hasValidator(
Validators.required
)
}"
> >
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label> <mat-label i18n>Name</mat-label>
@ -118,9 +143,9 @@
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label> <mat-label i18n>Currency</mat-label>
<mat-select formControlName="currency"> <mat-select formControlName="currency">
<mat-option *ngFor="let currency of currencies" [value]="currency" <mat-option *ngFor="let currency of currencies" [value]="currency">{{
>{{ currency }}</mat-option currency
> }}</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
@ -146,7 +171,13 @@
</div> </div>
<div <div
class="mb-3" class="mb-3"
[ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'FEE' || activityForm.controls['type']?.value === 'INTEREST' || activityForm.controls['type']?.value === 'ITEM' || activityForm.controls['type']?.value === 'LIABILITY' }" [ngClass]="{
'd-none':
activityForm.controls['type']?.value === 'FEE' ||
activityForm.controls['type']?.value === 'INTEREST' ||
activityForm.controls['type']?.value === 'ITEM' ||
activityForm.controls['type']?.value === 'LIABILITY'
}"
> >
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Quantity</mat-label> <mat-label i18n>Quantity</mat-label>
@ -155,7 +186,7 @@
</div> </div>
<div <div
class="mb-3" class="mb-3"
[ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'FEE' }" [ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'FEE' }"
> >
<div class="align-items-start d-flex"> <div class="align-items-start d-flex">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
@ -192,17 +223,26 @@
</mat-select> </mat-select>
</div> </div>
<mat-error <mat-error
*ngIf="activityForm.controls['unitPriceInCustomCurrency'].hasError('invalid')" *ngIf="
activityForm.controls['unitPriceInCustomCurrency'].hasError(
'invalid'
)
"
><ng-container i18n ><ng-container i18n
>Oops! Could not get the historical exchange rate >Oops! Could not get the historical exchange rate
from</ng-container from</ng-container
> >
{{ activityForm.controls['date']?.value | date: defaultDateFormat {{
activityForm.controls['date']?.value | date: defaultDateFormat
}}</mat-error }}</mat-error
> >
</mat-form-field> </mat-form-field>
<button <button
*ngIf="currentMarketPrice && (data.activity.type === 'BUY' || data.activity.type === 'SELL')" *ngIf="
currentMarketPrice &&
(data.activity.type === 'BUY' || data.activity.type === 'SELL') &&
isToday(activityForm.controls['date']?.value)
"
class="ml-2 mt-1 no-min-width" class="ml-2 mt-1 no-min-width"
mat-button mat-button
title="Apply current market price" title="Apply current market price"
@ -228,14 +268,19 @@
</ng-container> </ng-container>
</mat-label> </mat-label>
<input formControlName="unitPrice" matInput type="number" /> <input formControlName="unitPrice" matInput type="number" />
<span class="ml-2" matTextSuffix <span class="ml-2" matTextSuffix>{{
>{{ activityForm.controls['currency'].value }}</span activityForm.controls['currency'].value
> }}</span>
</mat-form-field> </mat-form-field>
</div> </div>
<div <div
class="mb-3" class="mb-3"
[ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'INTEREST' || activityForm.controls['type']?.value === 'ITEM' || activityForm.controls['type']?.value === 'LIABILITY' }" [ngClass]="{
'd-none':
activityForm.controls['type']?.value === 'INTEREST' ||
activityForm.controls['type']?.value === 'ITEM' ||
activityForm.controls['type']?.value === 'LIABILITY'
}"
> >
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label> <mat-label i18n>Fee</mat-label>
@ -252,11 +297,14 @@
</mat-select> </mat-select>
</div> </div>
<mat-error <mat-error
*ngIf="activityForm.controls['feeInCustomCurrency'].hasError('invalid')" *ngIf="
activityForm.controls['feeInCustomCurrency'].hasError('invalid')
"
><ng-container i18n ><ng-container i18n
>Oops! Could not get the historical exchange rate from</ng-container >Oops! Could not get the historical exchange rate from</ng-container
> >
{{ activityForm.controls['date']?.value | date: defaultDateFormat {{
activityForm.controls['date']?.value | date: defaultDateFormat
}}</mat-error }}</mat-error
> >
</mat-form-field> </mat-form-field>
@ -265,9 +313,9 @@
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label> <mat-label i18n>Fee</mat-label>
<input formControlName="fee" matInput type="number" /> <input formControlName="fee" matInput type="number" />
<span class="ml-2" matTextSuffix <span class="ml-2" matTextSuffix>{{
>{{ activityForm.controls['currency'].value }}</span activityForm.controls['currency'].value
> }}</span>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@ -340,7 +388,7 @@
(optionSelected)="onAddTag($event)" (optionSelected)="onAddTag($event)"
> >
<mat-option <mat-option
*ngFor="let tag of filteredTagsObservable | async" *ngFor="let tag of filteredTagsObservable | async"
[value]="tag.id" [value]="tag.id"
> >
{{ tag.name }} {{ tag.name }}
@ -354,7 +402,10 @@
class="flex-grow-1" class="flex-grow-1"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.user?.settings?.locale" [locale]="data.user?.settings?.locale"
[unit]="activityForm.controls['currency']?.value ?? data.user?.settings?.baseCurrency" [unit]="
activityForm.controls['currency']?.value ??
data.user?.settings?.baseCurrency
"
[value]="total" [value]="total"
/> />
<div> <div>

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

Loading…
Cancel
Save